Compare commits
239 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 |
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 描述中标注。也欢迎你告诉我有哪些部分需要补充或澄清,我可以进一步细化。
|
||||
159
.github/workflows/build-docker.yml
vendored
159
.github/workflows/build-docker.yml
vendored
@@ -7,21 +7,53 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
IMAGE_NAME: krau/saveany-bot
|
||||
|
||||
concurrency:
|
||||
group: docker-build-${{ github.repository }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
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@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -33,28 +65,99 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=sha
|
||||
type=raw,value=latest
|
||||
type=ref,event=branch
|
||||
type=ref,event=tag
|
||||
labels: |
|
||||
org.opencontainers.image.title=${{ env.IMAGE_NAME }}
|
||||
org.opencontainers.image.source=https://github.com/krau/SaveAny-Bot
|
||||
org.opencontainers.image.url=https://github.com/krau/SaveAny-Bot
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
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
|
||||
|
||||
11
.github/workflows/build-release.yml
vendored
11
.github/workflows/build-release.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
uses: softprops/action-gh-release@v2
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
@@ -58,15 +58,14 @@ jobs:
|
||||
goos: ${{ matrix.goos }}
|
||||
goarch: ${{ matrix.goarch }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
executable_compression: upx
|
||||
extra_files: |
|
||||
LICENSE
|
||||
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 }}
|
||||
|
||||
30
.github/workflows/docs.yml
vendored
30
.github/workflows/docs.yml
vendored
@@ -6,17 +6,31 @@ on:
|
||||
paths:
|
||||
- "docs/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- uses: actions/cache@v4
|
||||
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:
|
||||
key: ${{ github.ref }}
|
||||
path: .cache
|
||||
- run: pip install mkdocs-material
|
||||
- run: cd docs && mkdocs gh-deploy --force
|
||||
hugo-version: '0.147.8'
|
||||
extended: true
|
||||
|
||||
- name: Build
|
||||
run: hugo --minify --destination public --source docs
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
if: github.ref == 'refs/heads/main'
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./docs/public
|
||||
publish_branch: gh-pages
|
||||
17
.github/workflows/update-contributors.yml
vendored
17
.github/workflows/update-contributors.yml
vendored
@@ -1,17 +0,0 @@
|
||||
name: Update Contributors
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
contrib-readme-job:
|
||||
runs-on: ubuntu-latest
|
||||
name: A job to automate contrib in readme
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@v2.3.10
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -3,7 +3,10 @@ logs/
|
||||
tmp/
|
||||
data/
|
||||
downloads/
|
||||
cache/
|
||||
session.*
|
||||
cache.db
|
||||
.vscode/
|
||||
.vscode/
|
||||
temp/
|
||||
.hugo_build.lock
|
||||
playwright/
|
||||
testplugins/
|
||||
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
|
||||
31
Dockerfile
31
Dockerfile
@@ -1,18 +1,39 @@
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
ARG VERSION="dev"
|
||||
ARG GitCommit="Unknown"
|
||||
ARG BuildTime="Unknown"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.* ./
|
||||
RUN go mod download
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o saveany-bot .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg \
|
||||
CGO_ENABLED=0 \
|
||||
go build -trimpath \
|
||||
-ldflags=" \
|
||||
-s -w \
|
||||
-X 'github.com/krau/SaveAny-Bot/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 .
|
||||
|
||||
CMD ["./saveany-bot"]
|
||||
RUN chmod +x /app/saveany-bot && \
|
||||
chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
41
Dockerfile.micro
Normal file
41
Dockerfile.micro
Normal file
@@ -0,0 +1,41 @@
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
ARG VERSION="dev"
|
||||
ARG GitCommit="Unknown"
|
||||
ARG BuildTime="Unknown"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg \
|
||||
CGO_ENABLED=0 \
|
||||
go build -trimpath \
|
||||
-tags=no_jsparser,no_minio,no_bubbletea \
|
||||
-ldflags=" \
|
||||
-s -w \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
|
||||
" \
|
||||
-o saveany-bot .
|
||||
|
||||
FROM alpine:latest
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/saveany-bot .
|
||||
COPY entrypoint.sh .
|
||||
|
||||
RUN chmod +x /app/saveany-bot && \
|
||||
chmod +x /app/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["/app/entrypoint.sh"]
|
||||
|
||||
35
Dockerfile.pico
Normal file
35
Dockerfile.pico
Normal file
@@ -0,0 +1,35 @@
|
||||
# pico is the minimum build of SaveAnyBot, which disables all the optional features like JS parsing and MinIO support.
|
||||
FROM golang:alpine AS builder
|
||||
|
||||
ARG VERSION="dev"
|
||||
ARG GitCommit="Unknown"
|
||||
ARG BuildTime="Unknown"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download
|
||||
|
||||
COPY . .
|
||||
RUN --mount=type=cache,target=/root/.cache/go-build \
|
||||
--mount=type=cache,target=/go/pkg \
|
||||
CGO_ENABLED=0 \
|
||||
go build -trimpath \
|
||||
-tags=no_jsparser,no_minio,sqlite_glebarez,no_bubbletea \
|
||||
-ldflags=" \
|
||||
-s -w \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
|
||||
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
|
||||
" \
|
||||
-o saveany-bot . && chmod +x saveany-bot
|
||||
|
||||
FROM scratch
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/saveany-bot .
|
||||
|
||||
ENTRYPOINT ["/app/saveany-bot"]
|
||||
121
README.md
121
README.md
@@ -1,71 +1,92 @@
|
||||
<div align="center">
|
||||
|
||||
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
|
||||
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
|
||||
|
||||
**简体中文** | [English](README_EN.md)
|
||||
**English** | [简体中文](./README_zh.md)
|
||||
|
||||
把 Telegram 的文件保存到各类存储端.
|
||||
> **Save Any Telegram File to Anywhere 📂. Support restrict saving content and beyond telegram.**
|
||||
|
||||
> _就像 PikPak Bot 一样_
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](https://github.com/krau/saveany-bot/releases)
|
||||
[](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>
|
||||
|
||||
## [部署](https://sabot.unv.app/deploy/)
|
||||
## 🎯 Features
|
||||
|
||||
## [参与开发](https://sabot.unv.app/contribute/)
|
||||
- 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)
|
||||
|
||||
---
|
||||
## 📦 Quick Start
|
||||
|
||||
## 赞助
|
||||
Create a `config.toml` file with the following content:
|
||||
|
||||
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
|
||||
```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"
|
||||
|
||||
- [爱发电](https://afdian.com/a/acherkrau)
|
||||
[[users]]
|
||||
id = 114514 # Your Telegram account id
|
||||
storages = []
|
||||
blacklist = true
|
||||
```
|
||||
|
||||
## Contributors
|
||||
Run Save Any Bot with Docker:
|
||||
|
||||
<!-- readme: contributors,AHCorn -start -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/krau">
|
||||
<img src="https://avatars.githubusercontent.com/u/71133316?v=4" width="100;" alt="krau"/>
|
||||
<br />
|
||||
<sub><b>Krau</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/TG-Twilight">
|
||||
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
|
||||
<br />
|
||||
<sub><b>Simon Twilight</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ysicing">
|
||||
<img src="https://avatars.githubusercontent.com/u/8605565?v=4" width="100;" alt="ysicing"/>
|
||||
<br />
|
||||
<sub><b>缘生</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/ahcorn">
|
||||
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="ahcorn"/>
|
||||
<br />
|
||||
<sub><b>安和</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
</table>
|
||||
<!-- readme: contributors,AHCorn -end -->
|
||||
```bash
|
||||
docker run -d --name saveany-bot \
|
||||
-v ./config.toml:/app/config.toml \
|
||||
-v ./downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
## Thanks
|
||||
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)
|
||||
- All the dependencies
|
||||
- [tdl](https://github.com/iyear/tdl)
|
||||
- All the dependencies, contributors, sponsors and users.
|
||||
|
||||
## Contact
|
||||
|
||||
- [](https://t.me/ProjectSaveAny)
|
||||
- [](https://github.com/krau/saveany-bot/discussions)
|
||||
- [](https://t.me/acherkrau)
|
||||
108
README_EN.md
108
README_EN.md
@@ -1,108 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
|
||||
|
||||
[简体中文](README.md) | **English**
|
||||
|
||||
Save Telegram files to various storage endpoints.
|
||||
|
||||
> _Just like PikPak Bot_
|
||||
|
||||
</div>
|
||||
|
||||
## Deployment
|
||||
|
||||
### Deploy from Binary
|
||||
|
||||
Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page.
|
||||
|
||||
Create a `config.toml` file in the extracted directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
chmod +x saveany-bot
|
||||
./saveany-bot
|
||||
```
|
||||
|
||||
#### Add as systemd Service
|
||||
|
||||
Create file `/etc/systemd/system/saveany-bot.service` and write the following content:
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=SaveAnyBot
|
||||
After=systemd-user-sessions.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
WorkingDirectory=/yourpath/
|
||||
ExecStart=/yourpath/saveany-bot
|
||||
Restart=on-failure
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
```
|
||||
|
||||
Enable auto-start and start the service:
|
||||
|
||||
```bash
|
||||
systemctl enable --now saveany-bot
|
||||
```
|
||||
|
||||
### Deploy with Docker
|
||||
|
||||
#### Docker Compose
|
||||
|
||||
Download [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file and create a `config.toml` file in the same directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
#### Docker
|
||||
|
||||
```shell
|
||||
docker run -d --name saveany-bot \
|
||||
-v /path/to/config.toml:/app/config.toml \
|
||||
-v /path/to/downloads:/app/downloads \
|
||||
ghcr.io/krau/saveany-bot:latest
|
||||
```
|
||||
|
||||
## Update
|
||||
|
||||
Use `upgrade` or `up` command to upgrade to the latest version:
|
||||
|
||||
```bash
|
||||
./saveany-bot upgrade
|
||||
```
|
||||
|
||||
If deployed with Docker, use the following commands to update:
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/krau/saveany-bot:latest
|
||||
docker restart saveany-bot
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Send (forward) files to the Bot and follow the prompts.
|
||||
|
||||
---
|
||||
|
||||
## Sponsors
|
||||
|
||||
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
|
||||
|
||||
You can consider sponsoring me if this project helps you:
|
||||
|
||||
- [Afdian](https://afdian.com/a/acherkrau)
|
||||
|
||||
## Thanks
|
||||
|
||||
- [gotd](https://github.com/gotd/td)
|
||||
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
|
||||
- [gotgproto](https://github.com/celestix/gotgproto)
|
||||
- All the dependencies
|
||||
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)
|
||||
103
bot/bot.go
103
bot/bot.go
@@ -1,103 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto"
|
||||
"github.com/celestix/gotgproto/sessionMaker"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/gotd/td/telegram/dcs"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
var Client *gotgproto.Client
|
||||
|
||||
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
|
||||
url, err := url.Parse(proxyUrl)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return proxy.FromURL(url, proxy.Direct)
|
||||
}
|
||||
|
||||
func Init() {
|
||||
common.Log.Info("初始化 Telegram 客户端...")
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Cfg.Telegram.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
go InitTelegraphClient()
|
||||
resultChan := make(chan struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
})
|
||||
go func() {
|
||||
var resolver dcs.Resolver
|
||||
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
|
||||
dialer, err := newProxyDialer(config.Cfg.Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{nil, err}
|
||||
return
|
||||
}
|
||||
resolver = dcs.Plain(dcs.PlainOptions{
|
||||
Dial: dialer.(proxy.ContextDialer).DialContext,
|
||||
})
|
||||
} else {
|
||||
resolver = dcs.DefaultResolver()
|
||||
}
|
||||
client, err := gotgproto.NewClient(config.Cfg.Telegram.AppID,
|
||||
config.Cfg.Telegram.AppHash,
|
||||
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
|
||||
&gotgproto.ClientOpts{
|
||||
Session: sessionMaker.SqlSession(sqlite.Open("data/session.db")),
|
||||
DisableCopyright: true,
|
||||
Middlewares: FloodWaitMiddleware(),
|
||||
Resolver: resolver,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{nil, err}
|
||||
return
|
||||
}
|
||||
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
|
||||
Scope: &tg.BotCommandScopeDefault{},
|
||||
Commands: []tg.BotCommand{
|
||||
{Command: "start", Description: "开始使用"},
|
||||
{Command: "help", Description: "显示帮助"},
|
||||
{Command: "silent", Description: "开启/关闭静默模式"},
|
||||
{Command: "storage", Description: "设置默认存储端"},
|
||||
{Command: "save", Description: "保存所回复的文件"},
|
||||
{Command: "dir", Description: "管理存储文件夹"},
|
||||
{Command: "rule", Description: "管理规则"},
|
||||
},
|
||||
})
|
||||
resultChan <- struct {
|
||||
client *gotgproto.Client
|
||||
err error
|
||||
}{client, err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
common.Log.Fatal("初始化客户端失败: 超时")
|
||||
os.Exit(1)
|
||||
case result := <-resultChan:
|
||||
if result.err != nil {
|
||||
common.Log.Fatalf("初始化客户端失败: %s", result.err)
|
||||
os.Exit(1)
|
||||
}
|
||||
Client = result.client
|
||||
RegisterHandlers(Client.Dispatcher)
|
||||
common.Log.Info("客户端初始化完成")
|
||||
}
|
||||
}
|
||||
@@ -1,207 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/queue"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func AddToQueue(ctx *ext.Context, update *ext.Update) error {
|
||||
// TODO: 回调数据用户独立鉴权 (处理 bot 在群聊中的情况)
|
||||
if !slice.Contain(config.Cfg.GetUsersID(), update.CallbackQuery.UserID) {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "你没有权限",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
args := strings.Split(string(update.CallbackQuery.Data), " ")
|
||||
addToDir := args[0] == "add_to_dir" // 已经选择了路径
|
||||
cbDataId, _ := strconv.Atoi(args[1])
|
||||
cbData, err := dao.GetCallbackData(uint(cbDataId))
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取回调数据失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取回调数据失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
data := strings.Split(cbData, " ")
|
||||
fileChatID, _ := strconv.Atoi(data[0])
|
||||
fileMessageID, _ := strconv.Atoi(data[1])
|
||||
storageName := data[2]
|
||||
dirIdInt, _ := strconv.Atoi(data[3])
|
||||
dirId := uint(dirIdInt)
|
||||
|
||||
user, err := dao.GetUserByChatID(update.CallbackQuery.UserID)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取用户失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
if !addToDir {
|
||||
dirs, err := dao.GetDirsByUserIDAndStorageName(user.ID, storageName)
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
common.Log.Errorf("获取路径失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取路径失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if len(dirs) != 0 {
|
||||
markup, err := getSelectDirMarkup(fileChatID, fileMessageID, storageName, dirs)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取路径失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取路径失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
Message: "请选择要保存到的路径",
|
||||
ReplyMarkup: markup,
|
||||
})
|
||||
if err != nil {
|
||||
common.Log.Errorf("编辑消息失败: %s", err)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
|
||||
common.Log.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName)
|
||||
record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取记录失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "查询记录失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if update.CallbackQuery.MsgID != record.ReplyMessageID {
|
||||
record.ReplyMessageID = update.CallbackQuery.MsgID
|
||||
if err := dao.SaveReceivedFile(record); err != nil {
|
||||
common.Log.Errorf("更新接收的文件失败: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
var dir *dao.Dir
|
||||
if addToDir && dirId != 0 {
|
||||
dir, err = dao.GetDirByID(dirId)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取路径失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取路径失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
|
||||
var task types.Task
|
||||
if record.IsTelegraph {
|
||||
task = types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
IsTelegraph: true,
|
||||
TelegraphURL: record.TelegraphURL,
|
||||
StorageName: storageName,
|
||||
FileChatID: record.ChatID,
|
||||
FileMessageID: record.MessageID,
|
||||
ReplyMessageID: record.ReplyMessageID,
|
||||
ReplyChatID: record.ReplyChatID,
|
||||
UserID: update.GetUserChat().GetID(),
|
||||
}
|
||||
if dir != nil {
|
||||
task.StoragePath = path.Join(dir.Path, record.FileName)
|
||||
}
|
||||
} else {
|
||||
file, err := FileFromMessage(ctx, record.ChatID, record.MessageID, record.FileName)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取消息中的文件失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: fmt.Sprintf("获取消息中的文件失败: %s", err),
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
task = types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: storageName,
|
||||
FileChatID: record.ChatID,
|
||||
ReplyMessageID: record.ReplyMessageID,
|
||||
FileMessageID: record.MessageID,
|
||||
ReplyChatID: record.ReplyChatID,
|
||||
UserID: update.GetUserChat().GetID(),
|
||||
}
|
||||
if dir != nil {
|
||||
task.StoragePath = path.Join(dir.Path, file.FileName)
|
||||
}
|
||||
}
|
||||
|
||||
queue.AddTask(&task)
|
||||
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", record.FileName, queue.Len())
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("已添加到任务队列\n文件名: "),
|
||||
styling.Code(record.FileName),
|
||||
styling.Plain("\n当前排队任务数: "),
|
||||
styling.Bold(strconv.Itoa(queue.Len())),
|
||||
); err != nil {
|
||||
common.Log.Errorf("Failed to build entity: %s", err)
|
||||
} else {
|
||||
text, entities = entityBuilder.Complete()
|
||||
}
|
||||
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
ID: record.ReplyMessageID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/queue"
|
||||
)
|
||||
|
||||
func cancelTask(ctx *ext.Context, update *ext.Update) error {
|
||||
key := strings.Split(string(update.CallbackQuery.Data), " ")[1]
|
||||
ok := queue.CancelTask(key)
|
||||
if ok {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Message: "任务已取消",
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Message: "任务取消失败",
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
type ConversationType string
|
||||
|
||||
type ConversationState struct {
|
||||
sync.Mutex
|
||||
conversationType ConversationType
|
||||
InConversation bool
|
||||
data map[ConversationType]map[string]interface{}
|
||||
}
|
||||
|
||||
func (c *ConversationState) Reset() {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.InConversation = false
|
||||
c.conversationType = ""
|
||||
c.data = make(map[ConversationType]map[string]interface{})
|
||||
}
|
||||
|
||||
func (c *ConversationState) SetConversationType(t ConversationType) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
c.conversationType = t
|
||||
}
|
||||
|
||||
func (c *ConversationState) GetData(key string) interface{} {
|
||||
if c.data == nil || c.data[c.conversationType] == nil {
|
||||
return nil
|
||||
}
|
||||
return c.data[c.conversationType][key]
|
||||
}
|
||||
|
||||
func (c *ConversationState) SetData(key string, value interface{}) {
|
||||
c.Lock()
|
||||
defer c.Unlock()
|
||||
if c.data == nil {
|
||||
c.data = make(map[ConversationType]map[string]interface{})
|
||||
}
|
||||
if c.data[c.conversationType] == nil {
|
||||
c.data[c.conversationType] = make(map[string]interface{})
|
||||
}
|
||||
c.data[c.conversationType][key] = value
|
||||
}
|
||||
|
||||
// TODO: Implement conversation handling
|
||||
// var userConversationState = make(map[int64]*ConversationState)
|
||||
|
||||
// func handleConversation(ctx *ext.Context, update *ext.Update) error {
|
||||
// userID := update.EffectiveUser().GetID()
|
||||
// state, ok := userConversationState[userID]
|
||||
// if !ok {
|
||||
// return dispatcher.ContinueGroups
|
||||
// }
|
||||
// if update.EffectiveMessage.Text == "/cancel" {
|
||||
// state.Reset()
|
||||
// ctx.Reply(update, ext.ReplyTextString("已取消"), nil)
|
||||
// return dispatcher.EndGroups
|
||||
// }
|
||||
// if !state.InConversation {
|
||||
// return dispatcher.ContinueGroups
|
||||
// }
|
||||
// return handleConversationState(ctx, update, state)
|
||||
// }
|
||||
|
||||
// func handleConversationState(ctx *ext.Context, update *ext.Update, state *ConversationState) error {
|
||||
// switch state.conversationType {
|
||||
// default:
|
||||
// common.Log.Errorf("Unknown conversation type: %s", state.conversationType)
|
||||
// }
|
||||
// return dispatcher.EndGroups
|
||||
// }
|
||||
@@ -1,110 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func sendDirHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error {
|
||||
dirs, err := dao.GetUserDirsByChatID(userChatID)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户路径失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户路径失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(
|
||||
[]styling.StyledTextOption{
|
||||
styling.Bold("使用方法: /dir <操作> <参数...>"),
|
||||
styling.Plain("\n\n可用操作:\n"),
|
||||
styling.Code("add"),
|
||||
styling.Plain(" <存储名> <路径> - 添加路径\n"),
|
||||
styling.Code("del"),
|
||||
styling.Plain(" <路径ID> - 删除路径\n"),
|
||||
styling.Plain("\n添加路径示例:\n"),
|
||||
styling.Code("/dir add local1 path/to/dir"),
|
||||
styling.Plain("\n\n删除路径示例:\n"),
|
||||
styling.Code("/dir del 3"),
|
||||
styling.Plain("\n\n当前已添加的路径:\n"),
|
||||
styling.Blockquote(func() string {
|
||||
var sb strings.Builder
|
||||
for _, dir := range dirs {
|
||||
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
|
||||
sb.WriteString(dir.StorageName)
|
||||
sb.WriteString(" - ")
|
||||
sb.WriteString(dir.Path)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
return sb.String()
|
||||
}(), true),
|
||||
},
|
||||
), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func dirCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
|
||||
}
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
switch args[1] {
|
||||
case "add":
|
||||
// /dir add local1 path/to/dir
|
||||
if len(args) < 4 {
|
||||
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
|
||||
}
|
||||
return addDir(ctx, update, user, args[2], args[3])
|
||||
case "del":
|
||||
// /dir del 3
|
||||
if len(args) < 3 {
|
||||
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
|
||||
}
|
||||
dirID, err := strconv.Atoi(args[2])
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("路径ID无效"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return delDir(ctx, update, user, dirID)
|
||||
default:
|
||||
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
|
||||
func addDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, path string) error {
|
||||
if _, err := storage.GetStorageByUserIDAndName(user.ChatID, storageName); err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
if err := dao.CreateDirForUser(user.ID, storageName, path); err != nil {
|
||||
common.Log.Errorf("创建路径失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("创建路径失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("路径添加成功"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func delDir(ctx *ext.Context, update *ext.Update, user *dao.User, dirID int) error {
|
||||
if err := dao.DeleteDirByID(uint(dirID)); err != nil {
|
||||
common.Log.Errorf("删除路径失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("删除路径失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("路径删除成功"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
common.Log.Trace("Got media: ", update.EffectiveMessage.Media.TypeName())
|
||||
supported, err := supportedMediaFilter(update.EffectiveMessage.Message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !supported {
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||
if err != nil {
|
||||
common.Log.Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
media := update.EffectiveMessage.Media
|
||||
file, err := FileFromMedia(media, "")
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取文件失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if file.FileName == "" {
|
||||
file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file)
|
||||
}
|
||||
|
||||
if err := dao.SaveReceivedFile(&dao.ReceivedFile{
|
||||
Processing: false,
|
||||
FileName: file.FileName,
|
||||
ChatID: update.EffectiveChat().GetID(),
|
||||
MessageID: update.EffectiveMessage.ID,
|
||||
ReplyMessageID: msg.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
}); err != nil {
|
||||
common.Log.Errorf("添加接收的文件失败: %s", err)
|
||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("添加接收的文件失败: %s", err),
|
||||
ID: msg.ID,
|
||||
}); err != nil {
|
||||
common.Log.Errorf("编辑消息失败: %s", err)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
if !user.Silent || user.DefaultStorage == "" {
|
||||
return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID)
|
||||
}
|
||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: user.DefaultStorage,
|
||||
FileChatID: update.EffectiveChat().GetID(),
|
||||
ReplyMessageID: msg.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
FileMessageID: update.EffectiveMessage.ID,
|
||||
UserID: user.ChatID,
|
||||
})
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
var (
|
||||
linkRegexString = `t.me/.*/\d+`
|
||||
linkRegex = regexp.MustCompile(linkRegexString)
|
||||
)
|
||||
|
||||
func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
|
||||
common.Log.Trace("Got link message")
|
||||
link := linkRegex.FindString(update.EffectiveMessage.Text)
|
||||
if link == "" {
|
||||
return dispatcher.ContinueGroups
|
||||
}
|
||||
strSlice := strings.Split(link, "/")
|
||||
if len(strSlice) < 3 {
|
||||
return dispatcher.ContinueGroups
|
||||
}
|
||||
messageID, err := strconv.Atoi(strSlice[len(strSlice)-1])
|
||||
if err != nil {
|
||||
common.Log.Errorf("解析消息 ID 失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("无法解析消息 ID"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
var linkChatID int64
|
||||
if len(strSlice) == 3 {
|
||||
chatUsername := strSlice[1]
|
||||
linkChat, err := ctx.ResolveUsername(chatUsername)
|
||||
if err != nil {
|
||||
common.Log.Errorf("解析用户名失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("解析用户名失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if linkChat == nil {
|
||||
common.Log.Errorf("无法找到聊天: %s", chatUsername)
|
||||
ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
linkChatID = linkChat.GetID()
|
||||
} else if len(strSlice) == 4 {
|
||||
chatID, err := strconv.Atoi(strSlice[2])
|
||||
if err != nil {
|
||||
common.Log.Errorf("解析 Chat ID 失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("解析 Chat ID 失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
linkChatID = int64(chatID)
|
||||
} else {
|
||||
ctx.Reply(update, ext.ReplyTextString("无法解析链接"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
|
||||
if err != nil {
|
||||
common.Log.Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
file, err := FileFromMessage(ctx, linkChatID, messageID, "")
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取文件失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if file.FileName == "" {
|
||||
file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file)
|
||||
}
|
||||
|
||||
receivedFile := &dao.ReceivedFile{
|
||||
Processing: false,
|
||||
FileName: file.FileName,
|
||||
ChatID: linkChatID,
|
||||
MessageID: messageID,
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
}
|
||||
if err := dao.SaveReceivedFile(receivedFile); err != nil {
|
||||
common.Log.Errorf("保存接收的文件失败: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "无法保存文件: " + err.Error(),
|
||||
ID: replied.ID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !user.Silent || user.DefaultStorage == "" {
|
||||
return ProvideSelectMessage(ctx, update, file.FileName, linkChatID, messageID, replied.ID)
|
||||
}
|
||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: user.DefaultStorage,
|
||||
UserID: user.ChatID,
|
||||
FileChatID: linkChatID,
|
||||
FileMessageID: messageID,
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
})
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
func sendRuleHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error {
|
||||
user, err := dao.GetUserByChatID(userChatID)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户规则失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(
|
||||
[]styling.StyledTextOption{
|
||||
styling.Bold("使用方法: /rule <操作> <参数...>"),
|
||||
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[user.ApplyRule])),
|
||||
styling.Plain("\n\n可用操作:\n"),
|
||||
styling.Code("switch"),
|
||||
styling.Plain(" - 开关规则模式\n"),
|
||||
styling.Code("add"),
|
||||
styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"),
|
||||
styling.Code("del"),
|
||||
styling.Plain(" <规则ID> - 删除规则\n"),
|
||||
styling.Plain("\n当前已添加的规则:\n"),
|
||||
styling.Blockquote(func() string {
|
||||
var sb strings.Builder
|
||||
for _, rule := range user.Rules {
|
||||
ruleText := fmt.Sprintf("%s %s %s %s", rule.Type, rule.Data, rule.StorageName, rule.DirPath)
|
||||
sb.WriteString(fmt.Sprintf("%d: %s\n", rule.ID, ruleText))
|
||||
}
|
||||
return sb.String()
|
||||
}(), true),
|
||||
},
|
||||
), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func ruleCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) < 2 {
|
||||
return sendRuleHelp(ctx, update, update.GetUserChat().GetID())
|
||||
}
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
switch args[1] {
|
||||
case "switch":
|
||||
// /rule switch
|
||||
return switchApplyRule(ctx, update, user)
|
||||
case "add":
|
||||
// /rule add <type> <data> <storage> <dirpath>
|
||||
if len(args) < 6 {
|
||||
return sendRuleHelp(ctx, update, user.ChatID)
|
||||
}
|
||||
return addRule(ctx, update, user, args)
|
||||
case "del":
|
||||
// /rule del <id>
|
||||
if len(args) < 3 {
|
||||
return sendRuleHelp(ctx, update, user.ChatID)
|
||||
}
|
||||
ruleID := args[2]
|
||||
id, err := strconv.Atoi(ruleID)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if err := dao.DeleteRule(uint(id)); err != nil {
|
||||
common.Log.Errorf("删除规则失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
|
||||
return dispatcher.EndGroups
|
||||
default:
|
||||
return sendRuleHelp(ctx, update, user.ChatID)
|
||||
}
|
||||
}
|
||||
|
||||
func switchApplyRule(ctx *ext.Context, update *ext.Update, user *dao.User) error {
|
||||
applyRule := !user.ApplyRule
|
||||
if err := dao.UpdateUserApplyRule(user.ChatID, applyRule); err != nil {
|
||||
common.Log.Errorf("更新用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if applyRule {
|
||||
ctx.Reply(update, ext.ReplyTextString("已启用规则模式"), nil)
|
||||
} else {
|
||||
ctx.Reply(update, ext.ReplyTextString("已禁用规则模式"), nil)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func addRule(ctx *ext.Context, update *ext.Update, user *dao.User, args []string) error {
|
||||
// /rule add <type> <data> <storage> <dirpath>
|
||||
ruleType := args[2]
|
||||
ruleData := args[3]
|
||||
storageName := args[4]
|
||||
dirPath := args[5]
|
||||
|
||||
if !slice.Contain(types.RuleTypes, types.RuleType(ruleType)) {
|
||||
var ruleTypesStylingArray []styling.StyledTextOption
|
||||
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Bold("无效的规则类型, 可用类型:\n"))
|
||||
for i, ruleType := range types.RuleTypes {
|
||||
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Code(string(ruleType)))
|
||||
if i != len(types.RuleTypes)-1 {
|
||||
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Plain(", "))
|
||||
}
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextStyledTextArray(ruleTypesStylingArray), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
rule := &dao.Rule{
|
||||
Type: ruleType,
|
||||
Data: ruleData,
|
||||
StorageName: storageName,
|
||||
DirPath: dirPath,
|
||||
UserID: user.ID,
|
||||
}
|
||||
if err := dao.CreateRule(rule); err != nil {
|
||||
common.Log.Errorf("添加规则失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("添加规则失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("添加规则成功"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,263 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/queue"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
func sendSaveHelp(ctx *ext.Context, update *ext.Update) error {
|
||||
helpText := `
|
||||
使用方法:
|
||||
|
||||
1. 使用该命令回复要保存的文件, 可选文件名参数.
|
||||
示例:
|
||||
/save custom_file_name.mp4
|
||||
|
||||
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
|
||||
示例:
|
||||
/save @moreacg 114-514
|
||||
`
|
||||
ctx.Reply(update, ext.ReplyTextString(helpText), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func saveCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
args := strings.Split(update.EffectiveMessage.Text, " ")
|
||||
if len(args) >= 3 {
|
||||
return handleBatchSave(ctx, update, args[1:])
|
||||
}
|
||||
|
||||
replyToMsgID := func() int {
|
||||
res, ok := update.EffectiveMessage.GetReplyTo()
|
||||
if !ok || res == nil {
|
||||
return 0
|
||||
}
|
||||
replyHeader, ok := res.(*tg.MessageReplyHeader)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
replyToMsgID, ok := replyHeader.GetReplyToMsgID()
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return replyToMsgID
|
||||
}()
|
||||
if replyToMsgID == 0 {
|
||||
return sendSaveHelp(ctx, update)
|
||||
}
|
||||
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
msg, err := GetTGMessage(ctx, update.EffectiveChat().GetID(), replyToMsgID)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取消息失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("无法获取消息"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
supported, _ := supportedMediaFilter(msg)
|
||||
if !supported {
|
||||
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型或消息中没有文件"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
|
||||
if err != nil {
|
||||
common.Log.Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
cmdText := update.EffectiveMessage.Text
|
||||
customFileName := strings.TrimSpace(strings.TrimPrefix(cmdText, "/save"))
|
||||
|
||||
file, err := FileFromMessage(ctx, update.EffectiveChat().GetID(), msg.ID, customFileName)
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取文件失败: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("获取文件失败: %s", err),
|
||||
ID: replied.ID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
if file.FileName == "" {
|
||||
file.FileName = GenFileNameFromMessage(*msg, file)
|
||||
}
|
||||
receivedFile := &dao.ReceivedFile{
|
||||
Processing: false,
|
||||
FileName: file.FileName,
|
||||
ChatID: update.EffectiveChat().GetID(),
|
||||
MessageID: replyToMsgID,
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
}
|
||||
|
||||
if err := dao.SaveReceivedFile(receivedFile); err != nil {
|
||||
common.Log.Errorf("保存接收的文件失败: %s", err)
|
||||
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("保存接收的文件失败: %s", err),
|
||||
ID: replied.ID,
|
||||
}); err != nil {
|
||||
common.Log.Errorf("编辑消息失败: %s", err)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !user.Silent || user.DefaultStorage == "" {
|
||||
return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), msg.ID, replied.ID)
|
||||
}
|
||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: user.DefaultStorage,
|
||||
FileChatID: update.EffectiveChat().GetID(),
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
FileMessageID: msg.ID,
|
||||
UserID: user.ChatID,
|
||||
})
|
||||
}
|
||||
|
||||
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
|
||||
// args: [0] = @channel, [1] = 114-514
|
||||
chatArg := args[0]
|
||||
var chatID int64
|
||||
var err error
|
||||
msgIdSlice := strings.Split(args[1], "-")
|
||||
if len(msgIdSlice) != 2 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
minMsgID, minerr := strconv.ParseInt(msgIdSlice[0], 10, 64)
|
||||
maxMsgID, maxerr := strconv.ParseInt(msgIdSlice[1], 10, 64)
|
||||
if minerr != nil || maxerr != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if minMsgID > maxMsgID || minMsgID <= 0 || maxMsgID <= 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if user.DefaultStorage == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("请先设置默认存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
if strings.HasPrefix(chatArg, "@") {
|
||||
chatUsername := strings.TrimPrefix(chatArg, "@")
|
||||
chat, err := ctx.ResolveUsername(chatUsername)
|
||||
if err != nil {
|
||||
common.Log.Errorf("解析频道用户名失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("解析频道用户名失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if chat == nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
chatID = chat.GetID()
|
||||
} else {
|
||||
chatID, err = strconv.ParseInt(chatArg, 10, 64)
|
||||
if err != nil {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
}
|
||||
if chatID == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在批量保存..."), nil)
|
||||
if err != nil {
|
||||
common.Log.Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
total := maxMsgID - minMsgID + 1
|
||||
successadd := 0
|
||||
failedGetFile := 0
|
||||
failedGetMsg := 0
|
||||
failedSaveDB := 0
|
||||
for i := minMsgID; i <= maxMsgID; i++ {
|
||||
file, err := FileFromMessage(ctx, chatID, int(i), "")
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取文件失败: %s", err)
|
||||
failedGetFile++
|
||||
continue
|
||||
}
|
||||
if file.FileName == "" {
|
||||
message, err := GetTGMessage(ctx, chatID, int(i))
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取消息失败: %s", err)
|
||||
failedGetMsg++
|
||||
continue
|
||||
}
|
||||
file.FileName = GenFileNameFromMessage(*message, file)
|
||||
}
|
||||
receivedFile := &dao.ReceivedFile{
|
||||
Processing: false,
|
||||
FileName: file.FileName,
|
||||
ChatID: chatID,
|
||||
MessageID: int(i),
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
ReplyMessageID: 0,
|
||||
}
|
||||
if err := dao.SaveReceivedFile(receivedFile); err != nil {
|
||||
common.Log.Errorf("保存接收的文件失败: %s", err)
|
||||
failedSaveDB++
|
||||
continue
|
||||
}
|
||||
task := &types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
File: file,
|
||||
StorageName: user.DefaultStorage,
|
||||
FileChatID: chatID,
|
||||
FileMessageID: int(i),
|
||||
UserID: user.ChatID,
|
||||
ReplyMessageID: 0,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
}
|
||||
queue.AddTask(task)
|
||||
successadd++
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("批量保存完成\n成功添加: %d/%d\n获取文件失败: %d\n获取消息失败: %d\n保存数据库失败: %d", successadd, total, failedGetFile, failedGetMsg, failedSaveDB),
|
||||
ID: replied.ID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
)
|
||||
|
||||
func silent(ctx *ext.Context, update *ext.Update) error {
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
if !user.Silent && user.DefaultStorage == "" {
|
||||
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user.Silent = !user.Silent
|
||||
if err := dao.UpdateUser(user); err != nil {
|
||||
common.Log.Errorf("更新用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
)
|
||||
|
||||
func start(ctx *ext.Context, update *ext.Update) error {
|
||||
if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil {
|
||||
common.Log.Errorf("创建用户失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return help(ctx, update)
|
||||
}
|
||||
|
||||
const helpText string = `
|
||||
Save Any Bot - 转存你的 Telegram 文件
|
||||
版本: %s , 提交: %s
|
||||
命令:
|
||||
/start - 开始使用
|
||||
/help - 显示帮助
|
||||
/silent - 开关静默模式
|
||||
/storage - 设置默认存储位置
|
||||
/save [自定义文件名] - 保存文件
|
||||
|
||||
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
|
||||
|
||||
默认存储位置: 在静默模式下保存到的位置
|
||||
|
||||
向 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件
|
||||
`
|
||||
|
||||
func help(ctx *ext.Context, update *ext.Update) error {
|
||||
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, common.Version, common.GitCommit[:7])), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
)
|
||||
|
||||
func storageCmd(ctx *ext.Context, update *ext.Update) error {
|
||||
userChatID := update.GetUserChat().GetID()
|
||||
storages := storage.GetUserStorages(userChatID)
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
markup, err := getSetDefaultStorageMarkup(userChatID, storages)
|
||||
if err != nil {
|
||||
common.Log.Errorf("Failed to get markup: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取存储位置失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
|
||||
Markup: markup,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
|
||||
args := strings.Split(string(update.CallbackQuery.Data), " ")
|
||||
userID, _ := strconv.Atoi(args[1])
|
||||
if userID != int(update.CallbackQuery.GetUserID()) {
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "你没有权限",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
cbDataId, _ := strconv.Atoi(args[2])
|
||||
storageName, err := dao.GetCallbackData(uint(cbDataId))
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取回调数据失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取回调数据失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
selectedStorage, err := storage.GetStorageByName(storageName)
|
||||
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取指定存储失败: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取指定存储失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user, err := dao.GetUserByChatID(int64(userID))
|
||||
if err != nil {
|
||||
common.Log.Errorf("Failed to get user: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "获取用户失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user.DefaultStorage = storageName
|
||||
if err := dao.UpdateUser(user); err != nil {
|
||||
common.Log.Errorf("Failed to update user: %s", err)
|
||||
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
|
||||
QueryID: update.CallbackQuery.QueryID,
|
||||
Alert: true,
|
||||
Message: "更新用户失败",
|
||||
CacheTime: 5,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("已将 %s (%s) 设为默认存储位置", selectedStorage.Name(), selectedStorage.Type()),
|
||||
ID: update.CallbackQuery.GetMsgID(),
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/celestix/telegraph-go/v2"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
var (
|
||||
TelegraphClient *telegraph.TelegraphClient
|
||||
TelegraphUrlRegexString = `https://telegra.ph/.*`
|
||||
TelegraphUrlRegex = regexp.MustCompile(TelegraphUrlRegexString)
|
||||
)
|
||||
|
||||
func InitTelegraphClient() {
|
||||
var httpClient *http.Client
|
||||
if config.Cfg.Telegram.Proxy.Enable {
|
||||
proxyUrl, err := url.Parse(config.Cfg.Telegram.Proxy.URL)
|
||||
if err != nil {
|
||||
fmt.Println("Error parsing proxy URL:", err)
|
||||
return
|
||||
}
|
||||
proxy := http.ProxyURL(proxyUrl)
|
||||
httpClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: proxy,
|
||||
},
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
} else {
|
||||
httpClient = &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
}
|
||||
TelegraphClient = telegraph.GetTelegraphClient(&telegraph.ClientOpt{HttpClient: httpClient})
|
||||
}
|
||||
|
||||
func handleTelegraph(ctx *ext.Context, update *ext.Update) error {
|
||||
common.Log.Trace("Got telegraph link")
|
||||
tgphUrl := TelegraphUrlRegex.FindString(update.EffectiveMessage.Text)
|
||||
if tgphUrl == "" {
|
||||
return dispatcher.ContinueGroups
|
||||
}
|
||||
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
|
||||
if err != nil {
|
||||
common.Log.Errorf("回复失败: %s", err)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
|
||||
if err != nil {
|
||||
common.Log.Errorf("获取用户失败: %s", err)
|
||||
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
|
||||
if len(storages) == 0 {
|
||||
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
tgphPath := strings.Split(tgphUrl, "/")[len(strings.Split(tgphUrl, "/"))-1]
|
||||
fileName, err := url.PathUnescape(tgphPath)
|
||||
if err != nil {
|
||||
common.Log.Errorf("解析 Telegraph 路径失败: %s", err)
|
||||
fileName = tgphPath
|
||||
}
|
||||
|
||||
record := &dao.ReceivedFile{
|
||||
Processing: false,
|
||||
FileName: fileName,
|
||||
ChatID: update.EffectiveChat().GetID(),
|
||||
MessageID: update.EffectiveMessage.GetID(),
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.EffectiveChat().GetID(),
|
||||
IsTelegraph: true,
|
||||
TelegraphURL: tgphUrl,
|
||||
}
|
||||
if err := dao.SaveReceivedFile(record); err != nil {
|
||||
common.Log.Errorf("保存接收的文件失败: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "无法保存文件: " + err.Error(),
|
||||
ID: replied.ID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
if !user.Silent || user.DefaultStorage == "" {
|
||||
return ProvideSelectMessage(ctx, update, fileName, update.EffectiveChat().GetID(), update.EffectiveMessage.GetID(), replied.ID)
|
||||
}
|
||||
return HandleSilentAddTask(ctx, update, user, &types.Task{
|
||||
Ctx: ctx,
|
||||
Status: types.Pending,
|
||||
StorageName: user.DefaultStorage,
|
||||
UserID: user.ChatID,
|
||||
ReplyMessageID: replied.ID,
|
||||
ReplyChatID: update.GetUserChat().GetID(),
|
||||
IsTelegraph: true,
|
||||
TelegraphURL: tgphUrl,
|
||||
})
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers"
|
||||
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
)
|
||||
|
||||
func RegisterHandlers(dispatcher dispatcher.Dispatcher) {
|
||||
dispatcher.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
|
||||
dispatcher.AddHandler(handlers.NewCommand("start", start))
|
||||
dispatcher.AddHandler(handlers.NewCommand("help", help))
|
||||
dispatcher.AddHandler(handlers.NewCommand("silent", silent))
|
||||
dispatcher.AddHandler(handlers.NewCommand("storage", storageCmd))
|
||||
dispatcher.AddHandler(handlers.NewCommand("save", saveCmd))
|
||||
dispatcher.AddHandler(handlers.NewCommand("dir", dirCmd))
|
||||
dispatcher.AddHandler(handlers.NewCommand("rule", ruleCmd))
|
||||
linkRegexFilter, err := filters.Message.Regex(linkRegexString)
|
||||
if err != nil {
|
||||
common.Log.Panicf("创建正则表达式过滤器失败: %s", err)
|
||||
}
|
||||
dispatcher.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage))
|
||||
telegraphUrlRegexFilter, err := filters.Message.Regex(TelegraphUrlRegexString)
|
||||
if err != nil {
|
||||
common.Log.Panicf("创建 Telegraph URL 正则表达式过滤器失败: %s", err)
|
||||
}
|
||||
dispatcher.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleTelegraph))
|
||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
|
||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
|
||||
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), cancelTask))
|
||||
dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/gotd/contrib/middleware/floodwait"
|
||||
"github.com/gotd/contrib/middleware/ratelimit"
|
||||
"github.com/gotd/td/telegram"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
func FloodWaitMiddleware() []telegram.Middleware {
|
||||
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(5)
|
||||
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
|
||||
return []telegram.Middleware{
|
||||
waiter,
|
||||
ratelimiter,
|
||||
}
|
||||
}
|
||||
|
||||
const noPermissionText string = `
|
||||
您不在白名单中, 无法使用此 Bot.
|
||||
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
|
||||
`
|
||||
|
||||
func checkPermission(ctx *ext.Context, update *ext.Update) error {
|
||||
userID := update.GetUserChat().GetID()
|
||||
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
|
||||
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
return dispatcher.ContinueGroups
|
||||
}
|
||||
329
bot/utils.go
329
bot/utils.go
@@ -1,329 +0,0 @@
|
||||
package bot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/celestix/gotgproto/dispatcher"
|
||||
"github.com/celestix/gotgproto/ext"
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gotd/td/telegram/message/entity"
|
||||
"github.com/gotd/td/telegram/message/styling"
|
||||
"github.com/gotd/td/tg"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"github.com/krau/SaveAny-Bot/dao"
|
||||
"github.com/krau/SaveAny-Bot/queue"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/krau/SaveAny-Bot/types"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrEmptyDocument = errors.New("document is empty")
|
||||
ErrEmptyPhoto = errors.New("photo is empty")
|
||||
ErrEmptyPhotoSize = errors.New("photo size is empty")
|
||||
ErrEmptyPhotoSizes = errors.New("photo size slice is empty")
|
||||
ErrNoStorages = errors.New("no available storage")
|
||||
ErrEmptyMessage = errors.New("message is empty")
|
||||
)
|
||||
|
||||
func supportedMediaFilter(m *tg.Message) (bool, error) {
|
||||
if not := m.Media == nil; not {
|
||||
return false, dispatcher.EndGroups
|
||||
}
|
||||
switch m.Media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
return true, nil
|
||||
case *tg.MessageMediaPhoto:
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func getSelectStorageMarkup(userChatID int64, fileChatID, fileMessageID int) (*tg.ReplyInlineMarkup, error) {
|
||||
user, err := dao.GetUserByChatID(userChatID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get user by chat ID: %d, error: %w", userChatID, err)
|
||||
}
|
||||
storages := storage.GetUserStorages(user.ChatID)
|
||||
if len(storages) == 0 {
|
||||
return nil, ErrNoStorages
|
||||
}
|
||||
|
||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||
for _, storage := range storages {
|
||||
cbData := fmt.Sprintf("%d %d %s 0", fileChatID, fileMessageID, storage.Name()) // 0 for empty dir id
|
||||
cbDataId, err := dao.CreateCallbackData(cbData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create callback data: %w", err)
|
||||
}
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: storage.Name(),
|
||||
Data: fmt.Appendf(nil, "add %d", cbDataId),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
for i := 0; i < len(buttons); i += 3 {
|
||||
row := tg.KeyboardButtonRow{}
|
||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||
markup.Rows = append(markup.Rows, row)
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
|
||||
func getSelectDirMarkup(fileChatID, fileMessageID int, storageName string, dirs []dao.Dir) (*tg.ReplyInlineMarkup, error) {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||
for _, dir := range dirs {
|
||||
if dir.ID == 0 || dir.StorageName != storageName {
|
||||
return nil, fmt.Errorf("unexpected dir: %v", dir)
|
||||
}
|
||||
cbDataId, err := dao.CreateCallbackData(fmt.Sprintf("%d %d %s %d", fileChatID, fileMessageID, storageName, dir.ID))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create callback data: %w", err)
|
||||
}
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: dir.Path,
|
||||
Data: []byte(fmt.Sprintf("add_to_dir %d", cbDataId)),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
for i := 0; i < len(buttons); i += 3 {
|
||||
row := tg.KeyboardButtonRow{}
|
||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||
markup.Rows = append(markup.Rows, row)
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
|
||||
func getSetDefaultStorageMarkup(userChatID int64, storages []storage.Storage) (*tg.ReplyInlineMarkup, error) {
|
||||
buttons := make([]tg.KeyboardButtonClass, 0)
|
||||
for _, storage := range storages {
|
||||
cbDataId, err := dao.CreateCallbackData(storage.Name())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create callback data: %w", err)
|
||||
}
|
||||
buttons = append(buttons, &tg.KeyboardButtonCallback{
|
||||
Text: storage.Name(),
|
||||
Data: []byte(fmt.Sprintf("set_default %d %d", userChatID, cbDataId)),
|
||||
})
|
||||
}
|
||||
markup := &tg.ReplyInlineMarkup{}
|
||||
for i := 0; i < len(buttons); i += 3 {
|
||||
row := tg.KeyboardButtonRow{}
|
||||
row.Buttons = buttons[i:min(i+3, len(buttons))]
|
||||
markup.Rows = append(markup.Rows, row)
|
||||
}
|
||||
return markup, nil
|
||||
}
|
||||
|
||||
func FileFromMedia(media tg.MessageMediaClass, customFileName string) (*types.File, error) {
|
||||
switch media := media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
document, ok := media.Document.AsNotEmpty()
|
||||
if !ok {
|
||||
return nil, ErrEmptyDocument
|
||||
}
|
||||
if customFileName != "" {
|
||||
return &types.File{
|
||||
Location: document.AsInputDocumentFileLocation(),
|
||||
FileSize: document.Size,
|
||||
FileName: customFileName,
|
||||
}, nil
|
||||
}
|
||||
fileName := ""
|
||||
for _, attribute := range document.Attributes {
|
||||
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
|
||||
fileName = name.GetFileName()
|
||||
break
|
||||
}
|
||||
}
|
||||
return &types.File{
|
||||
Location: document.AsInputDocumentFileLocation(),
|
||||
FileSize: document.Size,
|
||||
FileName: fileName,
|
||||
}, nil
|
||||
case *tg.MessageMediaPhoto:
|
||||
photo, ok := media.Photo.AsNotEmpty()
|
||||
if !ok {
|
||||
return nil, ErrEmptyPhoto
|
||||
}
|
||||
sizes := photo.Sizes
|
||||
if len(sizes) == 0 {
|
||||
return nil, ErrEmptyPhotoSizes
|
||||
}
|
||||
photoSize := sizes[len(sizes)-1]
|
||||
size, ok := photoSize.AsNotEmpty()
|
||||
if !ok {
|
||||
return nil, ErrEmptyPhotoSize
|
||||
}
|
||||
location := new(tg.InputPhotoFileLocation)
|
||||
location.ID = photo.GetID()
|
||||
location.AccessHash = photo.GetAccessHash()
|
||||
location.FileReference = photo.GetFileReference()
|
||||
location.ThumbSize = size.GetType()
|
||||
fileName := customFileName
|
||||
if fileName == "" {
|
||||
fileName = fmt.Sprintf("photo_%s_%d.jpg", time.Now().Format("2006-01-02_15-04-05"), photo.GetID())
|
||||
}
|
||||
return &types.File{
|
||||
Location: location,
|
||||
FileSize: 0,
|
||||
FileName: fileName,
|
||||
}, nil
|
||||
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected type %T", media)
|
||||
}
|
||||
|
||||
func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileName string) (*types.File, error) {
|
||||
key := fmt.Sprintf("file:%d:%d", chatID, messageID)
|
||||
cachedFile, err := common.CacheGet[*types.File](ctx, key)
|
||||
if err == nil {
|
||||
return cachedFile, nil
|
||||
}
|
||||
common.Log.Debugf("Getting file: %s", key)
|
||||
message, err := GetTGMessage(ctx, chatID, messageID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := FileFromMedia(message.Media, customFileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := common.CacheSet(ctx, key, file); err != nil {
|
||||
common.Log.Errorf("Failed to cache file: %s", err)
|
||||
}
|
||||
return file, nil
|
||||
}
|
||||
|
||||
func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) {
|
||||
key := fmt.Sprintf("message:%d:%d", chatId, messageID)
|
||||
cacheMessage, err := common.CacheGet[*tg.Message](ctx, key)
|
||||
if err == nil {
|
||||
return cacheMessage, nil
|
||||
}
|
||||
common.Log.Debugf("Fetching message: %d", messageID)
|
||||
messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(messages) == 0 {
|
||||
return nil, ErrEmptyMessage
|
||||
}
|
||||
msg := messages[0]
|
||||
tgMessage, ok := msg.(*tg.Message)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected message type: %T", msg)
|
||||
}
|
||||
if err := common.CacheSet(ctx, key, tgMessage); err != nil {
|
||||
common.Log.Errorf("Failed to cache message: %s", err)
|
||||
}
|
||||
return tgMessage, nil
|
||||
}
|
||||
|
||||
func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, fileName string, chatID int64, fileMsgID, toEditMsgID int) error {
|
||||
entityBuilder := entity.Builder{}
|
||||
var entities []tg.MessageEntityClass
|
||||
text := fmt.Sprintf("文件名: %s\n请选择存储位置", fileName)
|
||||
if err := styling.Perform(&entityBuilder,
|
||||
styling.Plain("文件名: "),
|
||||
styling.Code(fileName),
|
||||
styling.Plain("\n请选择存储位置"),
|
||||
); err != nil {
|
||||
common.Log.Errorf("Failed to build entity: %s", err)
|
||||
} else {
|
||||
text, entities = entityBuilder.Complete()
|
||||
}
|
||||
markup, err := getSelectStorageMarkup(update.GetUserChat().GetID(), int(chatID), fileMsgID)
|
||||
if errors.Is(err, ErrNoStorages) {
|
||||
common.Log.Errorf("Failed to get select storage markup: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "无可用存储",
|
||||
ID: toEditMsgID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
} else if err != nil {
|
||||
common.Log.Errorf("Failed to get select storage markup: %s", err)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "无法获取存储",
|
||||
ID: toEditMsgID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: text,
|
||||
Entities: entities,
|
||||
ReplyMarkup: markup,
|
||||
ID: toEditMsgID,
|
||||
})
|
||||
if err != nil {
|
||||
common.Log.Errorf("Failed to reply: %s", err)
|
||||
}
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func HandleSilentAddTask(ctx *ext.Context, update *ext.Update, user *dao.User, task *types.Task) error {
|
||||
if user.DefaultStorage == "" {
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: "请先使用 /storage 设置默认存储位置",
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
queue.AddTask(task)
|
||||
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
|
||||
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", task.FileName(), queue.Len()),
|
||||
ID: task.ReplyMessageID,
|
||||
})
|
||||
return dispatcher.EndGroups
|
||||
}
|
||||
|
||||
func GenFileNameFromMessage(message tg.Message, file *types.File) string {
|
||||
if file.FileName != "" {
|
||||
return file.FileName
|
||||
}
|
||||
fileName := genFileNameFromMessageText(message, file)
|
||||
media, ok := message.GetMedia()
|
||||
if !ok {
|
||||
return fileName
|
||||
}
|
||||
ext, ok := extraMediaExt(media)
|
||||
if ok {
|
||||
return fileName + ext
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
func genFileNameFromMessageText(message tg.Message, file *types.File) string {
|
||||
text := strings.TrimSpace(message.GetMessage())
|
||||
if text == "" {
|
||||
return file.Hash()
|
||||
}
|
||||
tags := common.ExtractTagsFromText(text)
|
||||
if len(tags) > 0 {
|
||||
return fmt.Sprintf("%s_%s", strings.Join(tags, "_"), strconv.Itoa(message.GetID()))
|
||||
}
|
||||
runes := []rune(text)
|
||||
return string(runes[:min(128, len(runes))])
|
||||
}
|
||||
|
||||
func extraMediaExt(media tg.MessageMediaClass) (string, bool) {
|
||||
switch media := media.(type) {
|
||||
case *tg.MessageMediaDocument:
|
||||
doc, ok := media.Document.AsNotEmpty()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
ext := mimetype.Lookup(doc.MimeType).Extension()
|
||||
if ext == "" {
|
||||
return "", false
|
||||
}
|
||||
return ext, true
|
||||
case *tg.MessageMediaPhoto:
|
||||
return ".jpg", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
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, "")
|
||||
}
|
||||
12
cmd/root.go
12
cmd/root.go
@@ -1,8 +1,11 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/cmd/upload"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -12,8 +15,13 @@ var rootCmd = &cobra.Command{
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
112
cmd/run.go
112
cmd/run.go
@@ -1,67 +1,107 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"slices"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/bot"
|
||||
"github.com/krau/SaveAny-Bot/common"
|
||||
"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/dao"
|
||||
"github.com/krau/SaveAny-Bot/database"
|
||||
"github.com/krau/SaveAny-Bot/parsers"
|
||||
"github.com/krau/SaveAny-Bot/storage"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func Run(_ *cobra.Command, _ []string) {
|
||||
InitAll()
|
||||
core.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)
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
sig := <-quit
|
||||
common.Log.Info(sig, ", exitting...")
|
||||
defer common.Log.Info("Bye!")
|
||||
if config.Cfg.NoCleanCache {
|
||||
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.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
|
||||
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
|
||||
common.Log.Error("无效的缓存文件夹: ", config.Cfg.Temp.BasePath)
|
||||
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 {
|
||||
common.Log.Error("获取工作目录失败: ", err)
|
||||
log.Error("Failed to get working directory", "error", err)
|
||||
return
|
||||
}
|
||||
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
|
||||
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
|
||||
cachePath, err = filepath.Abs(cachePath)
|
||||
if err != nil {
|
||||
common.Log.Error("获取缓存绝对路径失败: ", err)
|
||||
log.Error("Failed to get absolute cache path", "error", err)
|
||||
return
|
||||
}
|
||||
common.Log.Info("正在清理缓存文件夹: ", cachePath)
|
||||
if err := os.RemoveAll(cachePath); err != nil {
|
||||
common.Log.Error("清理缓存失败: ", err)
|
||||
log.Info("Cleaning cache directory", "path", cachePath)
|
||||
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
|
||||
log.Error("Failed to clean cache directory", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func InitAll() {
|
||||
if err := config.Init(); err != nil {
|
||||
fmt.Println("加载配置文件失败: ", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
common.InitLogger()
|
||||
common.Log.Info("正在启动 SaveAny-Bot...")
|
||||
dao.Init()
|
||||
storage.LoadStorages()
|
||||
common.Init()
|
||||
bot.Init()
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/eko/gocache/lib/v4/cache"
|
||||
gocachestore "github.com/eko/gocache/store/go_cache/v4"
|
||||
gocache "github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
var Cache *cache.Cache[any]
|
||||
|
||||
func initCache() {
|
||||
gocacheClient := gocache.New(time.Hour*1, time.Minute*10)
|
||||
gocacheStore := gocachestore.NewGoCache(gocacheClient)
|
||||
cacheManager := cache.New[any](gocacheStore)
|
||||
Cache = cacheManager
|
||||
}
|
||||
|
||||
func CacheGet[T any](ctx context.Context, key string) (T, error) {
|
||||
data, err := Cache.Get(ctx, key)
|
||||
if err != nil {
|
||||
return *new(T), err
|
||||
}
|
||||
if v, ok := data.(T); ok {
|
||||
return v, nil
|
||||
}
|
||||
return *new(T), nil
|
||||
}
|
||||
|
||||
func CacheSet(ctx context.Context, key string, value any) error {
|
||||
return Cache.Set(ctx, key, value)
|
||||
}
|
||||
|
||||
func CacheDelete(ctx context.Context, key string) error {
|
||||
return Cache.Delete(ctx, key)
|
||||
}
|
||||
53
common/cache/ristretto.go
vendored
Normal file
53
common/cache/ristretto.go
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/log"
|
||||
"github.com/dgraph-io/ristretto/v2"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
var cache *ristretto.Cache[string, any]
|
||||
|
||||
func Init() {
|
||||
if cache != nil {
|
||||
panic("cache already initialized")
|
||||
}
|
||||
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
|
||||
NumCounters: config.C().Cache.NumCounters,
|
||||
MaxCost: config.C().Cache.MaxCost,
|
||||
BufferItems: 64,
|
||||
OnReject: func(item *ristretto.Item[any]) {
|
||||
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create ristretto cache: %v", err)
|
||||
}
|
||||
cache = c
|
||||
}
|
||||
|
||||
func Set(key string, value any) error {
|
||||
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.C().Cache.TTL)*time.Second)
|
||||
if !ok {
|
||||
return fmt.Errorf("failed to set value in cache")
|
||||
}
|
||||
cache.Wait()
|
||||
return nil
|
||||
}
|
||||
|
||||
func Get[T any](key string) (T, bool) {
|
||||
v, ok := cache.Get(key)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
vT, ok := v.(T)
|
||||
if !ok {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return vT, true
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package common
|
||||
|
||||
func Init() {
|
||||
initCache()
|
||||
}
|
||||
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错误: "
|
||||
@@ -1,39 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/gookit/slog"
|
||||
"github.com/gookit/slog/handler"
|
||||
"github.com/gookit/slog/rotatefile"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
)
|
||||
|
||||
var Log *slog.Logger
|
||||
|
||||
func InitLogger() {
|
||||
if Log != nil {
|
||||
return
|
||||
}
|
||||
slog.DefaultChannelName = "SaveAnyBot"
|
||||
Log = slog.New()
|
||||
logLevel := slog.LevelByName(config.Cfg.Log.Level)
|
||||
logFilePath := config.Cfg.Log.File
|
||||
logBackupNum := config.Cfg.Log.BackupCount
|
||||
var logLevels []slog.Level
|
||||
for _, level := range slog.AllLevels {
|
||||
if level <= logLevel {
|
||||
logLevels = append(logLevels, level)
|
||||
}
|
||||
}
|
||||
consoleH := handler.NewConsoleHandler(logLevels)
|
||||
fileH, err := handler.NewTimeRotateFile(
|
||||
logFilePath,
|
||||
rotatefile.EveryDay,
|
||||
handler.WithLogLevels(slog.AllLevels),
|
||||
handler.WithBackupNum(logBackupNum),
|
||||
handler.WithBuffSize(0),
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
Log.AddHandlers(consoleH, fileH)
|
||||
}
|
||||
55
common/os.go
55
common/os.go
@@ -1,55 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 创建文件, 自动创建目录
|
||||
func MkFile(path string, data []byte) error {
|
||||
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, os.ModePerm)
|
||||
}
|
||||
|
||||
// 删除文件, 并清理空目录. 如果文件不存在则返回 nil
|
||||
func PurgeFile(path string) error {
|
||||
if err := os.Remove(path); err != nil {
|
||||
if !errors.Is(err, os.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return RemoveEmptyDirectories(filepath.Dir(path))
|
||||
}
|
||||
|
||||
func RmFileAfter(path string, td time.Duration) {
|
||||
_, err := os.Stat(path)
|
||||
if err != nil {
|
||||
Log.Errorf("Failed to create timer for %s: %s", path, err)
|
||||
return
|
||||
}
|
||||
Log.Debugf("Remove file after %s: %s", td, path)
|
||||
time.AfterFunc(td, func() {
|
||||
PurgeFile(path)
|
||||
})
|
||||
}
|
||||
|
||||
// 递归删除空目录
|
||||
func RemoveEmptyDirectories(dirPath string) error {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(entries) == 0 {
|
||||
err := os.Remove(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return RemoveEmptyDirectories(filepath.Dir(dirPath))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
14
common/tdler/dler.go
Normal file
14
common/tdler/dler.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package tdler
|
||||
|
||||
import (
|
||||
"github.com/gotd/td/telegram/downloader"
|
||||
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
|
||||
"github.com/krau/SaveAny-Bot/config"
|
||||
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
|
||||
"github.com/krau/SaveAny-Bot/pkg/tfile"
|
||||
)
|
||||
|
||||
func NewDownloader(file tfile.TGFile) *downloader.Builder {
|
||||
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
|
||||
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.C().Threads))
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
func HashString(s string) string {
|
||||
hash := md5.New()
|
||||
hash.Write([]byte(s))
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`)
|
||||
|
||||
func ExtractTagsFromText(text string) []string {
|
||||
matches := TagRe.FindAllStringSubmatch(text, -1)
|
||||
tags := make([]string, 0)
|
||||
for _, match := range matches {
|
||||
if len(match) > 1 {
|
||||
tags = append(tags, match[1])
|
||||
}
|
||||
}
|
||||
return tags
|
||||
}
|
||||
33
common/utils/dlutil/dl.go
Normal file
33
common/utils/dlutil/dl.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package dlutil
|
||||
|
||||
import "time"
|
||||
|
||||
var threadsLevels = []struct {
|
||||
threads int
|
||||
size int64
|
||||
}{
|
||||
{1, 10 << 20},
|
||||
{2, 50 << 20},
|
||||
{4, 200 << 20},
|
||||
{8, 500 << 20},
|
||||
}
|
||||
|
||||
func BestThreads(size int64, max int) int {
|
||||
for _, thread := range threadsLevels {
|
||||
if size < thread.size {
|
||||
return min(thread.threads, max)
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
func GetSpeed(downloaded int64, startTime time.Time) float64 {
|
||||
if startTime.IsZero() {
|
||||
return 0
|
||||
}
|
||||
elapsed := time.Since(startTime).Seconds()
|
||||
if elapsed <= 0 {
|
||||
return 0
|
||||
}
|
||||
return float64(downloaded) / elapsed
|
||||
}
|
||||
77
common/utils/fsutil/fs.go
Normal file
77
common/utils/fsutil/fs.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
)
|
||||
|
||||
// 删除文件夹内的所有文件和子目录, 但不删除文件夹本身
|
||||
func RemoveAllInDir(dirPath string) error {
|
||||
entries, err := os.ReadDir(dirPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
entryPath := filepath.Join(dirPath, entry.Name())
|
||||
if err := os.RemoveAll(entryPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DetectFileExt(fp string) string {
|
||||
mt, err := mimetype.DetectFile(fp)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return mt.Extension()
|
||||
}
|
||||
|
||||
type File struct {
|
||||
*os.File
|
||||
}
|
||||
|
||||
func (f *File) Remove() error {
|
||||
return os.Remove(f.Name())
|
||||
}
|
||||
|
||||
func (f *File) CloseAndRemove() error {
|
||||
if err := f.Close(); err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Remove()
|
||||
}
|
||||
|
||||
func CreateFile(fp string) (*File, error) {
|
||||
if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
file, err := os.Create(fp)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &File{File: file}, nil
|
||||
}
|
||||
|
||||
func NormalizePathname(s string) string {
|
||||
specials := `\/:*?"<>|` + "\n\r\t"
|
||||
var builder strings.Builder
|
||||
for _, ch := range s {
|
||||
if strings.ContainsRune(specials, ch) || unicode.IsControl(ch) {
|
||||
builder.WriteRune('_')
|
||||
} else {
|
||||
builder.WriteRune(ch)
|
||||
}
|
||||
}
|
||||
|
||||
result := strings.TrimRightFunc(builder.String(), func(r rune) bool {
|
||||
return r == '.' || r == '_' || unicode.IsSpace(r)
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
46
common/utils/fsutil/normalize_pathname_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package fsutil_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
|
||||
)
|
||||
|
||||
func TestNormalizePathname(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
input: "hello/world?.txt ",
|
||||
expected: "hello_world_.txt",
|
||||
},
|
||||
{
|
||||
input: "bad|name:\nfile\r.",
|
||||
expected: "bad_name__file",
|
||||
},
|
||||
{
|
||||
input: "normal.txt",
|
||||
expected: "normal.txt",
|
||||
},
|
||||
{
|
||||
input: "test.... ",
|
||||
expected: "test",
|
||||
},
|
||||
{
|
||||
input: "abc<>def",
|
||||
expected: "abc__def",
|
||||
},
|
||||
{
|
||||
input: "with\tcontrol",
|
||||
expected: "with_control",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
got := fsutil.NormalizePathname(tc.input)
|
||||
if got != tc.expected {
|
||||
t.Errorf("NormalizePathname(%q) = %q; want %q", tc.input, got, tc.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user