Compare commits

...

122 Commits

Author SHA1 Message Date
krau
1339c69dbf feat: update fnametmpl help 2026-01-06 09:21:05 +08:00
krau
63aeabb39b feat: add msgraw var to filename template and update i18n for fnamest display 2026-01-06 09:19:32 +08:00
krau
e60e983229 docs: add lang settings guide 2026-01-05 11:56:19 +08:00
krau
75e5fd10ea feat(aria2): add Aria2 download command and client integration 2026-01-03 17:40:55 +08:00
krau
c8d8a2e0eb feat: add command to sync peer chats with userbot 2026-01-03 16:32:58 +08:00
krau
044e732084 chore: remove CLAUDE.md documentation file as it is incorrect 2026-01-01 10:36:53 +08:00
krau
0e951f641c fix(storage): add panic recovery for malformed MP4 files
- Add defer/recover in getMP4Meta to catch panics from gomedia library
- Implement fallback to ffprobe when MP4 parsing fails
- Fixes "no vosdata" panic when processing certain MP4 files

Also add CLAUDE.md to provide architecture guidance for AI coding assistants.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 20:36:26 +08:00
krau
8dd6265d55 chore: modenrize it 2025-12-27 16:01:41 +08:00
krau
ad7e01f052 fix: improve text message handling by removing duplicates and empty lines 2025-12-21 13:17:10 +08:00
krau
c069d1edc7 fix: format the generated i18n keys 2025-12-21 13:03:50 +08:00
krau
fd3b17a360 fix(i18n): add cancel button text 2025-12-21 12:59:14 +08:00
doomwithdon
a3162eff89 Add BotMsgCancelCancelTask key for bot messages (#162) 2025-12-21 12:56:42 +08:00
krau
cebadd39f7 docs: Update issue templates to English for consistency and clarity 2025-12-19 21:54:59 +08:00
krau
95f7d5abb5 docs: Enhance documentation with new features, guidelines, and configuration details 2025-12-19 21:03:58 +08:00
krau
5b8249060d docs: update readme 2025-12-19 20:57:28 +08:00
krau
0b97d15b2a feat(i18n): Integrate internationalization for bot commands and add English locale support 2025-12-19 20:48:50 +08:00
krau
6db5633ca6 feat(i18n): Add progress tracking messages for various tasks and enhance error handling 2025-12-19 20:40:14 +08:00
krau
4ccbf8177e fix(i18n): Update log messages to English and improve error handling in bot initialization and storage loading 2025-12-19 20:29:48 +08:00
krau
6e16dc6051 feat: Refactor bot handlers to utilize i18n for internationalization
- Updated silent.go to replace hardcoded strings with i18n keys for user feedback.
- Enhanced telegraph.go to use i18n for error messages and prompts.
- Modified update.go to implement i18n for version update notifications and errors.
- Refactored dir.go to utilize i18n for help messages related to directory operations.
- Updated parse.go to replace static text with i18n keys for parsed text entities.
- Enhanced rule.go to use i18n for rule management help messages.
- Refactored storage.go to implement i18n for storage selection prompts.
- Updated task.go to utilize i18n for task addition notifications.
- Added new i18n keys in keys.go for various messages.
- Updated Chinese localization in zh-Hans.yaml to reflect new i18n keys and messages.
2025-12-19 20:20:27 +08:00
krau
9ed2c425be feat(i18n): Enhance internationalization support and error handling messages
- Added i18n keys for various error and info messages related to task management, user handling, and storage operations.
- Updated error messages in the watch and task handling commands to use i18n for better localization.
- Refactored error handling in the database initialization and user synchronization processes to provide clearer messages.
- Improved logging messages for better clarity and consistency across the application.
- Added comprehensive documentation for AI collaboration and project structure in the repository.
2025-12-19 16:01:36 +08:00
krau
bc892d9370 chore: add no_bubbletea tag to build options in Dockerfiles 2025-12-19 14:21:12 +08:00
krau
29d523bd4f feat: refactor upload command and implement progress tracking 2025-12-19 14:20:00 +08:00
krau
3f9c3f2a73 feat: implement upload command for local file storage 2025-12-19 13:49:58 +08:00
krau
b796b045a8 feat: update locale file 2025-12-19 13:49:54 +08:00
krau
5f7b936270 feat: refactor storage retrieval functions and improve documentation 2025-12-19 13:49:20 +08:00
krau
df64ec3069 feat: add configuration flags and enhance config initialization 2025-12-19 13:49:13 +08:00
krau
d3cc56c8e6 feat: enhance user client context management and improve error handling 2025-12-19 13:48:55 +08:00
krau
018ed47949 fix(docs): update contribution guidelines for Go environment setup 2025-12-19 11:45:52 +08:00
krau
09f4dd4ce7 fix(docs): update Telegram configuration for file upload settings 2025-12-19 10:44:38 +08:00
krau
adc64ad510 ci: go eat ur fking shit tmp tags 2025-12-18 20:03:57 +08:00
krau
da6cf42355 ci: fxxk github action, gpt, gemini, docker and myself 2025-12-18 19:55:29 +08:00
krau
8c76516953 fix: simplify Docker build workflow by removing unnecessary steps and improving tag handling 2025-12-18 19:37:43 +08:00
krau
15c4fffb98 fix: update Docker build workflow to streamline variable extraction and tag handling 2025-12-18 19:35:29 +08:00
krau
b5cdf1e880 fix: streamline tag formatting in Docker image build step 2025-12-18 19:32:55 +08:00
krau
264dd9f9ed fix: update Docker image tag formatting in build workflow 2025-12-18 19:30:22 +08:00
krau
b25df2e214 fix: add missing fi statement in Dockerfile path setting step 2025-12-18 19:28:28 +08:00
krau
5999ddbe1d ci: refactor Docker build workflow to consolidate build steps and improve image tagging 2025-12-18 19:25:47 +08:00
krau
7424190ee5 fix: remove redundant chmod command for saveany-bot in Dockerfile.pico 2025-12-18 19:22:13 +08:00
krau
87e8836c78 fix: update IMAGE_NAME in Docker build workflow to use a specific repository name 2025-12-18 19:08:43 +08:00
krau
1a7747c2d2 feat: refactor Docker build workflow to extract metadata and streamline image builds 2025-12-18 19:03:29 +08:00
krau
ca0fd67fba ci: add latest tags for micro and pico Docker images 2025-12-18 18:44:18 +08:00
krau
4d736b925b docs: update storage config 2025-12-18 18:44:12 +08:00
krau
ead2b20f4e docs: add docker vriant introduction 2025-12-18 18:43:57 +08:00
krau
080d474714 revert: remove variant handling and simplify binary and asset naming in build-release workflow 2025-12-18 18:31:27 +08:00
krau
f453205fde feat: add Docker build flag and update version handling in update command 2025-12-18 18:12:31 +08:00
krau
407677f270 fix: update binary and asset naming conventions in build-release workflow 2025-12-18 17:59:55 +08:00
krau
958bfd1dbe ci: update build-release workflow to include asset name and additional build flags 2025-12-18 17:55:24 +08:00
krau
debe33d84d ci: add micro and pico Docker image build steps to workflow 2025-12-18 17:46:09 +08:00
krau
52eead3bf5 feat: refactor database dialect handling and add stubs for unsupported features 2025-12-18 17:42:20 +08:00
krau
0af049a507 feat: migrate S3 client implementation to a new package structure 2025-12-18 16:42:58 +08:00
krau
8752dd865c feat: refactor S3 storage implementation and reduce binary size 2025-12-18 16:21:40 +08:00
krau
c0b4580e34 fix: correct split size calculation in Save method 2025-12-16 20:52:17 +08:00
krau
280fd6ead8 fix: update DefaultSplitSize 2025-12-16 20:51:11 +08:00
krau
0ca3d97711 feat: add task command to client and Title method to Task for tasks queue managing, #157 2025-12-15 11:29:55 +08:00
krau
51198a1e3d fix: remove redundant cancellation in Done method 2025-12-15 11:15:17 +08:00
krau
651835c467 feat: refactor queue to remove unused methods and add comments 2025-12-15 10:49:40 +08:00
krau
45c978980c feat: add support for splitting large files into parts for Telegram storage, #156 2025-12-15 10:25:50 +08:00
krau
c21ff7e499 feat: add direct links download functionality
- Implemented a new task type for handling direct links downloads.
- Added command handler for downloading multiple links via /dl command.
- Introduced progress tracking for direct link downloads.
- Enhanced filename parsing to support various encoding scenarios.
- Updated enums to include direct links as a task type.
- Refactored existing task structures to accommodate new functionality.
- Improved error handling and logging throughout the download process.
2025-12-08 17:10:41 +08:00
krau
32cc1e4b5a fix: update watch command help to bot api style id, close #151 2025-12-08 10:22:43 +08:00
krau
c974791dc0 fix: add VirtualHost option to S3StorageConfig and implement endpoint validation, close #150 2025-12-08 10:11:58 +08:00
krau
91814a83c7 fix: deprecate minio and introduce s3 storage backend 2025-12-04 22:59:23 +08:00
krau
685047e463 fix: compatibility between tdlib and bot api style chatID 2025-12-04 22:43:22 +08:00
krau
37e9c79ceb fix: replace huh package with bufio and term for terminal input handling 2025-12-03 22:29:02 +08:00
krau
494d1bf51c fix: migrate to unvgo/ghselfupdate for version management and prevent major version selfupgrade 2025-12-02 10:57:23 +08:00
krau
a6f194aedd feat: add global proxy config 2025-12-02 10:11:58 +08:00
krau
acd16a91a3 chore: re gen enum code using new version go-enum 2025-11-28 10:27:40 +08:00
krau
75f79e8abc fix: add ffmpeg to Dockerfile and update error message in entrypoint.sh to English 2025-11-28 10:26:35 +08:00
krau
1065acfdb8 feat: auto generate thumbnail for video uploaded to telegram storage 2025-11-28 10:24:15 +08:00
krau
fef7d37a7e fix: ensure media group timeout is set to a minimum of 1 second during setup 2025-11-27 12:16:38 +08:00
krau
b5e9cf987a fix: update media group timeout calculation to ensure minimum value is enforced 2025-11-27 11:43:14 +08:00
krau
c58fa454bb fix: remove redundant configuration call in Add function to config parsers correctly 2025-11-23 17:41:44 +08:00
krau
2c5d6f0e57 fix: upgrade golang.org/x/crypto for security 2025-11-22 17:43:52 +08:00
krau
7d57ad30a9 feat: add MP4 metadata extraction and integrate gomedia for video handling 2025-11-22 15:42:02 +08:00
krau
4f314bd37f feat: implement configurable media group handling timeout, close #137 2025-11-16 21:44:51 +08:00
krau
131dfeb4cd refactor: js plugin api 2025-11-16 21:38:30 +08:00
krau
3f40acff55 feat: add directory management functionality and update handlers to utilize default directory 2025-11-16 21:09:55 +08:00
krau
fe47ee3b51 fix: upgrade sqlite driver version 2025-11-14 09:04:14 +08:00
krau
4a6f63e58f test: add tests for string utility functions 2025-11-09 11:48:29 +08:00
krau
16c71e6384 fix: enhance ParseArgsRespectQuotes to handle escaped quotes and backslashes 2025-11-09 11:48:15 +08:00
krau
0c2d116708 feat: parse rule command with quotes respecting 2025-11-09 11:43:28 +08:00
krau
450d32b2b7 feat: add parser manage command 2025-11-07 12:01:54 +08:00
krau
f80ecae3cc feat: add Playwright support for browser automation in plugins
- Updated .dockerignore and .gitignore to exclude Playwright-related files.
- Added Playwright-Go dependency in go.mod and updated go.sum.
- Implemented jsPlaywright function in js_api.go for browser-based requests.
- Enhanced README.md to document the new Playwright functionality for plugins.
2025-11-07 11:07:47 +08:00
krau
f0853536d9 fix: use same ctx to get grouped message 2025-11-06 16:59:51 +08:00
krau
15cf81e1bd fix: remove unnecessary chat ID validation and constant usage in Save method 2025-11-06 16:04:31 +08:00
krau
ae48bd52bf fix: handle chat ID parsing correctly by removing unnecessary prefix trimming, close #130 2025-11-06 15:01:18 +08:00
krau
44de871f63 fix: add context to HTTP request in getToken and simplify HTTP client initialization 2025-10-28 17:59:21 +08:00
krau
7a2a530e49 fix: update gotgproto dependency to v1.0.0-beta22 2025-10-28 17:55:22 +08:00
krau
3aa84e89bf fix: upgrade deps 2025-10-24 21:11:18 +08:00
krau
257c292679 fix: correct chat ID parsing by handling negative chat IDs properly 2025-10-12 22:23:08 +08:00
krau
0e2a9cacf2 feat: add lswatch cmd to list all user's watching chats 2025-10-12 21:54:43 +08:00
krau
a7854afb2a refactor: improve client initialization logic and ensure thread safety 2025-10-06 23:32:20 +08:00
krau
0e989cc1a6 fix: add logging for fetching telegraph page and handle image URLs from telegra.ph 2025-10-06 22:13:41 +08:00
krau
76a82a38ee fix: handle images on telegraph server 2025-10-06 21:57:35 +08:00
krau
c7a0076c15 fix: trim space in telegraph dir path , close #119 2025-10-03 09:32:45 +08:00
krau
ea07ff7eca fix: remove unused ChatTitle field from FilenameTemplateData struct 2025-09-30 21:36:23 +08:00
krau
4d837e946c feat: add .chatid variable in file name template 2025-09-30 21:34:35 +08:00
krau
f947ee6fc7 fix: implement filename strategy in userbot listen mode 2025-09-28 16:49:32 +08:00
krau
40ad12a892 fix: update SOCKS5 proxy dialer implementation for consistency 2025-09-18 22:28:25 +08:00
krau
697e419643 feat: refactor command registration to use a centralized handler list 2025-09-13 10:37:43 +08:00
Krau
eef051de3b feat: custom filename template (#110) 2025-09-13 10:25:45 +08:00
krau
6e29442c05 fix: update Docker deployment instructions for userbot integration 2025-09-13 10:21:10 +08:00
krau
a3f1f75caf fix: update initialization error message for clarity, close #108 2025-09-13 10:19:00 +08:00
Krau
f05dd883e3 feat: enhance URL handling by adding utility functions and filters for message entities (#105) 2025-09-09 20:16:56 +08:00
dependabot[bot]
9cb866de8c chore(deps): bump github.com/ulikunitz/xz from 0.5.12 to 0.5.14 (#102)
Bumps [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) from 0.5.12 to 0.5.14.
- [Commits](https://github.com/ulikunitz/xz/compare/v0.5.12...v0.5.14)

---
updated-dependencies:
- dependency-name: github.com/ulikunitz/xz
  dependency-version: 0.5.14
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-31 12:14:49 +08:00
krau
980455fd24 fix: remove go generate command from build process in Dockerfile and workflow 2025-08-27 11:17:03 +08:00
krau
24978470cd feat: add go generate command to build process and update go.mod dependencies 2025-08-27 11:12:42 +08:00
krau
215e082028 feat: implement internationalization support and update help commands 2025-08-27 11:09:38 +08:00
krau
a7b93e57fc refactor: js api 2025-08-24 22:49:44 +08:00
krau
a4b3b459a9 docs: change service restart policy to always 2025-08-24 14:47:13 +08:00
krau
06f326088a fix: remove redundant check for current version equality in update command 2025-08-24 14:37:01 +08:00
krau
b7d3ec6230 docs: update upgrade command 2025-08-24 14:26:47 +08:00
krau
f812990e1c feat: update help command to include usage and feedback group links 2025-08-24 14:18:43 +08:00
krau
492900bbef feat: add update command and callback for version checking and upgrading 2025-08-24 14:16:26 +08:00
krau
764be2a083 fix: improve error handling in config initialization 2025-08-24 14:16:23 +08:00
krau
46c21b77e9 feat: enhance Save method to validate chat ID and adjust ForceFile logic for image uploads 2025-08-24 12:02:27 +08:00
krau
8b389a58d5 fix: improve chat ID parsing in Save method and fallback to configured chat_id 2025-08-24 11:46:12 +08:00
krau
25ad9befa0 feat: add ForceFile option to TelegramStorageConfig and update Save method 2025-08-24 11:38:44 +08:00
krau
e824b210d1 feat: improve telegram storage chat ID parsing logic 2025-08-24 11:29:19 +08:00
krau
ae0aa7db3f fix: skip overwrite dirpath and storage when rule miss match 2025-08-24 11:28:37 +08:00
krau
226c15ef08 feat: add NormalizePathname function and update task handling for parsed items 2025-08-24 10:28:50 +08:00
krau
9b3f955e48 feat: use default HTTP client from netutil for task creation 2025-08-24 09:37:52 +08:00
krau
4997ec408f docs: update parsers 2025-08-23 20:42:51 +08:00
175 changed files with 8638 additions and 2102 deletions

View File

@@ -9,3 +9,4 @@ cache/
docs/
config.example.toml
docker-compose.*
playwright/

View File

@@ -1,5 +1,5 @@
name: "👾 报告 bug"
description: "报告 bug"
name: "👾 Bug Report"
description: "Report a bug or unexpected behavior"
labels:
- "bug"
assignees:
@@ -7,29 +7,29 @@ assignees:
body:
- type: textarea
attributes:
label: "👾 问题描述"
label: "👾 Description"
description: "What happened?"
placeholder: "When called ... happens ..."
validations:
required: true
- type: textarea
attributes:
label: "⚡️ 预期行为"
label: "⚡️ Expected Behavior"
description: "What was expected?"
placeholder: "It should be ..."
- type: textarea
attributes:
label: "📄 配置文件"
label: "📄 Configuration File"
description: "Please provide your config file"
placeholder: "请自行隐去密钥信息"
placeholder: "Please remove sensitive information"
render: toml
validations:
required: true
- type: textarea
attributes:
label: "🔍 日志"
label: "🔍 Logs"
description: "Please provide logs"
placeholder: "可删除隐私信息"
placeholder: "Please remove sensitive information"
render: shell
validations:
required: true

View File

@@ -1,8 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 💬 不知道如何正确使用?
- name: 💬 Don't know how to use it correctly?
url: https://github.com/krau/SaveAny-Bot/discussions
about: "前往讨论区提问"
- name: 📄 文档
about: "Go to the discussion area to ask questions"
- name: 📄 Documentation
url: https://sabot.unv.app
about: "查看文档"
about: "View the documentation"

View File

@@ -1,5 +1,5 @@
name: "⭐️ 功能请求"
description: "功能请求"
name: "⭐️ Feature Request"
description: "Feature request"
labels:
- "enhancement"
assignees:
@@ -8,7 +8,7 @@ body:
- type: markdown
attributes:
value: |
# 请详细描述你想要的功能
# Please describe the feature you want in detail
- type: textarea
attributes:
label: "⭐️ Feature description"

86
.github/copilot-instructions.md vendored Normal file
View 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 描述中标注。也欢迎你告诉我有哪些部分需要补充或澄清,我可以进一步细化。

View File

@@ -7,32 +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: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -44,26 +65,99 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Dockerfile args
id: args
run: |
echo "git_commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo "build_time=$(git show -s --format=%cI)" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
id: build-and-push
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: |
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
type=gha
cache-to: type=gha,mode=max
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=${{ steps.meta.outputs.version }}
GitCommit=${{ steps.args.outputs.git_commit }}
BuildTime=${{ steps.args.outputs.build_time }}
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

View File

@@ -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

View File

@@ -9,11 +9,11 @@ on:
jobs:
deploy:
runs-on: ubuntu-22.04
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
with:
submodules: true # Fetch Hugo themes (true OR recursive)
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod

View File

@@ -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 }}

2
.gitignore vendored
View File

@@ -8,3 +8,5 @@ cache.db
.vscode/
temp/
.hugo_build.lock
playwright/
testplugins/

View File

@@ -20,12 +20,13 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
-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
RUN apk add --no-cache curl ffmpeg
WORKDIR /app

41
Dockerfile.micro Normal file
View 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
View 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"]

View File

@@ -2,9 +2,9 @@
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
**简体中文** | [English](https://sabot.unv.app/en/)
**English** | [简体中文](./README_zh.md)
> ** Telegram 上的文件转存到多种存储端.**
> **Save Any Telegram File to Anywhere 📂. Support restrict saving content and beyond telegram.**
[![Release Date](https://img.shields.io/github/release-date/krau/saveany-bot?label=release)](https://github.com/krau/saveany-bot/releases)
[![tag](https://img.shields.io/github/v/tag/krau/saveany-bot.svg)](https://github.com/krau/saveany-bot/releases)
@@ -19,46 +19,47 @@
## 🎯 Features
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
- 破解禁止保存的文件
- 批量下载
- 流式传输
- 多用户使用
- 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- 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 (MinioSDK)
- S3
- WebDAV
- 本地磁盘
- Telegram (重传回指定聊天)
- Local filesystem
- Telegram (re-upload to specified chats)
## 📦 Quick Start
创建文件 `config.toml` 并填入以下内容:
Create a `config.toml` file with the following content:
```toml
lang = "en" # Language setting, "en" for English
[telegram]
token = "" # 你的 Bot Token, @BotFather 获取
token = "" # Your bot token, obtained from @BotFather
[telegram.proxy]
# 启用代理连接 telegram, 当前只支持 socks5
# Enable proxy for Telegram, currently only SOCKS5 is supported
enable = false
url = "socks5://127.0.0.1:7890"
[[storages]]
name = "本地磁盘"
name = "Local Disk"
type = "local"
enable = true
base_path = "./downloads"
[[users]]
id = 114514 # 你的 Telegram 账号 id
id = 114514 # Your Telegram account id
storages = []
blacklist = true
```
使用 Docker 运行 Save Any Bot:
Run Save Any Bot with Docker:
```bash
docker run -d --name saveany-bot \
@@ -67,69 +68,23 @@ docker run -d --name saveany-bot \
ghcr.io/krau/saveany-bot:latest
```
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
Please [**read the docs**](https://sabot.unv.app/en/) for more configuration options and usage.
## Sponsors
本项目受到 [YxVM](https://yxvm.com/) [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
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:
- [爱发电](https://afdian.com/a/unvapp)
- [Afdian](https://afdian.com/a/unvapp)
## Contributors
<!-- readme: contributors -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/Silentely">
<img src="https://avatars.githubusercontent.com/u/22141172?v=4" width="100;" alt="Silentely"/>
<br />
<sub><b>Abner</b></sub>
</a>
</td>
<td align="center">
<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 -end -->
## Thanks
## Thanks To
- [gotd](https://github.com/gotd/td)
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
- [gotgproto](https://github.com/celestix/gotgproto)
- [tdl](https://github.com/iyear/tdl)
- All the dependencies
- All the dependencies, contributors, sponsors and users.
## Contact

90
README_zh.md Normal file
View File

@@ -0,0 +1,90 @@
<div align="center">
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
> **把 Telegram 上的文件转存到多种存储端**
[![Release Date](https://img.shields.io/github/release-date/krau/saveany-bot?label=release)](https://github.com/krau/saveany-bot/releases)
[![tag](https://img.shields.io/github/v/tag/krau/saveany-bot.svg)](https://github.com/krau/saveany-bot/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/krau/saveany-bot/build-release.yml)](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
[![Stars](https://img.shields.io/github/stars/krau/saveany-bot?style=flat)](https://github.com/krau/saveany-bot/stargazers)
[![Downloads](https://img.shields.io/github/downloads/krau/saveany-bot/total)](https://github.com/krau/saveany-bot/releases)
[![Issues](https://img.shields.io/github/issues/krau/saveany-bot)](https://github.com/krau/saveany-bot/issues)
[![Pull Requests](https://img.shields.io/github/issues-pr/krau/saveany-bot?label=pr)](https://github.com/krau/saveany-bot/pulls)
[![License](https://img.shields.io/github/license/krau/saveany-bot)](./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://img.shields.io/badge/ProjectSaveAny-Group-blue)](https://t.me/ProjectSaveAny)
- [![讨论区](https://img.shields.io/badge/Github-Discussion-white)](https://github.com/krau/saveany-bot/discussions)
- [![个人频道](https://img.shields.io/badge/Krau-PersonalChannel-cyan)](https://t.me/acherkrau)

View File

@@ -9,45 +9,44 @@ import (
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/sessionMaker"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy"
"github.com/krau/SaveAny-Bot/database"
)
func Init(ctx context.Context) {
log.FromContext(ctx).Info("初始化 Bot...")
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() {
var resolver dcs.Resolver
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
} else {
resolver = dcs.DefaultResolver()
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(gormlite.Open(config.C().DB.Session)),
Session: sessionMaker.SqlSession(database.GetDialect(config.C().DB.Session)),
DisableCopyright: true,
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
Resolver: resolver,
@@ -55,7 +54,11 @@ func Init(ctx context.Context) {
MaxRetries: config.C().Telegram.RpcRetry,
AutoFetchReply: true,
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
if s == "SAVEANTBOT-RESTART" {
shouldRestart <- struct{}{}
return dispatcher.EndGroups
}
log.FromContext(ctx).Errorf("unhandled error: %s", s)
return dispatcher.EndGroups
},
},
@@ -70,18 +73,9 @@ func Init(ctx context.Context) {
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: "管理规则"},
}
if config.C().Telegram.Userbot.Enable {
commands = append(commands, tg.BotCommand{Command: "watch", Description: "监听聊天"})
commands = append(commands, tg.BotCommand{Command: "unwatch", Description: "取消监听聊天"})
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{},
@@ -95,12 +89,14 @@ func Init(ctx context.Context) {
select {
case <-ctx.Done():
log.FromContext(ctx).Errorf("已取消 Bot 初始化: %s", ctx.Err())
log.FromContext(ctx).Errorf("Bot initialization cancelled: %s", ctx.Err())
case result := <-resultChan:
if result.err != nil {
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
log.FromContext(ctx).Fatalf("Failed to initialize Bot: %s", result.err)
}
handlers.Register(result.client.Dispatcher)
log.FromContext(ctx).Info("Bot 初始化完成")
ectx = result.client.CreateContext()
log.FromContext(ctx).Info("Bot initialization completed.")
}
return shouldRestart
}

View File

@@ -3,6 +3,7 @@ package handlers
import (
"errors"
"fmt"
"path"
"strings"
"github.com/celestix/gotgproto/dispatcher"
@@ -11,6 +12,9 @@ import (
"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"
@@ -31,25 +35,29 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.SelectedStorName)
if err != nil {
log.FromContext(ctx).Errorf("Failed to get storage: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "存储获取失败: "+err.Error()))
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("获取用户目录失败: %w", err)
return fmt.Errorf("failed to get user directories: %w", err)
}
if !data.SettedDir && len(dirs) != 0 {
// ask for directory selection
markup, err := msgelem.BuildSetDirKeyboard(dirs, dataid)
markup, err := msgelem.BuildSetDirMarkupForAdd(dirs, dataid)
if err != nil {
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "目录键盘构建失败: "+err.Error()))
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: "请选择要存储到的目录",
Message: i18n.T(i18nk.BotMsgCommonPromptSelectDir, nil),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
@@ -59,7 +67,9 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
if data.DirID != 0 {
dir, err := database.GetDirByID(ctx, data.DirID)
if err != nil {
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "获取目录失败: "+err.Error()))
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
"Error": err.Error(),
})))
return dispatcher.EndGroups
}
dirPath = dir.Path
@@ -74,9 +84,14 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
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:
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
return fmt.Errorf("unexcept task type: %s", data.TaskType)
}
return dispatcher.EndGroups
}

View File

@@ -8,21 +8,46 @@ import (
"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("error cancelling task %s: %v", taskid, err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), "取消任务失败: "+err.Error()))
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: "正在取消任务...",
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
}

View File

@@ -3,23 +3,27 @@ package handlers
import (
"fmt"
"strings"
"text/template"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString("请选择要配置的选项"), &ext.ReplyOpts{
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigPromptSelectOption)), &ext.ReplyOpts{
Markup: &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: "文件名策略",
Text: i18n.T(i18nk.BotMsgConfigButtonFilenameStrategy),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
},
},
@@ -36,7 +40,7 @@ func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "无效的回调数据",
Message: i18n.T(i18nk.BotMsgConfigErrorInvalidCallbackData),
CacheTime: 5,
})
return dispatcher.EndGroups
@@ -70,8 +74,10 @@ func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
return err
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("已将文件名策略设置为: %s", fnamest.FnameSTDisplay[st]),
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgConfigInfoFilenameStrategySet, map[string]any{
"Strategy": fnamest.GetDisplay(st, config.C().Lang),
}),
})
return dispatcher.EndGroups
}
@@ -79,7 +85,7 @@ func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
for _, opt := range opts {
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: fnamest.FnameSTDisplay[opt],
Text: fnamest.GetDisplay(opt, config.C().Lang),
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
})
}
@@ -95,9 +101,44 @@ func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
currentSt = fnamest.Default
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("请选择文件名策略, 当前策略: %s", fnamest.FnameSTDisplay[currentSt]),
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgConfigPromptSelectFilenameStrategy, map[string]any{
"Strategy": fnamest.GetDisplay(currentSt, config.C().Lang),
}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
args := strings.Fields(string(update.EffectiveMessage.Text))
if len(args) <= 1 {
text := i18n.T(i18nk.BotMsgConfigFnametmplHelp, nil)
if user.FilenameTemplate != "" {
text += "\n\n" + i18n.T(i18nk.BotMsgConfigInfoCurrentTemplatePrefix, map[string]any{
"Template": user.FilenameTemplate,
})
}
ctx.Reply(update, ext.ReplyTextString(text), nil)
return dispatcher.EndGroups
}
newTmpl := strings.Join(args[1:], " ")
_, err = template.New("filename").Parse(newTmpl)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigErrorInvalidTemplate, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
user.FilenameTemplate = newTmpl
if err := database.UpdateUser(ctx, user); err != nil {
return err
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigInfoTemplateUpdated, nil)), nil)
return dispatcher.EndGroups
}

View File

@@ -8,6 +8,8 @@ import (
"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"
)
@@ -18,8 +20,8 @@ func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
userChatID := update.GetUserChat().GetID()
dirs, err := database.GetUserDirsByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("获取用户文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户文件夹失败"), 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 {
@@ -28,8 +30,8 @@ func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
}
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
logger.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), 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] {
@@ -45,11 +47,11 @@ func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
}
if err := database.CreateDirForUser(ctx, user.ID, args[2], args[3]); err != nil {
logger.Errorf("创建文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建文件夹失败"), 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("文件夹添加成功"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirInfoCreateDirSuccess)), nil)
case "del":
// /dir del 3
if len(args) < 3 {
@@ -58,17 +60,17 @@ func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
}
dirID, err := strconv.Atoi(args[2])
if err != nil {
ctx.Reply(update, ext.ReplyTextString("文件夹ID无效"), 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("删除文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除文件夹失败"), 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("文件夹删除成功"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirInfoDeleteDirSuccess)), nil)
default:
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorUnknownOperation)), nil)
}
return dispatcher.EndGroups
}

105
client/bot/handlers/dl.go Normal file
View File

@@ -0,0 +1,105 @@
package handlers
import (
"net/url"
"strings"
"sync"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/aria2"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleDlCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
return nil
}
links := args[1:]
for i, link := range links {
links[i] = strings.TrimSpace(link)
u, err := url.Parse(link)
if err != nil || u.Scheme == "" || u.Host == "" {
logger.Warn("invaild link", link)
links[i] = ""
}
}
links = slice.Compact(links)
if len(links) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
return nil
}
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
TaskType: tasktype.TaskTypeDirectlinks,
DirectLinks: links,
})
if err != nil {
return err
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlInfoFilesSelectStorage, map[string]any{
"Count": len(links),
})), &ext.ReplyOpts{
Markup: markup,
})
return nil
}
var aria2ClientInitOnce sync.Once
var aria2ClientInitErr error
var aria2Client *aria2.Client
func handleAria2DlCmd(ctx *ext.Context, update *ext.Update) error {
if !config.C().Aria2.Enable {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAria2NotEnabled)), nil)
return nil
}
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
return nil
}
links := args[1:]
for i, link := range links {
links[i] = strings.TrimSpace(link)
}
links = slice.Compact(links)
if len(links) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
return nil
}
logger.Debug("Adding aria2 download", "links", links)
aria2ClientInitOnce.Do(func() {
aria2Client, aria2ClientInitErr = aria2.NewClient(config.C().Aria2.Url, config.C().Aria2.Secret)
})
if aria2ClientInitErr != nil {
logger.Error("Failed to initialize aria2 client", "error", aria2ClientInitErr)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAria2ClientInitFailed, map[string]any{
"Error": aria2ClientInitErr.Error(),
})), nil)
return nil
}
gid, err := aria2Client.AddURI(ctx, links, nil)
if err != nil {
logger.Error("Failed to add aria2 download", "error", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAddingAria2Download, map[string]any{
"Error": err.Error(),
})), nil)
return nil
}
logger.Info("Aria2 download added", "gid", gid)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2InfoAria2DownloadAdded, map[string]any{
"GID": gid,
})), nil)
return nil
}

View File

@@ -5,29 +5,16 @@ import (
"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 {
const helpText string = `
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
/dir - 管理存储目录
/rule - 管理规则
使用帮助: https://sabot.unv.app/usage/
`
shortHash := config.GitCommit
if len(shortHash) > 7 {
shortHash = shortHash[:7]
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, config.Version, shortHash)), nil)
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
return dispatcher.EndGroups
}

View File

@@ -1,13 +1,14 @@
package handlers
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
@@ -23,8 +24,10 @@ func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
if len(files) == 1 {
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, files[0], replied.ID)
if err != nil {
logger.Errorf("构建存储选择消息失败: %s", err)
editReplied("构建存储选择消息失败: "+err.Error(), nil)
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)
@@ -34,29 +37,27 @@ func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
Files: files,
})
if err != nil {
logger.Errorf("构建存储选择键盘失败: %s", err)
editReplied("构建存储选择键盘失败: "+err.Error(), 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(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)), markup)
editReplied(i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{
"Count": len(files),
}), markup)
return dispatcher.EndGroups
}
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
if err != nil {
return err
}
userId := update.GetUserChat().GetID()
if len(files) == 1 {
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", files[0], replied.ID)
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files[0], replied.ID)
}
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", files, replied.ID)
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files, replied.ID)
}

View File

@@ -1,22 +1,16 @@
package handlers
import (
"fmt"
"sync"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/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/utils/tgutil"
"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"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
@@ -33,12 +27,7 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
if err != nil {
return err
}
tfOpts := make([]tfile.TGFileOption, 0)
switch userDB.FilenameStrategy {
case fnamest.Message.String():
tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
default:
}
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
if err != nil {
return err
@@ -47,8 +36,10 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
stors := storage.GetUserStorages(ctx, userId)
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
if err != nil {
logger.Errorf("构建存储选择消息失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
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)
@@ -58,11 +49,6 @@ func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
message := update.EffectiveMessage.Message
groupID, isGroup := message.GetGroupedID()
if isGroup && groupID != 0 {
@@ -74,108 +60,10 @@ func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
if err != nil {
return err
}
tfOpts := make([]tfile.TGFileOption, 0)
switch userDB.FilenameStrategy {
case fnamest.Message.String():
tfOpts = append(tfOpts, tfile.WithName(tgutil.GenFileNameFromMessage(*message)))
default:
}
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, "", file, msg.ID)
}
type MediaGroupHandler struct {
groups map[int64][]tfile.TGFileMessage
timers map[int64]*time.Timer
mu sync.Mutex
timeout time.Duration
}
var mediaGroupHandler = &MediaGroupHandler{
groups: make(map[int64][]tfile.TGFileMessage),
timers: make(map[int64]*time.Timer),
timeout: 1 * time.Second,
}
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
logger := log.FromContext(ctx)
media := message.Media
supported := mediautil.IsSupported(media)
if !supported {
return dispatcher.EndGroups
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
tgutil.GenFileNameFromMessage(*message),
))
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
return dispatcher.EndGroups
}
mediaGroupHandler.mu.Lock()
defer mediaGroupHandler.mu.Unlock()
if mediaGroupHandler.groups[groupID] == nil {
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
}
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
timer.Stop()
}
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
processMediaGroup(ctx, update, groupID)
})
return dispatcher.EndGroups
}
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
logger := log.FromContext(ctx)
mediaGroupHandler.mu.Lock()
items := mediaGroupHandler.groups[groupID]
delete(mediaGroupHandler.groups, groupID)
delete(mediaGroupHandler.timers, groupID)
mediaGroupHandler.mu.Unlock()
if len(items) == 0 {
logger.Warn("No media items to process for group", "groupID", groupID)
return
}
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
userId := update.GetUserChat().GetID()
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return
}
stor := storage.FromContext(ctx)
if stor != nil {
// In silent mode
if len(items) == 1 {
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
return
}
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
return
}
stors := storage.GetUserStorages(ctx, userId)
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
Files: items,
AsBatch: len(items) > 1,
})
if err != nil {
logger.Errorf("构建存储选择键盘失败: %s", err)
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: "构建存储选择键盘失败: " + err.Error(),
})
return
}
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
ReplyMarkup: markup,
})
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, dirutil.PathFromContext(ctx), file, msg.ID)
}

View 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,
})
}

View File

@@ -4,6 +4,9 @@ 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"
@@ -12,11 +15,7 @@ import (
func checkPermission(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
if !slice.Contain(config.C().GetUsersID(), userID) {
const noPermissionText string = `
您不在白名单中, 无法使用此 Bot.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
`
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoPermission, nil)), nil)
return dispatcher.EndGroups
}
@@ -28,21 +27,35 @@ func handleSilentMode(next func(*ext.Context, *ext.Update) error, handler func(*
userID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), 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("您已开启静默模式, 但未设置默认存储端, 请先使用 /storage 设置"), nil)
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("获取默认存储失败: "+err.Error()), 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)
}

View File

@@ -4,13 +4,20 @@ 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"
@@ -20,22 +27,44 @@ import (
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
logger := log.FromContext(ctx)
text := u.EffectiveMessage.Text
ok, pser := parsers.CanHandle(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("正在解析..."), nil)
msg, err := ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseInfoParsing, nil)), nil)
if err != nil {
return err
}
item, err := pser.Parse(ctx, text)
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("Failed to parse text: "+err.Error()), nil)
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)
@@ -46,13 +75,17 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
})
if err != nil {
logger.Errorf("Failed to build storage selection keyboard: %s", err)
ctx.Reply(u, ext.ReplyTextString("Failed to build storage selection keyboard: "+err.Error()), nil)
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("Failed to build parsed text entity: "+err.Error()), nil)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildParsedTextEntityFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
@@ -68,11 +101,6 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(u, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
text := u.EffectiveMessage.Text
if text == "" {
return dispatcher.EndGroups
@@ -83,7 +111,9 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
}
if err != nil {
logger.Error("Failed to parse text", "error", err)
ctx.Reply(u, ext.ReplyTextString("Failed to parse text: "+err.Error()), nil)
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)
@@ -91,7 +121,9 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
text, entities, err := msgelem.BuildParsedTextEntity(*item)
if err != nil {
logger.Errorf("Failed to build parsed text entity: %s", err)
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
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{
@@ -106,5 +138,12 @@ func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
logger.Errorf("Failed to send message: %s", err)
return dispatcher.EndGroups
}
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, "", item, msg.ID, userID)
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)
}

View 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
}

View File

@@ -1,28 +1,48 @@
package handlers
import (
"path"
"regexp"
"strings"
"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/charmbracelet/log"
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"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/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
type DescCommandHandler struct {
Cmd string
Desc i18nk.Key
handler func(ctx *ext.Context, u *ext.Update) error
}
var CommandHandlers = []DescCommandHandler{
{"start", i18nk.BotMsgCmdStart, handleHelpCmd},
{"silent", i18nk.BotMsgCmdSilent, handleSilentCmd},
{"storage", i18nk.BotMsgCmdStorage, handleStorageCmd},
{"dir", i18nk.BotMsgCmdDir, handleDirCmd},
{"rule", i18nk.BotMsgCmdRule, handleRuleCmd},
{"save", i18nk.BotMsgCmdSave, handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
{"dl", i18nk.BotMsgCmdDl, handleDlCmd},
{"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd},
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},
{"fnametmpl", i18nk.BotMsgCmdFnametmpl, handleConfigFnameTmpl},
{"help", i18nk.BotMsgCmdHelp, handleHelpCmd},
{"parser", i18nk.BotMsgCmdParser, handleParserCmd},
{"watch", i18nk.BotMsgCmdWatch, handleWatchCmd},
{"unwatch", i18nk.BotMsgCmdUnwatch, handleUnwatchCmd},
{"lswatch", i18nk.BotMsgCmdLswatch, handleLswatchCmd},
{"syncpeers", i18nk.BotMsgCmdSyncpeers, handleSyncpeersCmd},
{"update", i18nk.BotMsgCmdUpdate, handleUpdateCmd},
}
func Register(disp dispatcher.Dispatcher) {
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChannel), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
@@ -31,30 +51,16 @@ func Register(disp dispatcher.Dispatcher) {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
disp.AddHandler(handlers.NewCommand("start", handleHelpCmd))
disp.AddHandler(handlers.NewCommand("help", handleHelpCmd))
disp.AddHandler(handlers.NewCommand("silent", handleSilentCmd))
disp.AddHandler(handlers.NewCommand("storage", handleStorageCmd))
disp.AddHandler(handlers.NewCommand("dir", handleDirCmd))
disp.AddHandler(handlers.NewCommand("rule", handleRuleCmd))
disp.AddHandler(handlers.NewCommand("watch", handleWatchCmd))
disp.AddHandler(handlers.NewCommand("unwatch", handleUnwatchCmd))
disp.AddHandler(handlers.NewCommand("save", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)))
disp.AddHandler(handlers.NewCommand("config", handleConfigCmd))
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))
linkRegexFilter, err := filters.Message.Regex(re.TgMessageLinkRegexString)
if err != nil {
panic("failed to create regex filter: " + err.Error())
}
disp.AddHandler(handlers.NewMessage(linkRegexFilter, handleSilentMode(handleMessageLink, handleSilentSaveLink)))
telegraphUrlRegexFilter, err := filters.Message.Regex(re.TelegraphUrlRegexString)
if err != nil {
panic("failed to create Telegraph URL regex filter: " + err.Error())
}
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
disp.AddHandler(handlers.NewMessage(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)))
@@ -62,79 +68,3 @@ func Register(disp dispatcher.Dispatcher) {
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
}
}
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
logger := log.FromContext(userclient.GetCtx())
for event := range ch {
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
ctx := event.Ctx
file := event.File
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
if err != nil {
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
continue
}
msgText := event.File.Message().GetMessage()
for _, chat := range chats {
if chat.Filter != "" {
filter := strings.Split(chat.Filter, ":")
if len(filter) != 2 {
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
continue
}
filterType := filter[0]
filterData := filter[1]
switch filterType {
case "msgre": // [TODO] enums for filter types
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
continue
} else if !ok {
continue
}
default:
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
continue
}
}
user, err := database.GetUserByID(ctx, chat.UserID)
if err != nil {
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
continue
}
if user.DefaultStorage == "" {
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
continue
}
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
if err != nil {
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
continue
}
var dirPath string
if user.ApplyRule && user.Rules != nil {
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
dirPath = matchedDirPath.String()
if matchedStorageName.IsUsable() {
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
continue
}
}
}
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())
}
}
}

View File

@@ -10,18 +10,21 @@ import (
"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 := strings.Split(update.EffectiveMessage.Text, " ")
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("获取用户规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil)
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 {
@@ -33,11 +36,14 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
// /rule switch
applyRule := !user.ApplyRule
if err := database.UpdateUserApplyRule(ctx, user.ChatID, applyRule); err != nil {
logger.Errorf("更新用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorUpdateUserFailed, nil)), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[applyRule])), nil)
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 {
@@ -51,10 +57,13 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
return t, nil
}
}
return rule.RuleType(""), fmt.Errorf("无效的规则类型: %s\n可用: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
return rule.RuleType(""), fmt.Errorf("invalid rule type: %s\navailable: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
}()
if err != nil {
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorInvalidRuleType, map[string]any{
"Type": ruleTypeArg,
"Available": slice.Join(rule.Values(), ", "),
})), nil)
return dispatcher.EndGroups
}
@@ -70,29 +79,29 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
UserID: user.ID,
}
if err := database.CreateRule(ctx, rd); err != nil {
logger.Errorf("创建规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建规则失败"), 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("创建规则成功"), nil)
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("请提供规则ID"), nil)
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("无效的规则ID"), 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("删除规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), 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("删除规则成功"), nil)
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

View File

@@ -1,7 +1,6 @@
package handlers
import (
"fmt"
"regexp"
"strings"
@@ -9,11 +8,15 @@ import (
"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"
@@ -22,27 +25,25 @@ import (
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(string(update.EffectiveMessage.Text), " ")
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(msgelem.SaveHelpText), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups
}
genFilename := func() string {
if len(args) > 1 {
return args[1]
}
filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
return filename
}()
option := tfile.WithNameIfEmpty(genFilename)
if len(args) > 1 {
option = tfile.WithName(genFilename)
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
return err
}
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
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
}
@@ -50,8 +51,8 @@ func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
stors := storage.GetUserStorages(ctx, userId)
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
if err != nil {
logger.Errorf("构建存储选择消息失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+err.Error()), nil)
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)
@@ -63,34 +64,26 @@ func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1:])
}
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(msgelem.SaveHelpText), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups
}
genFilename := func() string {
if len(args) > 1 {
return args[1]
}
filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
return filename
}()
option := tfile.WithNameIfEmpty(genFilename)
if len(args) > 1 {
option = tfile.WithName(genFilename)
}
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, option)
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
return err
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", file, msg.GetID())
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 {
@@ -103,35 +96,35 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
var err error
filter, err = regexp.Compile(filterStr)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的正则表达式: "+err.Error()), 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("无效的消息ID范围: "+err.Error()), 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("无效的ID或用户名: "+err.Error()), 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("正在获取消息..."), nil)
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingMessages)), nil)
if err != nil {
log.FromContext(ctx).Errorf("回复失败: %s", err)
log.FromContext(ctx).Errorf("Failed to reply: %s", err)
return dispatcher.EndGroups
}
// TODO: generator istead of get all messages
// [TODO]: generator istead of get all messages
msgs, err := tgutil.GetMessagesRange(ctx, chatID, int(startID), int(endID))
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取消息失败: "+err.Error()), nil)
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("没有找到指定范围内的消息"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoMessagesInRange)), nil)
return dispatcher.EndGroups
}
files := make([]tfile.TGFileMessage, 0, len(msgs))
@@ -150,7 +143,7 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
log.FromContext(ctx).Errorf("获取文件失败: %s", err)
log.FromContext(ctx).Errorf("Failed to get file from message: %s", err)
continue
}
if filter != nil {
@@ -166,7 +159,7 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
files = append(files, file)
}
if len(files) == 0 {
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的可保存消息"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoSavableMessagesInRange)), nil)
return dispatcher.EndGroups
}
stor := storage.FromContext(ctx)
@@ -177,16 +170,16 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
Files: files,
})
if err != nil {
log.FromContext(ctx).Errorf("构建存储选择键盘失败: %s", err)
log.FromContext(ctx).Errorf("Failed to build storage selection keyboard: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: "构建存储选择键盘失败: " + err.Error(),
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: fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{"Count": len(files)}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups

View File

@@ -8,6 +8,8 @@ import (
"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"
@@ -16,71 +18,113 @@ import (
func handleSilentCmd(ctx *ext.Context, update *ext.Update) error {
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
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("请先使用 /storage 设置默认存储位置"), nil)
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("更新用户信息失败: "+err.Error()), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorUpdateUserInfoFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil
}
responseText := "已" + map[bool]string{true: "开启", false: "关闭"}[user.Silent] + "静默模式"
ctx.Reply(update, ext.ReplyTextString(responseText), 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)
if !ok {
failedAnswer := func(message string) error {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "数据已过期",
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 {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "存储获取失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
}))
}
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "获取用户信息失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
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 {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "更新用户信息失败: " + err.Error(),
CacheTime: 5,
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, "/"),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "已将默认存储位置设置为: " + selectedStorage.Name(),
Message: msg,
})
return dispatcher.EndGroups
}
@@ -89,15 +133,17 @@ func handleStorageCmd(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
storages := storage.GetUserStorages(ctx, userID)
if len(storages) == 0 {
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoAvailableStorage, nil)), nil)
return nil
}
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, userID, storages)
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, storages)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取存储失败: "+err.Error()), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil
}
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonPromptSelectDefaultStorage, nil)), &ext.ReplyOpts{
Markup: markup,
})
return dispatcher.EndGroups

View File

@@ -0,0 +1,62 @@
package handlers
import (
"context"
"sync"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/storage"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/query/dialogs"
"github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
)
var syncpeerMu sync.Mutex
func handleSyncpeersCmd(ctx *ext.Context, u *ext.Update) error {
if !config.C().Telegram.Userbot.Enable {
return dispatcher.EndGroups
}
syncpeerMu.Lock()
defer syncpeerMu.Unlock()
uctx := user.GetCtx()
if uctx == nil {
return dispatcher.EndGroups
}
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersStart)), nil)
tapi := uctx.Raw
peerStorage := uctx.PeerStorage
log.FromContext(ctx).Info("Starting to sync peers...")
count := 0
err := dialogs.NewQueryBuilder(tapi).GetDialogs().BatchSize(50).ForEach(ctx, func(ctx context.Context, e dialogs.Elem) error {
for cid, channel := range e.Entities.Channels() {
peerStorage.AddPeer(cid, channel.AccessHash, storage.TypeChannel, channel.Username)
count++
}
for uid, user := range e.Entities.Users() {
peerStorage.AddPeer(uid, user.AccessHash, storage.TypeUser, user.Username)
count++
}
for gid := range e.Entities.Chats() {
peerStorage.AddPeer(gid, storage.DefaultAccessHash, storage.TypeChat, storage.DefaultUsername)
count++
}
return nil
})
if err != nil {
log.FromContext(ctx).Error("Failed to sync peers", "error", err)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
log.FromContext(ctx).Info("Finished syncing peers")
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersSuccess, map[string]any{
"Count": count,
})), nil)
return dispatcher.EndGroups
}

View 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)
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"fmt"
"path"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
@@ -9,8 +10,11 @@ import (
"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"
@@ -32,18 +36,20 @@ func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
TphPics: result.Pics,
})
if err != nil {
logger.Errorf("构建存储选择键盘失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("构建存储选择键盘失败: "+err.Error()), 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("标题: "),
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoTitlePrefix, nil)),
styling.Code(result.Page.Title),
styling.Plain("\n图片数量: "),
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoPicCountPrefix, nil)),
styling.Code(fmt.Sprintf("%d", len(result.Pics))),
styling.Plain("\n请选择存储位置"),
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoPromptSelectStorage, nil)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
return dispatcher.EndGroups
@@ -59,18 +65,16 @@ func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
}
func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
if err != nil {
return err
}
userID := update.GetUserChat().GetID()
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, result.Page, result.TphDir, result.Pics, stor, msg.ID)
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)
}

View 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+&nbsp;&nbsp;&nbsp;(.+)$`).ReplaceAllString(md, "<b>$1</b>")
md = regexp.MustCompile(`(?m)^#####\s+&nbsp;&nbsp;&nbsp;&nbsp;(.+)$`).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, "&nbsp;", " ")
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")
}

View 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
}

View 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)
}
}

View File

@@ -1,6 +1,20 @@
package mediautil
import "github.com/gotd/td/tg"
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) {
@@ -10,3 +24,122 @@ func IsSupported(media tg.MessageMediaClass) bool {
return false
}
}
type FilenameTemplateData struct {
MsgID string `json:"msgid,omitempty"`
MsgTags string `json:"msgtags,omitempty"`
MsgGen string `json:"msggen,omitempty"`
MsgDate string `json:"msgdate,omitempty"`
MsgRaw string `json:"msgraw,omitempty"`
OrigName string `json:"origname,omitempty"`
ChatID string `json:"chatid,omitempty"`
}
func (f FilenameTemplateData) ToMap() map[string]string {
return map[string]string{
"msgid": f.MsgID,
"msgtags": f.MsgTags,
"msggen": f.MsgGen,
"msgraw": f.MsgRaw,
"msgdate": f.MsgDate,
"origname": f.OrigName,
"chatid": f.ChatID,
}
}
func TfileOptions(ctx context.Context, user *database.User, message *tg.Message) []tfile.TGFileOption {
opts := make([]tfile.TGFileOption, 0)
var fnameOpt tfile.TGFileOption
switch user.FilenameStrategy {
case fnamest.Message.String():
fnameOpt = tfile.WithName(tgutil.GenFileNameFromMessage(*message))
case fnamest.Template.String():
if user.FilenameTemplate == "" {
log.FromContext(ctx).Warnf("empty filename template")
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
break
}
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
if err != nil {
log.FromContext(ctx).Errorf("failed to parse filename template: %s", err)
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
break
}
data := BuildFilenameTemplateData(message)
var sb strings.Builder
err = tmpl.Execute(&sb, data)
if err != nil {
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
break
}
fnameOpt = tfile.WithName(sb.String())
default:
fnameOpt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message))
}
opts = append(opts, fnameOpt, tfile.WithMessage(message))
return opts
}
func BuildFilenameTemplateData(message *tg.Message) map[string]string {
data := FilenameTemplateData{
MsgID: func() string {
id := message.GetID()
if id == 0 {
return ""
}
return fmt.Sprintf("%d", id)
}(),
MsgTags: func() string {
tags := strutil.ExtractTagsFromText(message.GetMessage())
if len(tags) == 0 {
return ""
}
return strings.Join(tags, "_")
}(),
MsgGen: tgutil.GenFileNameFromMessage(*message),
OrigName: func() string {
f, _ := tgutil.GetMediaFileName(message.Media)
return f
}(),
MsgDate: func() string {
date := message.GetDate()
if date == 0 {
return ""
}
t := time.Unix(int64(date), 0)
return t.Format("2006-01-02_15-04-05")
}(),
MsgRaw: message.GetMessage(),
ChatID: func() string {
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id,
// 无论它是否是从其他来源转发的
if message.GetPost() {
peer := message.GetPeerID()
switch p := peer.(type) {
case *tg.PeerChannel:
return intToStringOmitZero(p.ChannelID)
default: // impossible case
return intToStringOmitZero(tgutil.ChatIdFromPeer(peer))
}
}
fwdHeader, ok := message.GetFwdFrom()
if !ok {
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
}
fwdFrom, ok := fwdHeader.GetFromID()
if !ok {
return intToStringOmitZero(tgutil.ChatIdFromPeer(message.GetPeerID()))
}
return intToStringOmitZero(tgutil.ChatIdFromPeer(fwdFrom))
}(),
}.ToMap()
return data
}
func intToStringOmitZero(i int64) string {
if i == 0 {
return ""
}
return fmt.Sprintf("%d", i)
}

View File

@@ -5,22 +5,24 @@ import (
"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("使用方法: /dir <操作> <参数...>"),
styling.Plain("\n\n可用操作:\n"),
styling.Bold(i18n.T(i18nk.BotMsgDirHelpUsage, nil)),
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAvailableOps, nil)),
styling.Code("add"),
styling.Plain(" <存储名> <路径> - 添加路径\n"),
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAddSuffix, nil)),
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.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 {

View File

@@ -7,6 +7,8 @@ import (
"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"
)
@@ -14,15 +16,15 @@ func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, e
eb := entity.Builder{}
if err := styling.Perform(&eb,
styling.Bold(fmt.Sprintf("[%s]%s", item.Site, item.Title)),
styling.Plain("\n链接: "),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoLinkPrefix, nil)),
styling.Code(item.URL),
styling.Plain("\n作者: "),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoAuthorPrefix, nil)),
styling.Code(item.Author),
styling.Plain("\n描述: "),
styling.Code(strutil.Ellipsis(item.Description, 233)),
styling.Plain("\n文件数量: "),
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("\n预计总大小: "),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoTotalSizePrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB", func() float64 {
var totalSize int64
for _, res := range item.Resources {
@@ -30,9 +32,9 @@ func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, e
}
return float64(totalSize) / 1024 / 1024
}())),
styling.Plain("\n请选择存储位置"),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoPromptSelectStorage, nil)),
); err != nil {
return "", nil, fmt.Errorf("构建消息失败: %w", err)
return "", nil, fmt.Errorf("failed to build parsed text entity: %w", err)
}
text, entities := eb.Complete()
return text, entities, nil

View File

@@ -5,21 +5,28 @@ import (
"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("使用方法: /rule <操作> <参数...>"),
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[enabled])),
styling.Plain("\n\n可用操作:\n"),
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(" - 开关规则模式\n"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpSwitchSuffix, nil)),
styling.Code("add"),
styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAddSuffix, nil)),
styling.Code("del"),
styling.Plain(" <规则ID> - 删除规则\n"),
styling.Plain("\n当前已添加的规则:\n"),
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 {

View File

@@ -1,15 +0,0 @@
package msgelem
const (
SaveHelpText = `
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @acherkrau 114-514
`
)

View File

@@ -9,6 +9,8 @@ import (
"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"
@@ -45,6 +47,8 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
TphDirPath: adddata.TphDirPath,
ParsedItem: adddata.ParsedItem,
DirectLinks: adddata.DirectLinks,
}
dataid := xid.New().String()
err := cache.Set(dataid, data)
@@ -68,11 +72,14 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storage, file tfile.TGFileMessage, msgId int) (*tg.MessagesEditMessageRequest, error) {
eb := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("文件名: %s\n请选择存储位置", file.Name())
text := i18n.T(i18nk.BotMsgTasksInfoAddedToQueueFull, map[string]any{
"Filename": file.Name(),
"QueueLength": 0,
})
if err := styling.Perform(&eb,
styling.Plain("文件名: "),
styling.Plain(i18n.T(i18nk.BotMsgStorageInfoFilenamePrefix, nil)),
styling.Code(file.Name()),
styling.Plain("\n请选择存储位置"),
styling.Plain(i18n.T(i18nk.BotMsgStorageInfoPromptSelectStorage, nil)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
} else {
@@ -94,7 +101,10 @@ func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storag
}, nil
}
func BuildSetDefaultStorageMarkup(ctx context.Context, userID int64, stors []storage.Storage) (*tg.ReplyInlineMarkup, error) {
// 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{
@@ -119,7 +129,35 @@ func BuildSetDefaultStorageMarkup(ctx context.Context, userID int64, stors []sto
return markup, nil
}
func BuildSetDirKeyboard(dirs []database.Dir, dataid string) (*tg.ReplyInlineMarkup, error) {
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)
@@ -152,7 +190,7 @@ func BuildSetDirKeyboard(dirs []database.Dir, dataid string) (*tg.ReplyInlineMar
return nil, fmt.Errorf("failed to set default directory data in cache: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: "默认",
Text: i18n.T(i18nk.BotMsgDirButtonDefault, nil),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDefaultDataId),
})
markup := &tg.ReplyInlineMarkup{}

View File

@@ -2,13 +2,14 @@ package msgelem
import (
"context"
"fmt"
"strconv"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
)
func BuildTaskAddedEntities(
@@ -18,11 +19,15 @@ func BuildTaskAddedEntities(
) (string, []tg.MessageEntityClass) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", filename, queueLength)
text := i18n.T(i18nk.BotMsgTasksInfoAddedToQueueFull, map[string]any{
"Filename": filename,
"QueueLength": queueLength,
})
if err := styling.Perform(&entityBuilder,
styling.Plain("已添加到任务队列\n文件名: "),
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoAddedToQueuePrefix, nil)),
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoFilenamePrefix, nil)),
styling.Code(filename),
styling.Plain("\n当前排队任务数: "),
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoQueueLengthPrefix, nil)),
styling.Bold(strconv.Itoa(queueLength)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)

View File

@@ -1,19 +0,0 @@
package msgelem
const (
WatchHelpText = `
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
命令语法:
/watch <chat_id> [filter]
参数:
- <chat_id>: 聊天的 ID 或用户名
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
命令示例:
/watch 2229835658 msgre:.*plana.*
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
`
)

View File

@@ -34,7 +34,7 @@ func (m matchedStorName) String() string {
}
// can we use this storage name directly?
func (m matchedStorName) IsUsable() bool {
func (m matchedStorName) Usable() bool {
return m != "" && m != rule.RuleStorNameChosen
}
@@ -48,9 +48,9 @@ func (m MatchedDirPath) NeedNewForAlbum() bool {
return m != "" && m == rule.RuleDirPathNewForAlbum
}
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matchedStorageName matchedStorName, dirPath MatchedDirPath) {
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matched bool, matchedStorageName matchedStorName, dirPath MatchedDirPath) {
if inputs == nil || len(rules) == 0 {
return "", ""
return false, "", ""
}
logger := log.FromContext(ctx)
for _, ur := range rules {
@@ -106,5 +106,8 @@ func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (m
}
}
}
return
if matchedStorageName != "" || dirPath != "" {
return true, matchedStorageName, dirPath
}
return false, "", ""
}

View 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
}

View File

@@ -17,11 +17,12 @@ import (
"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/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
@@ -37,23 +38,25 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *
return nil, nil, dispatcher.ContinueGroups
}
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
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, options...)
// options := []tfile.TGFileOption{
// tfile.WithMessage(message),
// }
// if len(tfileopts) > 0 {
// options = append(options, tfileopts...)
// } else {
// options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
// }
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, tfileopts...)
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil)
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
@@ -64,12 +67,12 @@ type EditMessageFunc func(text string, markup tg.ReplyMarkupClass)
// 获取链接中的文件并回复等待消息
func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Update) (replied *types.Message, files []tfile.TGFileMessage, editReplied EditMessageFunc, err error) {
logger := log.FromContext(ctx)
msgLinks := re.TgMessageLinkRegexp.FindAllString(update.EffectiveMessage.GetMessage(), -1)
msgLinks := re.TgMessageLinkRegexp.FindAllString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message), -1)
if len(msgLinks) == 0 {
logger.Warn("no matched message links but called handleMessageLink")
return nil, nil, nil, dispatcher.EndGroups
}
replied, err = ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
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
@@ -86,7 +89,9 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
logger.Errorf("failed to get user from db: %s", err)
editReplied("获取用户信息失败: "+err.Error(), nil)
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))
@@ -100,14 +105,8 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
logger.Debugf("message %d has no media", msg.GetID())
return
}
var opt tfile.TGFileOption
switch user.FilenameStrategy {
case fnamest.Message.String():
opt = tfile.WithName(tgutil.GenFileNameFromMessage(*msg))
default:
opt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg))
}
file, err := tfile.FromMediaMessage(media, client, msg, opt)
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
@@ -117,7 +116,9 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
tctx := ctx
if config.C().Telegram.Userbot.Enable {
tctx = uc.GetCtx()
if uc.GetCtx() != nil {
tctx = uc.GetCtx()
}
}
for _, link := range msgLinks {
@@ -133,12 +134,12 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
}
msg, err := tgutil.GetMessageByID(tctx, chatId, msgId)
if err != nil {
logger.Errorf("failed to get message by ID: %s", err)
logger.Error(err)
continue
}
groupID, isGroup := msg.GetGroupedID()
if isGroup && groupID != 0 && !linkUrl.Query().Has("single") {
gmsgs, err := tgutil.GetGroupedMessages(ctx, chatId, msg)
gmsgs, err := tgutil.GetGroupedMessages(tctx, chatId, msg)
if err != nil {
logger.Errorf("failed to get grouped messages: %s", err)
} else {
@@ -151,7 +152,7 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
}
}
if len(files) == 0 {
editReplied("没有找到可保存的文件", nil)
editReplied(i18n.T(i18nk.BotMsgCommonErrorNoSavableFilesFound, nil), nil)
return nil, nil, nil, dispatcher.EndGroups
}
return replied, files, editReplied, nil
@@ -162,7 +163,7 @@ func GetCallbackDataWithAnswer[DataType any](ctx *ext.Context, update *ext.Updat
if !ok {
log.FromContext(ctx).Warnf("Invalid data ID: %s", dataid)
queryID := update.CallbackQuery.GetQueryID()
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "数据已过期或无效"))
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorDataExpired, nil)))
var zero DataType
return zero, dispatcher.EndGroups
}
@@ -178,7 +179,7 @@ type TelegraphResult struct {
// return replied message, image urls, telegraph path(unescaped), error
func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) {
logger := log.FromContext(ctx)
tphurl := re.TelegraphUrlRegexp.FindString(update.EffectiveMessage.GetMessage()) // TODO: batch urls
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
@@ -187,18 +188,24 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
tphdir, err := url.PathUnescape(pagepath)
if err != nil {
logger.Errorf("Failed to unescape telegraph path: %s", err)
ctx.Reply(update, ext.ReplyTextString("解析 telegraph 路径失败: "+err.Error()), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorParseTelegraphPathFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil, nil, dispatcher.EndGroups
}
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取 telegraph 页面..."), nil)
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("获取 telegraph 页面失败: "+err.Error()), nil)
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)
@@ -222,13 +229,17 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
}
if node.Tag == "img" {
if src, ok := node.Attrs["src"]; ok {
if strings.HasPrefix(src, "/file/") {
// handle images on telegra.ph server
src = "https://telegra.ph" + src
}
imgs = append(imgs, src)
}
}
}
if len(imgs) == 0 {
logger.Warn("No images found in telegraph page")
ctx.Reply(update, ext.ReplyTextString("在 telegraph 页面中未找到图片"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoImagesInTelegraphPage, nil)), nil)
return nil, nil, dispatcher.EndGroups
}
return msg, &TelegraphResult{

View File

@@ -6,9 +6,11 @@ import (
"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"
"github.com/krau/SaveAny-Bot/core/tasks/parsed"
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"
@@ -21,7 +23,9 @@ func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirP
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: "任务添加失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}

View File

@@ -1,7 +1,6 @@
package shortcut
import (
"fmt"
"path"
"strings"
@@ -11,6 +10,8 @@ import (
"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"
@@ -29,26 +30,35 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
logger.Errorf("Failed to get user by chat ID: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取用户失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
if user.ApplyRule && user.Rules != nil {
matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
dirPath = matchedDirPath.String()
if matchedStorageName.IsUsable() {
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: "获取存储失败: " + err.Error(),
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()
@@ -60,7 +70,9 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
logger.Errorf("create task failed: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "创建任务失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -68,7 +80,9 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
logger.Errorf("add task failed: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "添加任务失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -90,7 +104,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
logger.Errorf("Failed to get user by chat ID: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取用户失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -101,10 +117,12 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
if !useRule {
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
storname := storName.String()
if !storName.IsUsable() {
if !storName.Usable() {
storname = stor.Name()
}
return storname, dirP
@@ -125,7 +143,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
logger.Errorf("Failed to get storage by user ID and name: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取存储失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -137,7 +157,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
logger.Errorf("Failed to create task element: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务创建失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -172,7 +194,9 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
logger.Errorf("Failed to create task element for album file: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务创建失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -187,13 +211,17 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
logger.Errorf("Failed to add batch task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "批量任务添加失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: fmt.Sprintf("已添加批量任务, 共 %d 个文件", len(files)),
Message: i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{
"Count": len(files),
}),
ReplyMarkup: nil,
})
return dispatcher.EndGroups

View File

@@ -6,6 +6,8 @@ import (
"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"
@@ -38,7 +40,9 @@ func CreateAndAddtelegraphWithEdit(
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务添加失败: " + err.Error(),
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}

View File

@@ -1,39 +1,51 @@
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/msgelem"
"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(msgelem.WatchHelpText), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
return dispatcher.EndGroups
}
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
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("请先设置默认存储, 使用 /storage 命令"), nil)
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("无效的ID或用户名: "+err.Error()), 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)
@@ -42,7 +54,7 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
return dispatcher.EndGroups
}
if watching {
ctx.Reply(update, ext.ReplyTextString("已经在监听此聊天"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoAlreadyWatchingChat)), nil)
return dispatcher.EndGroups
}
filter := ""
@@ -51,19 +63,19 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
filterType := strings.Split(filterArg, ":")[0]
filterData := strings.Split(filterArg, ":")[1]
if filterType == "" || filterData == "" {
ctx.Reply(update, ext.ReplyTextString("过滤器格式错误, 请使用 <过滤器类型>:<表达式>"), nil)
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("正则表达式格式错误: "+err.Error()), 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("不支持的过滤器类型, 请参阅文档"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorFilterTypeUnsupported)), nil)
return dispatcher.EndGroups
}
}
@@ -73,10 +85,40 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
Filter: filter,
}); err != nil {
logger.Errorf("Failed to watch chat %d: %s", chatID, err)
ctx.Reply(update, ext.ReplyTextString("监听聊天失败: "+err.Error()), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorWatchChatFailed, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("已开始监听聊天: "+chatArg), nil)
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
}
@@ -84,27 +126,133 @@ func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString("请提供要取消监听的聊天ID或用户名"), nil)
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("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), 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("无效的ID或用户名: "+err.Error()), 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("取消监听聊天失败: "+err.Error()), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorUnwatchChatFailed, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("已取消监听聊天: "+chatArg), nil)
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())
}
}
}

View File

@@ -1,80 +1,57 @@
package user
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/celestix/gotgproto"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/log"
"github.com/fatih/color"
"golang.org/x/term"
)
type terminalAuthConversator struct{}
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
phone := ""
err := huh.NewInput().Title("Your Phone Number").
Placeholder("+44 123456").
Prompt("> ").
Value(&phone).
WithTheme(huh.ThemeCatppuccin()).
Run()
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
}
log.Info("Sending code to your phone number...")
return strings.TrimSpace(phone), nil
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
fmt.Println("Your Phone Number (e.g. +44 123456):")
return readLine("> ")
}
func (t *terminalAuthConversator) AskCode() (string, error) {
code := ""
err := huh.NewInput().Title("Your Code").
Placeholder("123456").
Value(&code).
Prompt("> ").
WithTheme(huh.ThemeCatppuccin()).
Run()
if err != nil {
return "", err
}
return strings.TrimSpace(code), nil
fmt.Println("Your Code (e.g. 123456):")
return readLine("> ")
}
func (t *terminalAuthConversator) AskPassword() (string, error) {
pwd := ""
err := huh.NewInput().Title("Your 2FA Password").
EchoMode(huh.EchoModePassword).
Value(&pwd).
Prompt("> ").
WithTheme(huh.ThemeCatppuccin()).
Run()
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(pwd), nil
return strings.TrimSpace(string(bytePwd)), nil
}
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
switch authStatus.Event {
case gotgproto.AuthStatusPhoneRetrial:
color.Red("The phone number you just entered seems to be incorrect,")
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
color.Red("Please try again....")
fmt.Printf("The phone number is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
case gotgproto.AuthStatusPasswordRetrial:
color.Red("The 2FA password you just entered seems to be incorrect,")
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
color.Red("Please try again....")
fmt.Printf("The 2FA password is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
case gotgproto.AuthStatusPhoneCodeRetrial:
color.Red("The OTP you just entered seems to be incorrect,")
color.Red("Attempts Left: %d", authStatus.AttemptsLeft)
color.Red("Please try again....")
fmt.Printf("The OTP code is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
default:
}
}

View File

@@ -12,37 +12,27 @@ import (
"github.com/celestix/gotgproto/sessionMaker"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy"
)
var uc *gotgproto.Client
var ectx *ext.Context
func GetCtx() *ext.Context {
if uc == nil {
panic("User client is not initialized, please call Login first")
}
if ectx != nil {
return ectx
}
if uc == nil {
return nil
}
ectx = uc.CreateContext()
return ectx
}
func GetClient() *gotgproto.Client {
if uc == nil {
panic("User client is not initialized, please call Login first")
}
return uc
}
func Login(ctx context.Context) (*gotgproto.Client, error) {
log.FromContext(ctx).Debug("Logging in user client")
if uc != nil {
@@ -53,28 +43,20 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
err error
})
go func() {
var resolver dcs.Resolver
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil {
res <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
} else {
resolver = dcs.DefaultResolver()
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(gormlite.Open(config.C().Telegram.Userbot.Session)),
Session: sessionMaker.SqlSession(database.GetDialect(config.C().Telegram.Userbot.Session)),
AuthConversator: &terminalAuthConversator{},
Context: ctx,
DisableCopyright: true,

View File

@@ -3,14 +3,17 @@ package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"go/format"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/goccy/go-yaml"
)
func main() {
@@ -20,28 +23,27 @@ func main() {
flag.Parse()
keys := make(map[string]struct{})
re := regexp.MustCompile(`^\s*\[+\s*([^\]\[]+)\s*\]+`)
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".toml") {
if d.IsDir() || !(strings.HasSuffix(d.Name(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
return nil
}
f, err := os.Open(path)
data, err := os.ReadFile(path)
if err != nil {
return err
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
if m := re.FindStringSubmatch(s.Text()); m != nil {
keys[m[1]] = struct{}{}
}
var content map[string]any
if err := yaml.Unmarshal(data, &content); err != nil {
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
}
return s.Err()
collectKeys(content, "", keys)
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
@@ -54,31 +56,60 @@ func main() {
}
sort.Strings(list)
f, err := os.Create(*out)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err)
os.Exit(1)
}
defer f.Close()
w := bufio.NewWriter(f)
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n")
// 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 = %q\n", name, 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 {
if len(p) > 0 {
parts[i] = strings.ToUpper(string(p[0])) + p[1:]
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, "")
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"fmt"
"github.com/krau/SaveAny-Bot/cmd/upload"
"github.com/krau/SaveAny-Bot/config"
"github.com/spf13/cobra"
)
@@ -13,6 +15,11 @@ var rootCmd = &cobra.Command{
Run: Run,
}
func init() {
config.RegisterFlags(rootCmd)
upload.Register(rootCmd)
}
func Execute(ctx context.Context) {
if err := rootCmd.ExecuteContext(ctx); err != nil {
fmt.Println(err)

View File

@@ -14,7 +14,6 @@ import (
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
@@ -25,7 +24,7 @@ import (
)
func Run(cmd *cobra.Command, _ []string) {
ctx := cmd.Context()
ctx, cancel := context.WithCancel(cmd.Context())
logger := log.NewWithOptions(os.Stdout, log.Options{
Level: log.DebugLevel,
ReportTimestamp: true,
@@ -34,24 +33,32 @@ func Run(cmd *cobra.Command, _ []string) {
})
ctx = log.WithContext(ctx, logger)
initAll(ctx)
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(i18n.T(i18nk.Exiting))
defer logger.Info(i18n.T(i18nk.Bye))
logger.Info("Exiting...")
defer logger.Info("Exit complete")
cleanCache()
}
func initAll(ctx context.Context) {
if err := config.Init(ctx); err != nil {
fmt.Println("Failed to load config:", err)
os.Exit(1)
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(i18n.T(i18nk.Initing))
logger.Info("Initializing...")
database.Init(ctx)
storage.LoadStorages(ctx)
if config.C().Parser.PluginEnable {
@@ -59,17 +66,17 @@ func initAll(ctx context.Context) {
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", "dir", dir)
logger.Debug("Loaded parser plugins from directory", "dir", dir)
}
}
}
if config.C().Telegram.Userbot.Enable {
_, err := userclient.Login(ctx)
if err != nil {
logger.Fatalf("User client login failed: %s", err)
logger.Fatal("User login failed", "error", err)
}
}
bot.Init(ctx)
return bot.Init(ctx), nil
}
func cleanCache() {
@@ -78,33 +85,23 @@ func cleanCache() {
}
if config.C().Temp.BasePath != "" && !config.C().Stream {
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{
"Path": config.C().Temp.BasePath,
}))
log.Error("Invalid cache directory", "path", config.C().Temp.BasePath)
return
}
currentDir, err := os.Getwd()
if err != nil {
log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
"Error": err,
}))
log.Error("Failed to get working directory", "error", err)
return
}
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
cachePath, err = filepath.Abs(cachePath)
if err != nil {
log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
"Error": err,
}))
log.Error("Failed to get absolute cache path", "error", err)
return
}
log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
"Path": cachePath,
}))
log.Info("Cleaning cache directory", "path", cachePath)
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
"Error": err,
}))
log.Error("Failed to clean cache directory", "error", err)
}
}
}

128
cmd/upload/cmd.go Normal file
View 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
}

View 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
View 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()
}

View File

@@ -5,7 +5,7 @@ import (
"runtime"
"github.com/krau/SaveAny-Bot/config"
"github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/unvgo/ghselfupdate"
"github.com/blang/semver"
"github.com/spf13/cobra"
@@ -26,17 +26,32 @@ var upgradeCmd = &cobra.Command{
Short: "Upgrade saveany-bot to the latest version",
Run: func(cmd *cobra.Command, args []string) {
v := semver.MustParse(config.Version)
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
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", config.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)
},
}

View File

@@ -1,3 +1,5 @@
// [TODO] complete the i18n support
package i18n
import (
@@ -5,12 +7,13 @@ import (
"maps"
"github.com/goccy/go-yaml"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/pelletier/go-toml/v2"
"golang.org/x/text/language"
)
//go:embed locale/*.toml
//go:embed locale/*
var localesFS embed.FS
var (
@@ -20,7 +23,7 @@ var (
func Init(lang string) {
bundle = i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale")
if err != nil {
panic("failed to read locale directory: " + err.Error())
@@ -39,7 +42,7 @@ func Init(lang string) {
}
}
func T(key string, templateData ...map[string]any) string {
func T(key i18nk.Key, templateData ...map[string]any) string {
if localizer == nil || bundle == nil {
panic("localizer or bundle is not initialized, call Init() first")
}
@@ -48,11 +51,11 @@ func T(key string, templateData ...map[string]any) string {
maps.Copy(templateDataMap, data)
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
MessageID: string(key),
TemplateData: templateDataMap,
})
if err != nil {
return key
return string(key)
}
return msg
}
@@ -77,32 +80,32 @@ func TWithLang(lang, key string, templateData ...map[string]any) string {
}
// Only use in tests or packages that load before i18n
func TWithoutInit(lang, key string, templateData ...map[string]any) string {
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
bundle := i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("toml", toml.Unmarshal)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale")
if err != nil {
return key
return string(key)
}
for _, file := range files {
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
return key
return string(key)
}
}
localizer := i18n.NewLocalizer(bundle, lang)
if localizer == nil {
return key
return string(key)
}
templateDataMap := make(map[string]any)
for _, data := range templateData {
maps.Copy(templateDataMap, data)
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: key,
MessageID: string(key),
TemplateData: templateDataMap,
})
if err != nil {
return key
return string(key)
}
return msg
}

View File

@@ -1,19 +1,245 @@
// Code generated by cmd/gen_i18n. DO NOT EDIT.
// Code generated by cmd/geni18n. DO NOT EDIT.
package i18nk
type Key string
const (
CleanCacheFailed = "CleanCacheFailed"
CleaningCache = "CleaningCache"
ConfigInvalidDuplicateStorageName = "ConfigInvalid.DuplicateStorageName"
ConfigInvalidWorkersOrRetry = "ConfigInvalid.WorkersOrRetry"
CreateRmTimerFailed = "CreateRmTimerFailed"
GetCacheAbsPathFailed = "GetCacheAbsPathFailed"
GetWorkdirFailed = "GetWorkdirFailed"
InvalidCacheDir = "InvalidCacheDir"
LoadedStorages = "LoadedStorages"
RemoveFileAfter = "RemoveFileAfter"
RemoveFileFailed = "RemoveFileFailed"
Bye = "bye"
Exiting = "exiting"
Initing = "initing"
BotMsgAria2ErrorAddingAria2Download Key = "bot.msg.aria2.error_adding_aria2_download"
BotMsgAria2ErrorAria2ClientInitFailed Key = "bot.msg.aria2.error_aria2_client_init_failed"
BotMsgAria2ErrorAria2NotEnabled Key = "bot.msg.aria2.error_aria2_not_enabled"
BotMsgAria2InfoAddingAria2Download Key = "bot.msg.aria2.info_adding_aria2_download"
BotMsgAria2InfoAria2DownloadAdded Key = "bot.msg.aria2.info_aria2_download_added"
BotMsgCancelErrorCancelFailed Key = "bot.msg.cancel.error_cancel_failed"
BotMsgCancelInfoCancelRequested Key = "bot.msg.cancel.info_cancel_requested"
BotMsgCancelInfoCancellingTask Key = "bot.msg.cancel.info_cancelling_task"
BotMsgCancelUsage Key = "bot.msg.cancel.usage"
BotMsgCmdAria2dl Key = "bot.msg.cmd.aria2dl"
BotMsgCmdCancel Key = "bot.msg.cmd.cancel"
BotMsgCmdConfig Key = "bot.msg.cmd.config"
BotMsgCmdDir Key = "bot.msg.cmd.dir"
BotMsgCmdDl Key = "bot.msg.cmd.dl"
BotMsgCmdFnametmpl Key = "bot.msg.cmd.fnametmpl"
BotMsgCmdHelp Key = "bot.msg.cmd.help"
BotMsgCmdLswatch Key = "bot.msg.cmd.lswatch"
BotMsgCmdParser Key = "bot.msg.cmd.parser"
BotMsgCmdRule Key = "bot.msg.cmd.rule"
BotMsgCmdSave Key = "bot.msg.cmd.save"
BotMsgCmdSilent Key = "bot.msg.cmd.silent"
BotMsgCmdStart Key = "bot.msg.cmd.start"
BotMsgCmdStorage Key = "bot.msg.cmd.storage"
BotMsgCmdSyncpeers Key = "bot.msg.cmd.syncpeers"
BotMsgCmdTask Key = "bot.msg.cmd.task"
BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch"
BotMsgCmdUpdate Key = "bot.msg.cmd.update"
BotMsgCmdWatch Key = "bot.msg.cmd.watch"
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"
BotMsgSyncpeersDone Key = "bot.msg.syncpeers.done"
BotMsgSyncpeersFailed Key = "bot.msg.syncpeers.failed"
BotMsgSyncpeersStart Key = "bot.msg.syncpeers.start"
BotMsgSyncpeersSuccess Key = "bot.msg.syncpeers.success"
BotMsgTasksCancelFailed Key = "bot.msg.tasks.cancel_failed"
BotMsgTasksCancelRequestedPrefix Key = "bot.msg.tasks.cancel_requested_prefix"
BotMsgTasksFieldCreated Key = "bot.msg.tasks.field_created"
BotMsgTasksFieldId Key = "bot.msg.tasks.field_id"
BotMsgTasksFieldStatus Key = "bot.msg.tasks.field_status"
BotMsgTasksFieldTitle Key = "bot.msg.tasks.field_title"
BotMsgTasksInfoAddedToQueueFull Key = "bot.msg.tasks.info_added_to_queue_full"
BotMsgTasksInfoAddedToQueuePrefix Key = "bot.msg.tasks.info_added_to_queue_prefix"
BotMsgTasksInfoFilenamePrefix Key = "bot.msg.tasks.info_filename_prefix"
BotMsgTasksInfoQueueLengthPrefix Key = "bot.msg.tasks.info_queue_length_prefix"
BotMsgTasksQueuedEmpty Key = "bot.msg.tasks.queued_empty"
BotMsgTasksQueuedTitle Key = "bot.msg.tasks.queued_title"
BotMsgTasksRunningEmpty Key = "bot.msg.tasks.running_empty"
BotMsgTasksRunningTitle Key = "bot.msg.tasks.running_title"
BotMsgTasksStatusCancelRequested Key = "bot.msg.tasks.status_cancel_requested"
BotMsgTasksStatusQueued Key = "bot.msg.tasks.status_queued"
BotMsgTasksStatusRunning Key = "bot.msg.tasks.status_running"
BotMsgTasksTotalPrefix Key = "bot.msg.tasks.total_prefix"
BotMsgTasksTruncatedNote Key = "bot.msg.tasks.truncated_note"
BotMsgTasksUsage Key = "bot.msg.tasks.usage"
BotMsgTasksUsageCancel Key = "bot.msg.tasks.usage_cancel"
BotMsgTelegraphErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.telegraph.error_build_storage_select_keyboard_failed"
BotMsgTelegraphInfoPicCountPrefix Key = "bot.msg.telegraph.info_pic_count_prefix"
BotMsgTelegraphInfoPromptSelectStorage Key = "bot.msg.telegraph.info_prompt_select_storage"
BotMsgTelegraphInfoTitlePrefix Key = "bot.msg.telegraph.info_title_prefix"
BotMsgUpdateButtonUpgrade Key = "bot.msg.update.button_upgrade"
BotMsgUpdateErrorCheckLatestFailed Key = "bot.msg.update.error_check_latest_failed"
BotMsgUpdateErrorNoReleaseFound Key = "bot.msg.update.error_no_release_found"
BotMsgUpdateErrorUpgradeFailed Key = "bot.msg.update.error_upgrade_failed"
BotMsgUpdateErrorVersionVarInvalid Key = "bot.msg.update.error_version_var_invalid"
BotMsgUpdateInfoAlreadyLatest Key = "bot.msg.update.info_already_latest"
BotMsgUpdateInfoMajorUpgradeRequired Key = "bot.msg.update.info_major_upgrade_required"
BotMsgUpdateInfoNewVersionInDocker Key = "bot.msg.update.info_new_version_in_docker"
BotMsgUpdateInfoNewVersionPromptUpgrade Key = "bot.msg.update.info_new_version_prompt_upgrade"
BotMsgUpdateInfoUpgradeSuccess Key = "bot.msg.update.info_upgrade_success"
BotMsgUpdateInfoUpgradingWithVersion Key = "bot.msg.update.info_upgrading_with_version"
BotMsgWatchErrorFilterFormatInvalid Key = "bot.msg.watch.error_filter_format_invalid"
BotMsgWatchErrorFilterTypeUnsupported Key = "bot.msg.watch.error_filter_type_unsupported"
BotMsgWatchErrorUnwatchChatFailed Key = "bot.msg.watch.error_unwatch_chat_failed"
BotMsgWatchErrorUnwatchNoChatProvided Key = "bot.msg.watch.error_unwatch_no_chat_provided"
BotMsgWatchErrorWatchChatFailed Key = "bot.msg.watch.error_watch_chat_failed"
BotMsgWatchInfoAlreadyWatchingChat Key = "bot.msg.watch.info_already_watching_chat"
BotMsgWatchInfoWatchChatStarted Key = "bot.msg.watch.info_watch_chat_started"
BotMsgWatchInfoWatchChatStopped Key = "bot.msg.watch.info_watch_chat_stopped"
BotMsgWatchInfoWatchListEmpty Key = "bot.msg.watch.info_watch_list_empty"
BotMsgWatchInfoWatchListFilterPrefix Key = "bot.msg.watch.info_watch_list_filter_prefix"
BotMsgWatchInfoWatchListHeader Key = "bot.msg.watch.info_watch_list_header"
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
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"
)

332
common/i18n/locale/en.yaml Normal file
View File

@@ -0,0 +1,332 @@
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)
/syncpeers - Sync peer chats (UserBot)
/update - Check and upgrade to latest version
Usage guide: https://sabot.unv.app/usage
cmd:
start: "Start using"
silent: "Toggle silent mode"
storage: "Set default storage"
dir: "Manage storage directories"
rule: "Manage auto-save rules"
save: "Save files"
dl: "Download files from given links"
task: "Manage task queue"
cancel: "Cancel task"
watch: "Watch chats (UserBot)"
unwatch: "Stop watching chats (UserBot)"
lswatch: "List watched chats (UserBot)"
config: "Modify configuration"
fnametmpl: "Set filename template"
help: "Show help"
parser: "Manage parsers"
update: "Check for updates"
syncpeers: "Sync peer chats (UserBot)"
save_help_text: |
Usage:
1. Reply to the file you want to save with this command, optional filename parameter.
Example:
/save custom_file_name.mp4
2. After setting default storage, send /save <channel_id/username> <message_id_range> to batch save files. Rules will be applied; if no rule matches, default storage will be used.
Example:
/save @acherkrau 114-514
watch_help_text: |
Use /watch to watch messages in a chat and automatically save them to the default storage, following storage rules.
Syntax:
/watch <chat_id> [filter]
Parameters:
- <chat_id>: Chat ID or username
- [filter]: Optional, format is filter_type:expression , see docs for all supported filters
Example:
/watch -1002229835658 msgre:.*plana.*
This will watch chat with ID -1002229835658 and save all media messages containing "plana".
common:
cancel_button_text: "Cancel"
error_invalid_regex: "Invalid regex: {{.Error}}"
error_invalid_msg_id_range: "Invalid message ID range: {{.Error}}"
error_invalid_id_or_username: "Invalid ID or username: {{.Error}}"
error_get_messages_failed: "Failed to get messages: {{.Error}}"
info_fetching_messages: "Fetching messages..."
error_no_messages_in_range: "No messages found in the specified range"
error_no_savable_messages_in_range: "No savable messages found in the specified range"
error_build_storage_select_message_failed: "Failed to build storage selection message: {{.Error}}"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
info_found_files_select_storage: "Found {{.Count}} files, please select storage"
error_get_user_failed: "Failed to get user"
error_get_user_with_err_failed: "Failed to get user: {{.Error}}"
error_default_storage_not_set: "Please set a default storage first with /storage"
error_no_available_storage: "No available storage"
error_get_storage_failed: "Failed to get storage: {{.Error}}"
prompt_select_default_storage: "Please select a storage to set as default"
error_data_expired: "Data has expired or is invalid"
error_task_add_failed: "Failed to add task: {{.Error}}"
info_task_added: "Task added"
info_batch_tasks_added: "Batch tasks added, total {{.Count}} files"
error_task_create_failed: "Failed to create task: {{.Error}}"
error_get_dir_failed: "Failed to get directory: {{.Error}}"
prompt_select_dir: "Please select a directory to store to"
prompt_select_default_dir: "Please select a default directory to save to"
info_default_storage_set: "Default storage set to: {{.Name}}"
info_default_storage_with_dir_set: "Default storage set to: {{.Name}}:/{{.Dir}}"
error_get_user_info_failed: "Failed to get user info: {{.Error}}"
error_update_user_info_failed: "Failed to update user info: {{.Error}}"
info_silent_mode_on: "Silent mode enabled"
info_silent_mode_off: "Silent mode disabled"
error_get_file_failed: "Failed to get file: {{.Error}}"
info_fetching_file_info: "Fetching file info..."
error_no_savable_files_found: "No savable files found"
error_parse_telegraph_path_failed: "Failed to parse telegraph path: {{.Error}}"
info_fetching_telegraph_page: "Fetching telegraph page..."
error_get_telegraph_page_failed: "Failed to get telegraph page: {{.Error}}"
error_no_images_in_telegraph_page: "No images found in telegraph page"
error_build_dir_select_keyboard_failed: "Failed to build directory selection keyboard: {{.Error}}"
error_no_permission: |
You are not in the whitelist and cannot use this bot.
You can deploy your own instance: https://github.com/krau/SaveAny-Bot
save:
error_invalid_id_or_username: "Invalid ID or username: {{.Error}}"
watch:
error_filter_format_invalid: "Invalid filter format, please use <type>:<expression>"
error_filter_type_unsupported: "Unsupported filter type, please see the docs"
error_watch_chat_failed: "Failed to watch chat: {{.Error}}"
info_watch_chat_started: "Started watching chat: {{.Chat}}"
info_already_watching_chat: "Already watching this chat"
info_watch_list_empty: "No chats are being watched currently"
info_watch_list_header: "Currently watched chats:\n"
info_watch_list_filter_prefix: " (filter: "
error_unwatch_no_chat_provided: "Please provide a chat ID or username to unwatch"
error_unwatch_chat_failed: "Failed to unwatch chat: {{.Error}}"
info_watch_chat_stopped: "Stopped watching chat: {{.Chat}}"
tasks:
usage_cancel: "Usage: /tasks cancel <task_id>"
usage: "Usage: /tasks [running|queued|cancel <task_id>]"
cancel_failed: "Failed to cancel task: {{.Error}}"
cancel_requested_prefix: "Cancel requested for task: "
running_empty: "No running tasks"
running_title: "Currently running tasks:"
total_prefix: "Total: {{.Count}}\n"
field_id: "ID: "
field_title: "Title: "
field_created: "Created at: "
field_status: "Status: "
status_running: "Running"
status_queued: "Queued"
status_cancel_requested: "Cancel requested"
queued_empty: "No queued tasks"
queued_title: "Currently queued tasks:"
truncated_note: "...\nShowing first 10 tasks, total {{.Count}} tasks"
info_added_to_queue_full: "Added to task queue\nFilename: {{.Filename}}\nCurrent queued tasks: {{.QueueLength}}"
info_added_to_queue_prefix: "Added to task queue\n"
info_filename_prefix: "Filename: "
info_queue_length_prefix: "\nCurrent queued tasks: "
rule:
error_get_user_rules_failed: "Failed to get user rules"
error_update_user_failed: "Failed to update user"
info_rule_mode_enabled: "Rule mode enabled"
info_rule_mode_disabled: "Rule mode disabled"
error_invalid_rule_type: "Invalid rule type: {{.Type}}\nAvailable: {{.Available}}"
error_create_rule_failed: "Failed to create rule"
info_create_rule_success: "Rule created successfully"
prompt_provide_rule_id: "Please provide rule ID"
error_invalid_rule_id: "Invalid rule ID"
error_delete_rule_failed: "Failed to delete rule"
info_delete_rule_success: "Rule deleted successfully"
help_usage: "Usage: /rule <op> <args...>"
help_current_mode_enabled: "\nRule mode is currently enabled"
help_current_mode_disabled: "\nRule mode is currently disabled"
help_available_ops: "\n\nAvailable operations:\n"
help_switch_suffix: " - Toggle rule mode\n"
help_add_suffix: " <type> <data> <storage_name> <path> - Add rule\n"
help_del_suffix: " <rule_id> - Delete rule\n"
help_existing_rules_prefix: "\nCurrent rules:\n"
dir:
error_get_user_dirs_failed: "Failed to get user directories"
error_get_user_failed: "Failed to get user"
error_create_dir_failed: "Failed to create directory"
info_create_dir_success: "Directory created successfully"
error_invalid_dir_id: "Invalid directory ID"
error_delete_dir_failed: "Failed to delete directory"
info_delete_dir_success: "Directory deleted successfully"
error_unknown_operation: "Unknown operation"
help_usage: "Usage: /dir <op> <args...>"
help_available_ops: "\n\nAvailable operations:\n"
help_add_suffix: " <storage_name> <path> - Add path\n"
help_del_suffix: " <path_id> - Delete path\n"
help_add_example_prefix: "\nAdd path example:\n"
help_add_example_cmd: "/dir add local1 path/to/dir"
help_del_example_prefix: "\n\nDelete path example:\n"
help_del_example_cmd: "/dir del 3"
help_existing_dirs_prefix: "\n\nCurrent paths:\n"
button_default: "Default"
parser:
help_text: |
Usage:
/parser install <reply a file> - Install parser
plugin_not_enabled: "Parser plugin feature is not enabled"
prompt_reply_with_parser_file: "Please reply with a message containing the parser file"
error_no_valid_file_in_reply: "The replied message does not contain a valid file"
error_wrong_file_type: "Wrong file type"
error_file_too_large: "File too large"
error_get_filename_failed: "Failed to get filename"
error_only_js_supported: "Only .js files are supported as parsers"
error_download_file_failed: "Failed to download file: {{.Error}}"
error_install_plugin_failed: "Failed to install plugin: {{.Error}}"
info_install_plugin_success: "Plugin installed: {{.Name}}"
parse:
info_parsing: "Parsing..."
error_parse_text_failed: "Failed to parse text: {{.Error}}"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
error_build_parsed_text_entity_failed: "Failed to build parsed text entity: {{.Error}}"
info_link_prefix: "\nLink: "
info_author_prefix: "\nAuthor: "
info_description_prefix: "\nDescription: "
info_file_count_prefix: "\nFile count: "
info_total_size_prefix: "\nEstimated total size: "
info_prompt_select_storage: "\nPlease select storage"
telegraph:
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
info_title_prefix: "Title: "
info_pic_count_prefix: "\nImage count: "
info_prompt_select_storage: "\nPlease select storage"
update:
error_version_var_invalid: "Currently in development version or version info injection failed: {{.Error}}"
error_check_latest_failed: "Failed to check latest version: {{.Error}}"
error_no_release_found: "No release found"
info_major_upgrade_required: "Major upgrade detected: {{.Current}} -> {{.Latest}} , please download the latest version from GitHub and check the migration guide"
info_already_latest: "Already on latest version: {{.Version}}"
info_new_version_in_docker: |-
New version found: {{.Latest}}
Current version: {{.Current}}
Published at: {{.PublishedAt}}
Since you are using Docker, please update it on your deployment platform
info_new_version_prompt_upgrade: |-
New version found: {{.Latest}}
Current version: {{.Current}}
File size: {{.SizeMB}} MB
Download URL: {{.URL}}
Published at: {{.PublishedAt}}
Upgrading will restart the bot. Proceed?
info_upgrading_with_version: "Upgrading, current version: {{.Current}}"
error_upgrade_failed: "Upgrade failed: {{.Error}}"
info_upgrade_success: "Upgraded to version {{.Version}}\nIf the bot did not restart automatically, please start it manually"
button_upgrade: "Upgrade"
config:
prompt_select_option: "Please select an option to configure"
button_filename_strategy: "Filename strategy"
error_invalid_callback_data: "Invalid callback data"
error_invalid_template: "Invalid template, please check syntax\n{{.Error}}"
info_filename_strategy_set: "Filename strategy set to: {{.Strategy}}"
prompt_select_filename_strategy: "Please select filename strategy, current strategy: {{.Strategy}}"
fnametmpl_help: |-
Use this command to set filename template, for example:
/fnametmpl Image_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg
Available variables:
- {{"{{.msgid}}"}}: Message ID
- {{"{{.msgtags}}"}}: Tags in the message, joined with underscore
- {{"{{.msggen}}"}}: Generated filename from the message
- {{"{{.msgdate}}"}}: Message date, format YYYY-MM-DD_HH-MM-SS
- {{"{{.msgraw}}"}}: Raw message text (unprocessed)
- {{"{{.origname}}"}}: Original media filename (if any)
- {{"{{.chatid}}"}}: Chat ID of the message
Template only takes effect when filename strategy is set to 'Custom template'.
If template parsing fails, it will fall back to default filename.
info_template_updated: "Filename template updated"
info_current_template_prefix: "Current template: {{.Template}}"
dl:
usage: "Usage: /dl <url1> <url2> ..."
error_no_valid_links: "No valid links to download"
info_files_select_storage: "Total {{.Count}} files, please select storage"
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: "
syncpeers:
start: "Starting to sync peers..."
done: "Peer sync completed, total {{.Count}} chats synced"
failed: "Peer sync failed: {{.Error}}"

View File

@@ -1,28 +0,0 @@
[initing]
other = "正在启动..."
[exiting]
other = "正在退出..."
[bye]
other = "已退出"
[InvalidCacheDir]
other = "无效的缓存文件夹: {{.Path}}"
[GetWorkdirFailed]
other = "获取工作目录失败: {{.Error}}"
[GetCacheAbsPathFailed]
other = "获取缓存绝对路径失败: {{.Error}}"
[CleaningCache]
other = "正在清理缓存文件夹: {{.Path}}"
[CleanCacheFailed]
other = "清理缓存失败: {{.Error}}"
[CreateRmTimerFailed]
other = "创建清理定时器失败, 路径: {{.Path}}, 错误: {{.Error}}"
[RemoveFileAfter]
other = "将在 {{.Duration}} 后删除文件: {{.Path}}"
[RemoveFileFailed]
other = "删除文件失败: {{.Path}}, 错误: {{.Error}}"
[LoadedStorages]
other = "已加载 {{.Count}} 个存储"
[ConfigInvalid.WorkersOrRetry]
other = "配置无效: workers 或 retry 必须大于 0, 但当前值为: workers={{.Workers}}, retry={{.Retry}}"
[ConfigInvalid.DuplicateStorageName]
other = "存储名称重复: {{.Name}}"

View File

@@ -0,0 +1,340 @@
lifetime:
initing: 正在启动
initfailed: 初始化失败
exiting: 正在退出
user_login_failed: "用户登录失败: {{.Error}}"
cleaning_cache: "正在清理缓存 {{.Path}}"
bye: 已退出
config:
err:
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
err:
get_workdir_failed: "获取工作目录失败: {{.Error}}"
get_cache_abs_path_failed: "获取缓存绝对路径失败: {{.Error}}"
clean_cache_failed: "清理缓存失败: {{.Error}}"
parser:
plugin:
load_failed: 加载解析器插件失败
loaded_dir: 解析器插件已加载
bot:
msg:
help_text_fmt: |
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
/dl <链接1> <链接2> ... - 下载给定链接的文件
/dir - 管理存储目录
/rule - 管理规则
/config - 修改配置
/fnametmpl - 设置文件自定义命名模板
/parser - 管理解析器插件
/task - 管理任务队列
/watch - 监听聊天并自动保存 (UserBot)
/unwatch - 取消监听聊天 (UserBot)
/lswatch - 列出正在监听的聊天 (UserBot)
/syncpeers - 同步对话列表 (UserBot)
/update - 检查更新并升级
使用帮助: https://sabot.unv.app/usage
cmd:
start: "开始使用"
silent: "切换静默模式"
storage: "设置默认存储端"
dir: "管理存储文件夹"
rule: "管理自动存储规则"
save: "保存文件"
dl: "下载给定链接的文件"
aria2dl: "使用 Aria2 下载给定链接的文件"
task: "管理任务队列"
cancel: "取消任务"
watch: "监听聊天(UserBot)"
unwatch: "取消监听聊天(UserBot)"
lswatch: "列出监听的聊天(UserBot)"
syncpeers: "同步对话列表(UserBot)"
config: "修改配置"
fnametmpl: "设置文件命名模板"
help: "显示帮助"
parser: "管理解析器"
update: "检查更新"
save_help_text: |
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @acherkrau 114-514
watch_help_text: |
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
命令语法:
/watch <chat_id> [filter]
参数:
- <chat_id>: 聊天的 ID 或用户名
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
命令示例:
/watch -1002229835658 msgre:.*plana.*
这将监听 ID 为 -1002229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
common:
cancel_button_text: "取消任务"
error_invalid_regex: "无效的正则表达式: {{.Error}}"
error_invalid_msg_id_range: "无效的消息ID范围: {{.Error}}"
error_invalid_id_or_username: "无效的ID或用户名: {{.Error}}"
error_get_messages_failed: "获取消息失败: {{.Error}}"
info_fetching_messages: "正在获取消息..."
error_no_messages_in_range: "没有找到指定范围内的消息"
error_no_savable_messages_in_range: "没有找到指定范围内的可保存消息"
error_build_storage_select_message_failed: "构建存储选择消息失败: {{.Error}}"
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
info_found_files_select_storage: "找到 {{.Count}} 个文件, 请选择存储位置"
error_get_user_failed: "获取用户失败"
error_get_user_with_err_failed: "获取用户失败: {{.Error}}"
error_default_storage_not_set: "请先设置默认存储, 使用 /storage 命令"
error_no_available_storage: "无可用的存储"
error_get_storage_failed: "获取存储失败: {{.Error}}"
prompt_select_default_storage: "请选择要设为默认的存储位置"
error_data_expired: "数据已过期或无效"
error_task_add_failed: "任务添加失败: {{.Error}}"
info_task_added: "任务已添加"
info_batch_tasks_added: "已添加批量任务, 共 {{.Count}} 个文件"
error_task_create_failed: "任务创建失败: {{.Error}}"
error_get_dir_failed: "获取目录失败: {{.Error}}"
prompt_select_dir: "请选择要存储到的目录"
prompt_select_default_dir: "请选择要保存到的默认文件夹"
info_default_storage_set: "已将默认存储位置设为: {{.Name}}"
info_default_storage_with_dir_set: "已将默认存储位置设为: {{.Name}}:/{{.Dir}}"
error_get_user_info_failed: "获取用户信息失败: {{.Error}}"
error_update_user_info_failed: "更新用户信息失败: {{.Error}}"
info_silent_mode_on: "已开启静默模式"
info_silent_mode_off: "已关闭静默模式"
error_get_file_failed: "获取文件失败: {{.Error}}"
info_fetching_file_info: "正在获取文件信息..."
error_no_savable_files_found: "没有找到可保存的文件"
error_parse_telegraph_path_failed: "解析 telegraph 路径失败: {{.Error}}"
info_fetching_telegraph_page: "正在获取 telegraph 页面..."
error_get_telegraph_page_failed: "获取 telegraph 页面失败: {{.Error}}"
error_no_images_in_telegraph_page: "在 telegraph 页面中未找到图片"
error_build_dir_select_keyboard_failed: "构建目录选择键盘失败: {{.Error}}"
error_no_permission: |
您不在白名单中, 无法使用此 Bot.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
save:
error_invalid_id_or_username: "无效的ID或用户名: {{.Error}}"
watch:
error_filter_format_invalid: "过滤器格式错误, 请使用 <过滤器类型>:<表达式>"
error_filter_type_unsupported: "不支持的过滤器类型, 请参阅文档"
error_watch_chat_failed: "监听聊天失败: {{.Error}}"
info_watch_chat_started: "已开始监听聊天: {{.Chat}}"
info_already_watching_chat: "已经在监听此聊天"
info_watch_list_empty: "当前没有监听任何聊天"
info_watch_list_header: "当前监听的聊天:\n"
info_watch_list_filter_prefix: " (过滤器: "
error_unwatch_no_chat_provided: "请提供要取消监听的聊天ID或用户名"
error_unwatch_chat_failed: "取消监听聊天失败: {{.Error}}"
info_watch_chat_stopped: "已取消监听聊天: {{.Chat}}"
tasks:
usage_cancel: "用法: /tasks cancel <task_id>"
usage: "用法: /tasks [running|queued|cancel <task_id>]"
cancel_failed: "取消任务失败: {{.Error}}"
cancel_requested_prefix: "已请求取消任务: "
running_empty: "当前没有正在运行的任务"
running_title: "当前正在运行的任务:"
total_prefix: "总数: {{.Count}}\n"
field_id: "ID: "
field_title: "名称: "
field_created: "创建时间: "
field_status: "状态: "
status_running: "运行中"
status_queued: "排队中"
status_cancel_requested: "已请求取消"
queued_empty: "当前没有排队中的任务"
queued_title: "当前排队中的任务:"
truncated_note: "...\n只显示前 10 个任务, 共 {{.Count}} 个任务"
info_added_to_queue_full: "已添加到任务队列\n文件名: {{.Filename}}\n当前排队任务数: {{.QueueLength}}"
info_added_to_queue_prefix: "已添加到任务队列\n"
info_filename_prefix: "文件名: "
info_queue_length_prefix: "\n当前排队任务数: "
rule:
error_get_user_rules_failed: "获取用户规则失败"
error_update_user_failed: "更新用户失败"
info_rule_mode_enabled: "已启用规则模式"
info_rule_mode_disabled: "已禁用规则模式"
error_invalid_rule_type: "无效的规则类型: {{.Type}}\n可用: {{.Available}}"
error_create_rule_failed: "创建规则失败"
info_create_rule_success: "创建规则成功"
prompt_provide_rule_id: "请提供规则ID"
error_invalid_rule_id: "无效的规则ID"
error_delete_rule_failed: "删除规则失败"
info_delete_rule_success: "删除规则成功"
help_usage: "使用方法: /rule <操作> <参数...>"
help_current_mode_enabled: "\n当前已启用规则模式"
help_current_mode_disabled: "\n当前已禁用规则模式"
help_available_ops: "\n\n可用操作:\n"
help_switch_suffix: " - 开关规则模式\n"
help_add_suffix: " <类型> <数据> <存储名> <路径> - 添加规则\n"
help_del_suffix: " <规则ID> - 删除规则\n"
help_existing_rules_prefix: "\n当前已添加的规则:\n"
dir:
error_get_user_dirs_failed: "获取用户文件夹失败"
error_get_user_failed: "获取用户失败"
error_create_dir_failed: "创建文件夹失败"
info_create_dir_success: "文件夹添加成功"
error_invalid_dir_id: "文件夹ID无效"
error_delete_dir_failed: "删除文件夹失败"
info_delete_dir_success: "文件夹删除成功"
error_unknown_operation: "未知操作"
help_usage: "使用方法: /dir <操作> <参数...>"
help_available_ops: "\n\n可用操作:\n"
help_add_suffix: " <存储名> <路径> - 添加路径\n"
help_del_suffix: " <路径ID> - 删除路径\n"
help_add_example_prefix: "\n添加路径示例:\n"
help_add_example_cmd: "/dir add local1 path/to/dir"
help_del_example_prefix: "\n\n删除路径示例:\n"
help_del_example_cmd: "/dir del 3"
help_existing_dirs_prefix: "\n\n当前已添加的路径:\n"
button_default: "默认"
parser:
help_text: |
用法:
/parser install <回复一个文件> - 安装解析器
plugin_not_enabled: "解析器插件功能未启用"
prompt_reply_with_parser_file: "请回复一个包含解析器文件的消息"
error_no_valid_file_in_reply: "回复的消息不包含有效的文件"
error_wrong_file_type: "错误的文件类型"
error_file_too_large: "文件过大"
error_get_filename_failed: "无法获取文件名"
error_only_js_supported: "仅支持 .js 文件作为解析器"
error_download_file_failed: "文件下载失败: {{.Error}}"
error_install_plugin_failed: "插件安装失败: {{.Error}}"
info_install_plugin_success: "插件安装成功: {{.Name}}"
parse:
info_parsing: "正在解析..."
error_parse_text_failed: "Failed to parse text: {{.Error}}"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
error_build_parsed_text_entity_failed: "Failed to build parsed text entity: {{.Error}}"
info_link_prefix: "\n链接: "
info_author_prefix: "\n作者: "
info_description_prefix: "\n描述: "
info_file_count_prefix: "\n文件数量: "
info_total_size_prefix: "\n预计总大小: "
info_prompt_select_storage: "\n请选择存储位置"
telegraph:
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
info_title_prefix: "标题: "
info_pic_count_prefix: "\n图片数量: "
info_prompt_select_storage: "\n请选择存储位置"
update:
error_version_var_invalid: "当前处于开发版本或版本信息注入失败: {{.Error}}"
error_check_latest_failed: "检测最新版本失败: {{.Error}}"
error_no_release_found: "没有找到版本信息"
info_major_upgrade_required: "检测到大版本更新: {{.Current}} -> {{.Latest}} , 请前往 GitHub 手动下载最新版本并查看迁移指南"
info_already_latest: "当前已经是最新版本: {{.Version}}"
info_new_version_in_docker: |-
发现新版本: {{.Latest}}
当前版本: {{.Current}}
发布时间: {{.PublishedAt}}
由于您正在使用 Docker 部署, 请自行在部署平台上执行更新命令
info_new_version_prompt_upgrade: |-
发现新版本: {{.Latest}}
当前版本: {{.Current}}
文件大小: {{.SizeMB}} MB
下载链接: {{.URL}}
发布时间: {{.PublishedAt}}
升级将重启 Bot , 是否升级?
info_upgrading_with_version: "正在升级中, 当前版本: {{.Current}}"
error_upgrade_failed: "升级失败: {{.Error}}"
info_upgrade_success: "已升级至版本 {{.Version}}\n若 Bot 未自动重启请手动启动"
button_upgrade: "升级"
config:
prompt_select_option: "请选择要配置的选项"
button_filename_strategy: "文件名策略"
error_invalid_callback_data: "无效的回调数据"
error_invalid_template: "无效的模板, 请检查语法\n{{.Error}}"
info_filename_strategy_set: "已将文件名策略设置为: {{.Strategy}}"
prompt_select_filename_strategy: "请选择文件名策略, 当前策略: {{.Strategy}}"
fnametmpl_help: |-
使用该命令设置文件名模板, 示例:
/fnametmpl 图片_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg
可用变量:
- {{"{{.msgid}}"}}: 消息ID
- {{"{{.msgtags}}"}}: 消息中的标签, 将以下划线分隔输出
- {{"{{.msggen}}"}}: 根据消息生成的文件名
- {{"{{.msgdate}}"}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
- {{"{{.msgraw}}"}}: 消息的原始文本内容 (不经任何处理)
- {{"{{.origname}}"}}: 媒体的原始文件名 (如果有)
- {{"{{.chatid}}"}}: 消息的聊天ID
模板仅在文件名策略设置为 '自定义模板' 时生效,
且模板解析错误时会回退到默认文件名
info_template_updated: "已更新文件名模板"
info_current_template_prefix: "当前模板: {{.Template}}"
dl:
usage: "用法: /dl <链接1> <链接2> ..."
error_no_valid_links: "没有有效的链接可供下载"
info_files_select_storage: "共 {{.Count}} 个文件, 请选择存储位置"
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错误: "
syncpeers:
start: "正在同步对话列表..."
success: "对话列表同步完成, 共同步 {{.Count}} 个对话"
failed: "对话列表同步失败: {{.Error}}"
aria2:
error_aria2_not_enabled: "Aria2 功能未启用, 请在配置文件中启用"
error_aria2_client_init_failed: "Aria2 客户端初始化失败: {{.Error}}"
info_adding_aria2_download: "正在添加 Aria2 下载任务..."
error_adding_aria2_download: "添加 Aria2 下载任务失败: {{.Error}}"
info_aria2_download_added: "Aria2 下载任务已添加, GID: {{.GID}}"

View File

@@ -1,13 +1,14 @@
package tfile
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 TGFile) *downloader.Builder {
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))
}

View File

@@ -3,6 +3,8 @@ package fsutil
import (
"os"
"path/filepath"
"strings"
"unicode"
"github.com/gabriel-vasile/mimetype"
)
@@ -55,3 +57,21 @@ func CreateFile(fp string) (*File, error) {
}
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
}

View 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)
}
}
}

View 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()
}

View File

@@ -1,6 +1,8 @@
package ioutil
import "io"
import (
"io"
)
type ProgressWriterAt struct {
wrAt io.WriterAt
@@ -46,4 +48,4 @@ func NewProgressWriter(
wr: wr,
onWrite: onWrite,
}
}
}

View File

@@ -7,57 +7,24 @@ import (
"net/http"
"net/url"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/net/proxy"
)
func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
url, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
return proxy.FromURL(url, proxy.Direct)
}
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
if proxyUrl == "" {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
}, nil
return http.DefaultClient, nil
}
u, err := url.Parse(proxyUrl)
transport, err := NewProxyTransport(proxyUrl)
if err != nil {
return nil, err
}
switch u.Scheme {
case "http", "https":
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(u),
},
}, nil
case "socks5":
dialer, err := proxy.SOCKS5("tcp", u.Host, nil, proxy.Direct)
if err != nil {
return nil, err
}
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
},
},
}, nil
default:
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
}
return &http.Client{
Transport: transport,
}, nil
}
var (
@@ -77,3 +44,35 @@ func DefaultParserHTTPClient() *http.Client {
})
return defaultProxyHttpClient
}
func NewProxyTransport(proxyStr string) (*http.Transport, error) {
proxyURL, err := url.Parse(proxyStr)
if err != nil {
return nil, err
}
transport := &http.Transport{
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
switch proxyURL.Scheme {
case "http", "https":
transport.Proxy = http.ProxyURL(proxyURL)
case "socks5", "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
if err != nil {
return nil, err
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
}
default:
return nil, fmt.Errorf("unsupported proxy type: %s", proxyURL.Scheme)
}
return transport, nil
}

View File

@@ -48,3 +48,46 @@ func ParseIntStrRange(input string, sep string) (int64, int64, error) {
}
return min, max, nil
}
func ParseArgsRespectQuotes(input string) []string {
var args []string
var current strings.Builder
inQuotes := false
escaped := false
for _, r := range input {
switch {
case escaped:
if r == '"' || r == '\\' {
current.WriteRune(r)
} else {
current.WriteRune('\\')
current.WriteRune(r)
}
escaped = false
case r == '\\':
escaped = true
case r == '"':
inQuotes = !inQuotes
case r == ' ' || r == '\t':
if inQuotes {
current.WriteRune(r)
} else if current.Len() > 0 {
args = append(args, current.String())
current.Reset()
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
args = append(args, current.String())
}
return args
}

View File

@@ -0,0 +1,148 @@
package strutil_test
import (
"reflect"
"testing"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
)
func TestExtractTagsFromText(t *testing.T) {
tests := []struct {
text string
expected []string
}{
{
text: `初音ミクHappy 16th Birthday -Dear Creators-
✨エンドイラスト公開!✨
https://piapro.net/miku16thbd/
#初音ミク #miku16th`,
expected: []string{"初音ミク", "miku16th"},
},
{
text: `ひっつきむし
#創作百合`,
expected: []string{"創作百合"},
},
{
text: `#創作百合 #原创`,
expected: []string{"創作百合", "原创"},
},
{
text: `プラニャ #ブルアカ`,
expected: []string{"ブルアカ"},
},
{
text: `原神是一款#开放世界#冒险游戏,由中国著名游戏公司#miHoYo开发。`,
expected: []string{},
},
}
for _, test := range tests {
result := strutil.ExtractTagsFromText(test.text)
if !reflect.DeepEqual(result, test.expected) {
t.Fatalf("ExtractTagsFromText(%s) = %v, expected %v", test.text, result, test.expected)
}
}
}
func TestParseIntStrRange(t *testing.T) {
tests := []struct {
name string
input string
sep string
wantMin int64
wantMax int64
wantErr bool
}{
{
name: "normal range",
input: "10-20",
sep: "-",
wantMin: 10,
wantMax: 20,
},
{
name: "reverse order",
input: "30 - 10",
sep: "-",
wantMin: 10,
wantMax: 30,
},
{
name: "invalid format",
input: "10",
sep: "-",
wantErr: true,
},
{
name: "invalid number",
input: "a-b",
sep: "-",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
min, max, err := strutil.ParseIntStrRange(tt.input, tt.sep)
if (err != nil) != tt.wantErr {
t.Errorf("ParseIntStrRange(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
return
}
if !tt.wantErr {
if min != tt.wantMin || max != tt.wantMax {
t.Errorf("ParseIntStrRange(%q) = (%d, %d), want (%d, %d)", tt.input, min, max, tt.wantMin, tt.wantMax)
}
}
})
}
}
func TestParseArgsRespectQuotes(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{
name: "simple split",
input: `/rule add FILENAME-REGEX (?i)\.(mp4|mkv)$ "我的 Alist" /视频`,
want: []string{"/rule", "add", "FILENAME-REGEX", "(?i)\\.(mp4|mkv)$", "我的 Alist", "/视频"},
},
{
name: "escaped quotes",
input: `/rule add "My \"Awesome\" Folder"`,
want: []string{"/rule", "add", `My "Awesome" Folder`},
},
{
name: "escaped backslash",
input: `/cmd "C:\\Users\\Admin" test`,
want: []string{"/cmd", `C:\Users\Admin`, "test"},
},
{
name: "multiple quoted parts",
input: `"Hello World" "你好 世界"`,
want: []string{"Hello World", "你好 世界"},
},
{
name: "unquoted words",
input: "a b c",
want: []string{"a", "b", "c"},
},
{
name: "mixed quotes and plain",
input: `cmd "quoted arg" plain`,
want: []string{"cmd", "quoted arg", "plain"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := strutil.ParseArgsRespectQuotes(tt.input)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseArgsRespectQuotes(%q) = %#v, want %#v", tt.input, got, tt.want)
}
})
}
}

View File

@@ -4,17 +4,21 @@ import (
"fmt"
"strconv"
"strings"
"unicode"
"unicode/utf16"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/mathutil"
"github.com/duke-git/lancet/v2/slice"
lcstrutil "github.com/duke-git/lancet/v2/strutil"
"github.com/duke-git/lancet/v2/validator"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/constant"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/rs/xid"
)
@@ -61,16 +65,12 @@ func GenFileNameFromMessage(message tg.Message) string {
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
}
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7F {
return '_'
}
switch r {
// invalid characters
case '/', '\\',
':', '*', '?', '"', '<', '>', '|':
return '_'
// empty
case ' ', '\t', '\r', '\n':
}
if unicode.IsControl(r) || unicode.IsSpace(r) {
return '_'
}
if validator.IsPrintable(string(r)) {
@@ -98,7 +98,7 @@ func GenFileNameFromMessage(message tg.Message) string {
func BuildCancelButton(taskID string) tg.KeyboardButtonClass {
return &tg.KeyboardButtonCallback{
Text: "取消任务",
Text: i18n.T(i18nk.BotMsgCommonCancelButtonText, nil),
Data: fmt.Appendf(nil, "cancel %s", taskID),
}
}
@@ -114,6 +114,31 @@ func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
}
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
if msg, err := getMessagesRange(ctx, chatID, minId, maxId); err == nil {
return msg, nil
}
in := constant.TDLibPeerID(chatID)
plain := in.ToPlain()
var channel constant.TDLibPeerID
channel.Channel(plain)
if msg, err := getMessagesRange(ctx, int64(channel), minId, maxId); err == nil {
return msg, nil
}
var userID constant.TDLibPeerID
userID.User(plain)
if msg, err := getMessagesRange(ctx, int64(userID), minId, maxId); err == nil {
return msg, nil
}
var chat constant.TDLibPeerID
chat.Chat(plain)
if msg, err := getMessagesRange(ctx, int64(chat), minId, maxId); err == nil {
return msg, nil
}
return nil, fmt.Errorf("failed to get messages range for chatID %d", chatID)
}
func getMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
if minId > maxId {
return nil, fmt.Errorf("minId (%d) cannot be greater than maxId (%d)", minId, maxId)
}
@@ -169,97 +194,98 @@ func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.M
return result, nil
}
type MessageItem struct {
Message *tg.Message
Error error
}
// [TODO]
// type MessageItem struct {
// Message *tg.Message
// Error error
// }
func IterMessages(ctx *ext.Context, chatID int64, minId, maxId int) (<-chan MessageItem, error) {
total := maxId - minId + 1
ch := make(chan MessageItem, 100)
// func IterMessages(ctx *ext.Context, chatID int64, minId, maxId int) (<-chan MessageItem, error) {
// total := maxId - minId + 1
// ch := make(chan MessageItem, 100)
go func() {
defer close(ch)
if !ctx.Self.Bot {
perr := ctx.PeerStorage.GetInputPeerById(chatID)
if perr == nil || perr.(*tg.InputPeerEmpty) != nil {
ch <- MessageItem{
Error: fmt.Errorf("peer not found: %d", chatID),
}
return
}
// go func() {
// defer close(ch)
// if !ctx.Self.Bot {
// perr := ctx.PeerStorage.GetInputPeerById(chatID)
// if perr == nil || perr.(*tg.InputPeerEmpty) != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("peer not found: %d", chatID),
// }
// return
// }
for i := 0; i < total; i += 100 {
start := minId + i
end := min(start+100, maxId)
msgs, err := ctx.Raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
Peer: perr,
OffsetID: start,
AddOffset: start - end,
Limit: 100,
})
if err != nil {
ch <- MessageItem{
Error: fmt.Errorf("failed to get messages: %w", err),
}
return
}
var msgClass []tg.MessageClass
switch msgsv := msgs.(type) {
case *tg.MessagesMessages:
msgClass = msgsv.GetMessages()
case *tg.MessagesMessagesSlice:
msgClass = msgsv.GetMessages()
case *tg.MessagesChannelMessages:
msgClass = msgsv.GetMessages()
default:
ch <- MessageItem{
Error: fmt.Errorf("unsupported message type: %T", msgsv),
}
continue
}
for _, msg := range msgClass {
msg, ok := msg.AsNotEmpty()
if !ok {
continue
}
switch msg := msg.(type) {
case *tg.Message:
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID())
cache.Set(key, msg)
ch <- MessageItem{
Message: msg,
}
}
}
}
} else {
for i := 0; i < total; i += 100 {
start := minId + i
end := min(start+100, maxId)
msgs, err := GetMessagesRange(ctx, chatID, start, end)
if err != nil {
ch <- MessageItem{
Error: fmt.Errorf("failed to get messages: %w", err),
}
return
}
for _, msg := range msgs {
if msg == nil {
continue
}
ch <- MessageItem{
Message: msg,
}
}
}
}
}()
// for i := 0; i < total; i += 100 {
// start := minId + i
// end := min(start+100, maxId)
// msgs, err := ctx.Raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
// Peer: perr,
// OffsetID: start,
// AddOffset: start - end,
// Limit: 100,
// })
// if err != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("failed to get messages: %w", err),
// }
// return
// }
// var msgClass []tg.MessageClass
// switch msgsv := msgs.(type) {
// case *tg.MessagesMessages:
// msgClass = msgsv.GetMessages()
// case *tg.MessagesMessagesSlice:
// msgClass = msgsv.GetMessages()
// case *tg.MessagesChannelMessages:
// msgClass = msgsv.GetMessages()
// default:
// ch <- MessageItem{
// Error: fmt.Errorf("unsupported message type: %T", msgsv),
// }
// continue
// }
// for _, msg := range msgClass {
// msg, ok := msg.AsNotEmpty()
// if !ok {
// continue
// }
// switch msg := msg.(type) {
// case *tg.Message:
// key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID())
// cache.Set(key, msg)
// ch <- MessageItem{
// Message: msg,
// }
// }
// }
// }
// } else {
// for i := 0; i < total; i += 100 {
// start := minId + i
// end := min(start+100, maxId)
// msgs, err := GetMessagesRange(ctx, chatID, start, end)
// if err != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("failed to get messages: %w", err),
// }
// return
// }
// for _, msg := range msgs {
// if msg == nil {
// continue
// }
// ch <- MessageItem{
// Message: msg,
// }
// }
// }
// }
// }()
return ch, nil
}
// return ch, nil
// }
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
func getMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
if msg, ok := cache.Get[*tg.Message](key); ok {
return msg, nil
@@ -282,6 +308,33 @@ func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, err
return tgm, nil
}
// f**k gotgproto's breaking changes
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
// we don't know what the input chatID is bot api style(e.g. channel with -100 prefix) or plain tdlib style(no any prefix and every id is positive)
if msg, err := getMessageByID(ctx, chatID, msgID); err == nil {
return msg, nil
}
in := constant.TDLibPeerID(chatID)
plain := in.ToPlain()
var channel constant.TDLibPeerID
channel.Channel(plain)
if msg, err := getMessageByID(ctx, int64(channel), msgID); err == nil {
return msg, nil
}
var chat constant.TDLibPeerID
chat.Chat(plain)
if msg, err := getMessageByID(ctx, int64(chat), msgID); err == nil {
return msg, nil
}
var userID constant.TDLibPeerID
userID.User(plain)
if msg, err := getMessageByID(ctx, int64(userID), msgID); err == nil {
return msg, nil
}
return nil, fmt.Errorf("failed to get message by ID: chatID=%d, msgID=%d", chatID, msgID)
}
func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.Message, error) {
groupID, isGroup := msg.GetGroupedID()
if !isGroup || groupID == 0 {
@@ -295,7 +348,7 @@ func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.
}
msgs, err := GetMessagesRange(ctx, chatID, minID, maxID)
if err != nil {
return nil, fmt.Errorf("failed to get grouped messages: %w", err)
return nil, err
}
groupedMessages := make([]*tg.Message, 0, len(msgs))
for _, m := range msgs {
@@ -309,3 +362,49 @@ func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.
}
return groupedMessages, nil
}
func ExtractMessageEntityUrls(msg *tg.Message) []string {
if len(msg.Entities) == 0 {
return nil
}
msgText := msg.GetMessage()
if msgText == "" {
return nil
}
runes := []rune(msgText)
utf16Codes := utf16.Encode(runes)
var urls []string
for _, entity := range msg.Entities {
switch ent := entity.(type) {
case *tg.MessageEntityTextURL:
urls = append(urls, ent.GetURL())
case *tg.MessageEntityURL:
start := ent.Offset
end := ent.Offset + ent.Length
if start < 0 || end > len(utf16Codes) {
continue
}
subRunes := utf16.Decode(utf16Codes[start:end])
urls = append(urls, string(subRunes))
}
}
return urls
}
func ExtractMessageEntityUrlsText(msg *tg.Message) string {
if msg == nil {
return ""
}
urls := ExtractMessageEntityUrls(msg)
if len(urls) == 0 {
return msg.GetMessage()
}
var sb strings.Builder
for _, url := range urls {
sb.WriteString(url)
sb.WriteString(" ")
}
return sb.String()
}

View File

@@ -0,0 +1,41 @@
package tgutil
import (
"net/url"
"github.com/gotd/td/telegram/dcs"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/net/proxy"
)
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
url, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
return proxy.FromURL(url, proxy.Direct)
}
func NewConfigProxyResolver() (dcs.Resolver, error) {
resolver := dcs.DefaultResolver()
if config.C().Proxy != "" {
// gloabl proxy, which has lower priority
dialer, err := newProxyDialer(config.C().Proxy)
if err != nil {
return nil, err
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
}
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := newProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil {
return nil, err
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
}
return resolver, nil
}

View File

@@ -0,0 +1,16 @@
package tgutil
import "github.com/gotd/td/tg"
func ChatIdFromPeer(peer tg.PeerClass) int64 {
switch peer := peer.(type) {
case *tg.PeerChannel:
return peer.ChannelID
case *tg.PeerUser:
return peer.UserID
case *tg.PeerChat:
return peer.ChatID
default:
return 0
}
}

View File

@@ -86,7 +86,7 @@ func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
return chatID, msgID, nil
case 3:
// https://t.me/c/123456789/123
// https://t.me/acherkrau/123/456 , 456: message thread ID
// https://t.me/acherkrau/123/456 , 123: topic id
chatPart, msgPart := paths[1], paths[2]
if paths[0] != "c" {
chatPart = paths[0]

View File

@@ -2,28 +2,38 @@ package tphutil
import (
"encoding/json"
"strings"
"sync"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
)
var tphClient *telegraph.Client
var (
tphClient *telegraph.Client
once sync.Once
)
func DefaultClient() *telegraph.Client {
if tphClient != nil {
return tphClient
}
once.Do(func() {
tphClient = initDefault()
})
return tphClient
}
func initDefault() *telegraph.Client {
var client *telegraph.Client
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
proxyUrl := config.C().Telegram.Proxy.URL
var err error
tphClient, err = telegraph.NewClientWithProxy(proxyUrl)
client, err = telegraph.NewClientWithProxy(proxyUrl)
if err != nil {
tphClient = telegraph.NewClient()
client = telegraph.NewClient()
}
} else {
tphClient = telegraph.NewClient()
client = telegraph.NewClient()
}
return tphClient
return client
}
func GetNodeImages(node telegraph.Node) []string {
@@ -41,6 +51,10 @@ func GetNodeImages(node telegraph.Node) []string {
if nodeElement.Tag == "img" {
if src, exists := nodeElement.Attrs["src"]; exists {
if strings.HasPrefix(src, "/file/") {
// handle images on telegra.ph server
src = "https://telegra.ph" + src
}
srcs = append(srcs, src)
}
}

View File

@@ -22,7 +22,7 @@ url = "socks5://127.0.0.1:7890"
[[storages]]
# 标识名, 需要唯一
name = "本机1"
# 存储类型, 目前可用: local, alist, webdav, minio, telegram
# 存储类型, 目前可用: local, alist, webdav, s3, telegram
type = "local"
# 启用存储
enable = true

83
config/flags.go Normal file
View File

@@ -0,0 +1,83 @@
package config
import (
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
func RegisterFlags(cmd *cobra.Command) {
flags := cmd.Flags()
// 基础配置
flags.StringP("config", "c", "", "config file path")
flags.StringP("lang", "l", "", "language (e.g., zh-Hans, en)")
flags.IntP("workers", "w", 0, "number of workers")
flags.Int("retry", 0, "retry times")
flags.Int("threads", 0, "number of threads")
flags.Bool("stream", false, "enable stream mode")
flags.Bool("no-clean-cache", false, "do not clean cache on exit")
flags.String("proxy", "", "proxy URL (http, https, socks5, socks5h)")
// Telegram 配置
flags.String("telegram-token", "", "telegram bot token")
flags.Int("telegram-app-id", 0, "telegram app id")
flags.String("telegram-app-hash", "", "telegram app hash")
flags.Int("telegram-rpc-retry", 0, "telegram rpc retry times")
flags.Bool("telegram-userbot-enable", false, "enable userbot")
flags.String("telegram-userbot-session", "", "userbot session path")
flags.Bool("telegram-proxy-enable", false, "enable telegram proxy")
flags.String("telegram-proxy-url", "", "telegram proxy URL")
// 数据库配置
flags.String("db-path", "", "database path")
flags.String("db-session", "", "session database path")
// 临时目录配置
flags.String("temp-base-path", "", "temp directory base path")
// Parser 配置
flags.Bool("parser-plugin-enable", false, "enable parser plugins")
flags.StringSlice("parser-plugin-dirs", nil, "parser plugin directories")
flags.String("parser-proxy", "", "parser proxy URL")
// 绑定到 viper
bindFlags(cmd)
}
func bindFlags(cmd *cobra.Command) {
flags := cmd.Flags()
viper.BindPFlag("lang", flags.Lookup("lang"))
viper.BindPFlag("workers", flags.Lookup("workers"))
viper.BindPFlag("retry", flags.Lookup("retry"))
viper.BindPFlag("threads", flags.Lookup("threads"))
viper.BindPFlag("stream", flags.Lookup("stream"))
viper.BindPFlag("no_clean_cache", flags.Lookup("no-clean-cache"))
viper.BindPFlag("proxy", flags.Lookup("proxy"))
// Telegram
viper.BindPFlag("telegram.token", flags.Lookup("telegram-token"))
viper.BindPFlag("telegram.app_id", flags.Lookup("telegram-app-id"))
viper.BindPFlag("telegram.app_hash", flags.Lookup("telegram-app-hash"))
viper.BindPFlag("telegram.rpc_retry", flags.Lookup("telegram-rpc-retry"))
viper.BindPFlag("telegram.userbot.enable", flags.Lookup("telegram-userbot-enable"))
viper.BindPFlag("telegram.userbot.session", flags.Lookup("telegram-userbot-session"))
viper.BindPFlag("telegram.proxy.enable", flags.Lookup("telegram-proxy-enable"))
viper.BindPFlag("telegram.proxy.url", flags.Lookup("telegram-proxy-url"))
// database
viper.BindPFlag("db.path", flags.Lookup("db-path"))
viper.BindPFlag("db.session", flags.Lookup("db-session"))
// 临时目录
viper.BindPFlag("temp.base_path", flags.Lookup("temp-base-path"))
// Parser
viper.BindPFlag("parser.plugin_enable", flags.Lookup("parser-plugin-enable"))
viper.BindPFlag("parser.plugin_dirs", flags.Lookup("parser-plugin-dirs"))
viper.BindPFlag("parser.proxy", flags.Lookup("parser-proxy"))
}
func GetConfigFile(cmd *cobra.Command) string {
configFile, _ := cmd.Flags().GetString("config")
return configFile
}

View File

@@ -14,6 +14,7 @@ var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageCo
storenum.Alist: createStorageConfig(&AlistStorageConfig{}),
storenum.Webdav: createStorageConfig(&WebdavStorageConfig{}),
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
storenum.S3: createStorageConfig(&S3StorageConfig{}),
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
}

43
config/storage/s3.go Normal file
View File

@@ -0,0 +1,43 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type S3StorageConfig struct {
BaseConfig
Endpoint string `toml:"endpoint" mapstructure:"endpoint" json:"endpoint"`
AccessKeyID string `toml:"access_key_id" mapstructure:"access_key_id" json:"access_key_id"`
SecretAccessKey string `toml:"secret_access_key" mapstructure:"secret_access_key" json:"secret_access_key"`
BucketName string `toml:"bucket_name" mapstructure:"bucket_name" json:"bucket_name"`
UseSSL bool `toml:"use_ssl" mapstructure:"use_ssl" json:"use_ssl"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
Region string `toml:"region" mapstructure:"region" json:"region"`
VirtualHost bool `toml:"virtual_host" mapstructure:"virtual_host" json:"virtual_host"`
}
func (m *S3StorageConfig) Validate() error {
if m.Endpoint == "" {
return fmt.Errorf("endpoint is required for s3 storage")
}
if m.AccessKeyID == "" || m.SecretAccessKey == "" {
return fmt.Errorf("access_key_id and secret_access_key are required for s3 storage")
}
if m.BucketName == "" {
return fmt.Errorf("bucket_name is required for s3 storage")
}
if m.BasePath == "" {
return fmt.Errorf("base_path is required for s3 storage")
}
return nil
}
func (m *S3StorageConfig) GetType() storenum.StorageType {
return storenum.S3
}
func (m *S3StorageConfig) GetName() string {
return m.Name
}

View File

@@ -9,8 +9,14 @@ import (
type TelegramStorageConfig struct {
BaseConfig
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
ForceFile bool `toml:"force_file" mapstructure:"force_file" json:"force_file"`
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
SkipLarge bool `toml:"skip_large" mapstructure:"skip_large" json:"skip_large"` // skip files larger than Telegram limit(2GB)
// split files larger than Telegram limit(2GB) into parts of specified size, in MB, leave 0 to set default(2000MB)
// only effective when SkipLarge is false
// use zip when splitting
SplitSizeMB int64 `toml:"split_size_mb" mapstructure:"split_size_mb" json:"split_size_mb"`
}
func (m *TelegramStorageConfig) Validate() error {

View File

@@ -1,12 +1,13 @@
package config
type telegramConfig struct {
Token string `toml:"token" mapstructure:"token"`
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
Token string `toml:"token" mapstructure:"token"`
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
MediaGroupTimeout int `toml:"media_group_timeout" mapstructure:"media_group_timeout" json:"media_group_timeout"`
}
type userbotConfig struct {

View File

@@ -6,4 +6,9 @@ var (
Version string = "dev"
BuildTime string = "unknown"
GitCommit string = "unknown"
Docker string = "false" // whether built inside Docker
)
const (
GitRepo = "krau/SaveAny-Bot"
)

View File

@@ -2,25 +2,28 @@ package config
import (
"context"
"errors"
"fmt"
"os"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config/storage"
"github.com/spf13/viper"
"golang.org/x/net/proxy"
)
type Config struct {
Lang string `toml:"lang" mapstructure:"lang" json:"lang"`
Workers int `toml:"workers" mapstructure:"workers"`
Retry int `toml:"retry" mapstructure:"retry"`
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
Lang string `toml:"lang" mapstructure:"lang" json:"lang"`
Workers int `toml:"workers" mapstructure:"workers"`
Retry int `toml:"retry" mapstructure:"retry"`
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
Aria2 aria2Config `toml:"aria2" mapstructure:"aria2" json:"aria2"`
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
@@ -32,6 +35,12 @@ type Config struct {
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
}
type aria2Config struct {
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
Url string `toml:"url" mapstructure:"url" json:"url"`
Secret string `toml:"secret" mapstructure:"secret" json:"secret"`
}
var cfg = &Config{}
func C() Config {
@@ -47,16 +56,39 @@ func (c Config) GetStorageByName(name string) storage.StorageConfig {
return nil
}
func Init(ctx context.Context) error {
viper.SetConfigName("config")
viper.AddConfigPath(".")
viper.AddConfigPath("/etc/saveany/")
func Init(ctx context.Context, configFile ...string) error {
viper.SetConfigType("toml")
viper.SetEnvPrefix("SAVEANY")
viper.AutomaticEnv()
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
// 如果指定了配置文件路径,则使用指定的配置文件
// 配置文件支持传入一个 http(s) URL 地址
if len(configFile) > 0 && configFile[0] != "" {
cfg := configFile[0]
if strings.HasPrefix(cfg, "http://") || strings.HasPrefix(cfg, "https://") {
// 使用远程配置文件
resp, err := http.Get(cfg)
if err != nil {
return fmt.Errorf("failed to fetch remote config file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to fetch remote config file: status code %d", resp.StatusCode)
}
if err := viper.ReadConfig(resp.Body); err != nil {
return fmt.Errorf("failed to read remote config file: %w", err)
}
} else {
viper.SetConfigFile(cfg)
}
} else {
viper.SetConfigName("config")
viper.AddConfigPath(".")
viper.AddConfigPath("/etc/saveany/")
}
defaultConfigs := map[string]any{
// 基础配置
"lang": "zh-Hans",
@@ -96,12 +128,12 @@ func Init(ctx context.Context) error {
if err := viper.ReadInConfig(); err != nil {
fmt.Println("Error reading config file, ", err)
os.Exit(1)
return err
}
if err := viper.Unmarshal(cfg); err != nil {
fmt.Println("Error unmarshalling config file, ", err)
os.Exit(1)
return err
}
storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper())
@@ -113,25 +145,19 @@ func Init(ctx context.Context) error {
storageNames := make(map[string]struct{})
for _, storage := range cfg.Storages {
if _, ok := storageNames[storage.GetName()]; ok {
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigInvalidDuplicateStorageName, map[string]any{
"Name": storage.GetName(),
}))
return fmt.Errorf("duplicate storage name: %s", storage.GetName())
}
storageNames[storage.GetName()] = struct{}{}
}
fmt.Println(i18n.TWithoutInit(cfg.Lang, i18nk.LoadedStorages, map[string]any{
"Count": len(cfg.Storages),
}))
for _, storage := range cfg.Storages {
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
if cfg.Workers < 1 {
cfg.Workers = 1
}
if cfg.Workers < 1 || cfg.Retry < 1 {
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigInvalidWorkersOrRetry, map[string]any{
"Workers": cfg.Workers,
"Retry": cfg.Retry,
}))
if cfg.Threads < 1 {
cfg.Threads = 1
}
if cfg.Retry < 1 {
cfg.Retry = 1
}
for _, storage := range cfg.Storages {
@@ -145,5 +171,43 @@ func Init(ctx context.Context) error {
userStorages[user.ID] = user.Storages
}
}
if cfg.Proxy != "" {
http.DefaultTransport, err = newProxyTransport(cfg.Proxy)
if err != nil {
return fmt.Errorf("failed to create proxy transport: %w", err)
}
}
return nil
}
func newProxyTransport(proxyStr string) (*http.Transport, error) {
proxyURL, err := url.Parse(proxyStr)
if err != nil {
return nil, err
}
transport := &http.Transport{
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
switch proxyURL.Scheme {
case "http", "https":
transport.Proxy = http.ProxyURL(proxyURL)
case "socks5", "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
if err != nil {
return nil, err
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
}
default:
return nil, fmt.Errorf("unsupported proxy type: %s", proxyURL.Scheme)
}
return transport, nil
}

View File

@@ -10,15 +10,16 @@ import (
"github.com/krau/SaveAny-Bot/pkg/queue"
)
var queueInstance *queue.TaskQueue[Exectable]
var queueInstance *queue.TaskQueue[Executable]
type Exectable interface {
type Executable interface {
Type() tasktype.TaskType
Title() string
TaskID() string
Execute(ctx context.Context) error
}
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
func worker(ctx context.Context, qe *queue.TaskQueue[Executable], semaphore chan struct{}) {
logger := log.FromContext(ctx)
execHooks := config.C().Hook.Exec
for {
@@ -28,27 +29,27 @@ func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan
logger.Error("Failed to get task from queue:", err)
break // queue closed and empty
}
task := qtask.Data
logger.Infof("Processing task: %s", task.TaskID())
exe := qtask.Data
logger.Infof("Processing task: %s", exe.TaskID())
if err := ExecCommandString(qtask.Context(), execHooks.TaskBeforeStart); err != nil {
logger.Errorf("Failed to execute before start hook for task %s: %v", task.TaskID(), err)
logger.Errorf("Failed to execute before start hook for task %s: %v", exe.TaskID(), err)
}
if err := task.Execute(qtask.Context()); err != nil {
if err := exe.Execute(qtask.Context()); err != nil {
if errors.Is(err, context.Canceled) {
logger.Infof("Task %s was canceled", task.TaskID())
logger.Infof("Task %s was canceled", exe.TaskID())
if err := ExecCommandString(ctx, execHooks.TaskCancel); err != nil {
logger.Errorf("Failed to execute cancel hook for task %s: %v", task.TaskID(), err)
logger.Errorf("Failed to execute cancel hook for task %s: %v", exe.TaskID(), err)
}
} else {
logger.Errorf("Failed to execute task %s: %v", task.TaskID(), err)
logger.Errorf("Failed to execute task %s: %v", exe.TaskID(), err)
if err := ExecCommandString(ctx, execHooks.TaskFail); err != nil {
logger.Errorf("Failed to execute fail hook for task %s: %v", task.TaskID(), err)
logger.Errorf("Failed to execute fail hook for task %s: %v", exe.TaskID(), err)
}
}
} else {
logger.Infof("Task %s completed successfully", task.TaskID())
logger.Infof("Task %s completed successfully", exe.TaskID())
if err := ExecCommandString(ctx, execHooks.TaskSuccess); err != nil {
logger.Errorf("Failed to execute success hook for task %s: %v", task.TaskID(), err)
logger.Errorf("Failed to execute success hook for task %s: %v", exe.TaskID(), err)
}
}
qe.Done(qtask.ID)
@@ -60,7 +61,7 @@ func Run(ctx context.Context) {
log.FromContext(ctx).Info("Start processing tasks...")
semaphore := make(chan struct{}, config.C().Workers)
if queueInstance == nil {
queueInstance = queue.NewTaskQueue[Exectable]()
queueInstance = queue.NewTaskQueue[Executable]()
}
for range config.C().Workers {
go worker(ctx, queueInstance, semaphore)
@@ -68,8 +69,8 @@ func Run(ctx context.Context) {
}
func AddTask(ctx context.Context, task Exectable) error {
return queueInstance.Add(queue.NewTask(ctx, task.TaskID(), task))
func AddTask(ctx context.Context, task Executable) error {
return queueInstance.Add(queue.NewTask(ctx, task.TaskID(), task.Title(), task))
}
func CancelTask(ctx context.Context, id string) error {
@@ -78,8 +79,13 @@ func CancelTask(ctx context.Context, id string) error {
}
func GetLength(ctx context.Context) int {
if queueInstance == nil {
return 0
}
return queueInstance.ActiveLength()
}
func GetRunningTasks(ctx context.Context) []queue.TaskInfo {
return queueInstance.RunningTasks()
}
func GetQueuedTasks(ctx context.Context) []queue.TaskInfo {
return queueInstance.QueuedTasks()
}

View File

@@ -9,11 +9,11 @@ import (
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/tdler"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"golang.org/x/sync/errgroup"
)
@@ -24,7 +24,7 @@ func (t *Task) Execute(ctx context.Context) error {
workers := config.C().Workers
eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(workers)
for _, elem := range t.Elems {
for _, elem := range t.elems {
eg.Go(func() error {
t.processingMu.RLock()
if t.processing[elem.ID] != nil {
@@ -68,7 +68,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
errg.Go(func() error {
defer pw.Close()
logger.Info("Starting file download in stream mode")
_, err := tfile.NewDownloader(elem.File).Stream(uploadCtx, wr)
_, err := tdler.NewDownloader(elem.File).Stream(uploadCtx, wr)
if err != nil {
logger.Errorf("Failed to download file: %v", err)
pw.CloseWithError(err)
@@ -95,7 +95,7 @@ func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
})
_, err = tfile.NewDownloader(elem.File).Parallel(ctx, wrAt)
_, err = tdler.NewDownloader(elem.File).Parallel(ctx, wrAt)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}

View File

@@ -13,6 +13,8 @@ import (
"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/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
@@ -37,7 +39,7 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("开始执行批量下载任务\n总大小: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressBatchStartPrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
@@ -78,22 +80,22 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("正在处理批量下载任务\n总大小: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressBatchProcessingPrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalSize())/(1024*1024), info.Count())),
styling.Plain("\n正在处理:\n"),
styling.Plain(i18n.T(i18nk.BotMsgProgressProcessingListPrefix, nil)),
func() styling.StyledTextOption {
var lines []string
for _, elem := range info.Processing() {
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
}
if len(lines) == 0 {
lines = append(lines, " - 无")
lines = append(lines, i18n.T(i18nk.BotMsgProgressProcessingNone, nil))
}
return styling.Plain(slice.Join(lines, "\n"))
}(),
styling.Plain("\n平均速度: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.Downloaded(), p.start)/(1024*1024))),
styling.Plain("\n当前进度: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.Downloaded())/float64(info.TotalSize())*100)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
@@ -133,19 +135,21 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
if err != nil {
if errors.Is(err, context.Canceled) {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("任务已取消"),
styling.Plain(i18n.T(i18nk.BotMsgProgressTaskCanceled, nil)),
)
} else {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("处理失败, 错误:\n "),
styling.Plain(i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
"Error": "",
})),
styling.Code(err.Error()),
)
}
} else {
stylingErr = styling.Perform(&entityBuilder,
styling.Plain("处理完成\n文件数: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressBatchDonePrefix, nil)),
styling.Code(strconv.Itoa(info.Count())),
styling.Plain("\n总大小: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressTotalSizePrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB", float64(info.TotalSize())/(1024*1024))),
)
}

View File

@@ -8,12 +8,15 @@ import (
"sync/atomic"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
var _ core.Executable = (*Task)(nil)
type TaskElement struct {
ID string
Storage storage.Storage
@@ -25,8 +28,8 @@ type TaskElement struct {
type Task struct {
ID string
Ctx context.Context
Elems []TaskElement
ctx context.Context
elems []TaskElement
Progress ProgressTracker
IgnoreErrors bool // if true, errors during processing will be ignored
downloaded atomic.Int64
@@ -36,6 +39,11 @@ type Task struct {
failed map[string]error // [TODO] errors for each element
}
// Title implements core.Exectable.
func (t *Task) Title() string {
return fmt.Sprintf("[%s](%d files/%.2fMB)", t.Type(), len(t.elems), float64(t.totalSize)/(1024*1024))
}
func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeTgfiles
}
@@ -78,8 +86,8 @@ func NewBatchTGFileTask(
) *Task {
task := &Task{
ID: id,
Ctx: ctx,
Elems: files,
ctx: ctx,
elems: files,
Progress: progress,
downloaded: atomic.Int64{},
totalSize: func() int64 {

View File

@@ -44,11 +44,11 @@ func (t *Task) Downloaded() int64 {
}
func (t *Task) Count() int {
return len(t.Elems)
return len(t.elems)
}
func (t *Task) Processing() []TaskElementInfo {
processing := make([]TaskElementInfo, 0, len(t.Elems))
processing := make([]TaskElementInfo, 0, len(t.elems))
for _, elem := range t.processing {
processing = append(processing, elem)
}

View File

@@ -0,0 +1,167 @@
package directlinks
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"path/filepath"
"sync/atomic"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"golang.org/x/sync/errgroup"
)
func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx)
logger.Infof("Starting directlinks task %s", t.ID)
if t.Progress != nil {
t.Progress.OnStart(ctx, t)
}
// head all links to get file info
eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(config.C().Workers)
fetchedTotalBytes := atomic.Int64{}
for _, file := range t.files {
eg.Go(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, file.URL, nil)
if err != nil {
return fmt.Errorf("failed to create HEAD request for %s: %w", file.URL, err)
}
resp, err := t.client.Do(req)
if err != nil {
return fmt.Errorf("failed to HEAD %s: %w", file.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HEAD %s returned status %d", file.URL, resp.StatusCode)
}
fetchedTotalBytes.Add(resp.ContentLength)
file.Size = resp.ContentLength
if name := resp.Header.Get("Content-Disposition"); name != "" {
// Set file name
filename := parseFilename(name)
file.Name = filename
}
return nil
})
}
err := eg.Wait()
if err != nil {
logger.Errorf("Error during HEAD requests: %v", err)
if t.Progress != nil {
t.Progress.OnDone(ctx, t, err)
}
return err
}
t.totalBytes = fetchedTotalBytes.Load()
// start downloading
eg, gctx = errgroup.WithContext(ctx)
eg.SetLimit(config.C().Workers)
for _, file := range t.files {
eg.Go(func() error {
t.processingMu.RLock()
if _, ok := t.processing[file.URL]; ok {
return fmt.Errorf("file %s is already being processed", file.URL)
}
t.processingMu.RUnlock()
t.processingMu.Lock()
t.processing[file.URL] = file
t.processingMu.Unlock()
defer func() {
t.processingMu.Lock()
delete(t.processing, file.URL)
t.processingMu.Unlock()
}()
err := t.processLink(gctx, file)
t.downloaded.Add(1)
if errors.Is(err, context.Canceled) {
logger.Debug("Link processing canceled")
return err
}
if err != nil {
logger.Errorf("Error processing link %s: %v", file.URL, err)
return fmt.Errorf("failed to process link %s: %w", file.URL, err)
}
return nil
})
}
err = eg.Wait()
if err != nil {
logger.Errorf("Error during directlinks task execution: %v", err)
} else {
logger.Infof("Directlinks task %s completed successfully", t.ID)
}
if t.Progress != nil {
t.Progress.OnDone(ctx, t, err)
}
return err
}
func (t *Task) processLink(ctx context.Context, file *File) error {
logger := log.FromContext(ctx)
err := retry.Retry(func() error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, file.URL, nil)
if err != nil {
return fmt.Errorf("failed to create GET request for %s: %w", file.URL, err)
}
resp, err := t.client.Do(req)
if err != nil {
return fmt.Errorf("failed to GET %s: %w", file.URL, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("GET %s returned status %d", file.URL, resp.StatusCode)
}
ctx = context.WithValue(ctx, ctxkey.ContentLength, file.Size)
if t.stream {
return t.Storage.Save(ctx, resp.Body, filepath.Join(t.StorPath, file.Name))
}
cacheFile, err := fsutil.CreateFile(filepath.Join(config.C().Temp.BasePath,
fmt.Sprintf("direct_%s_%s", t.ID, file.Name)))
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer func() {
if err := cacheFile.CloseAndRemove(); err != nil {
logger.Errorf("Failed to close and remove cache file: %v", err)
}
}()
wr := ioutil.NewProgressWriter(cacheFile, func(n int) {
t.downloadedBytes.Add(int64(n))
if t.Progress != nil {
t.Progress.OnProgress(ctx, t)
}
})
copyResultCh := make(chan error, 1)
go func() {
_, err := io.Copy(wr, resp.Body)
copyResultCh <- err
}()
select {
case err := <-copyResultCh:
if err != nil {
return fmt.Errorf("failed to copy file %s to cache file: %w", file.URL, err)
}
case <-ctx.Done():
return ctx.Err()
}
_, err = cacheFile.Seek(0, 0)
if err != nil {
return fmt.Errorf("failed to seek cache file for resource %s: %w", file.URL, err)
}
return t.Storage.Save(ctx, cacheFile, filepath.Join(t.StorPath, file.Name))
}, retry.RetryTimes(uint(config.C().Retry)), retry.Context(ctx))
if ctx.Err() != nil {
return ctx.Err()
}
return err
}

View File

@@ -0,0 +1,205 @@
package directlinks
import (
"context"
"errors"
"fmt"
"sync/atomic"
"time"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/slice"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
type TaskInfo interface {
TotalBytes() int64
TotalFiles() int
TaskID() string
StorageName() string
StoragePath() string
DownloadedBytes() int64
Processing() []FileInfo
}
type FileInfo interface {
FileName() string
FileSize() int64
}
type ProgressTracker interface {
OnStart(ctx context.Context, info TaskInfo)
OnProgress(ctx context.Context, info TaskInfo)
OnDone(ctx context.Context, info TaskInfo, err error)
}
type Progress struct {
msgID int
chatID int64
start time.Time
lastUpdatePercent atomic.Int32
}
// OnDone implements ProgressTracker.
func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
logger := log.FromContext(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
logger.Infof("Parsed task %s was canceled", info.TaskID())
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.chatID, &tg.MessagesEditMessageRequest{
ID: p.msgID,
Message: i18n.T(i18nk.BotMsgProgressTaskCanceledWithId, map[string]any{
"TaskID": info.TaskID(),
}),
})
}
} else {
logger.Errorf("Parsed task %s failed: %s", info.TaskID(), err)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.chatID, &tg.MessagesEditMessageRequest{
ID: p.msgID,
Message: i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
"Error": err.Error(),
}),
})
}
}
return
}
logger.Infof("Parsed task %s completed successfully", info.TaskID())
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressDirectDonePrefix, nil)),
styling.Code(fmt.Sprintf("%d", info.TotalFiles())),
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
); err != nil {
logger.Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.msgID,
}
req.SetMessage(text)
req.SetEntities(entities)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.chatID, req)
}
}
// OnProgress implements ProgressTracker.
func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
if !shouldUpdateProgress(info.TotalBytes(), info.DownloadedBytes(), int(p.lastUpdatePercent.Load())) {
return
}
percent := int((info.DownloadedBytes() * 100) / info.TotalBytes())
if p.lastUpdatePercent.Load() == int32(percent) {
return
}
p.lastUpdatePercent.Store(int32(percent))
log.FromContext(ctx).Debugf("Progress update: %s, %d/%d", info.TaskID(), info.DownloadedBytes(), info.TotalBytes())
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressDownloadingPrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalBytes())/(1024*1024), info.TotalFiles())),
styling.Plain(i18n.T(i18nk.BotMsgProgressProcessingListPrefix, nil)),
func() styling.StyledTextOption {
var lines []string
for _, elem := range info.Processing() {
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
}
if len(lines) == 0 {
lines = append(lines, i18n.T(i18nk.BotMsgProgressProcessingNone, nil))
}
return styling.Plain(slice.Join(lines, "\n"))
}(),
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.DownloadedBytes(), p.start)/(1024*1024))),
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.DownloadedBytes())/float64(info.TotalBytes())*100)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.msgID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
}},
)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.chatID, req)
return
}
}
// OnStart implements ProgressTracker.
func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
logger := log.FromContext(ctx)
p.start = time.Now()
p.lastUpdatePercent.Store(0)
logger.Infof("Direct links task started: message_id=%d, chat_id=%d", p.msgID, p.chatID)
ext := tgutil.ExtFromContext(ctx)
if ext == nil {
return
}
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressDirectStart, map[string]any{
"SizeMB": float64(info.TotalBytes()) / (1024 * 1024),
"Count": info.TotalFiles(),
}))); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
return
}
text, entities := entityBuilder.Complete()
req := &tg.MessagesEditMessageRequest{
ID: p.msgID,
}
req.SetMessage(text)
req.SetEntities(entities)
req.SetReplyMarkup(&tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
tgutil.BuildCancelButton(info.TaskID()),
},
},
}},
)
ext.EditMessage(p.chatID, req)
}
var _ ProgressTracker = (*Progress)(nil)
func NewProgress(msgID int, userID int64) ProgressTracker {
return &Progress{
msgID: msgID,
chatID: userID,
}
}

View File

@@ -0,0 +1,130 @@
package directlinks
import (
"context"
"fmt"
"net/http"
"sync"
"sync/atomic"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/storage"
)
type File struct {
Name string
URL string
Size int64
}
func (f *File) FileName() string {
return f.Name
}
func (f *File) FileSize() int64 {
return f.Size
}
var _ core.Executable = (*Task)(nil)
type Task struct {
ID string
ctx context.Context
files []*File
Storage storage.Storage
StorPath string
Progress ProgressTracker
client *http.Client // [TODO] parallel download
stream bool
totalBytes int64 // total bytes to download
downloadedBytes atomic.Int64 // downloaded bytes
totalFiles int64 // total files to download
downloaded atomic.Int64 // downloaded files count
processing map[string]*File // {"url": File}
processingMu sync.RWMutex
failed map[string]error // [TODO] errors for each file
}
// Title implements core.Exectable.
func (t *Task) Title() string {
return fmt.Sprintf("[%s](%s...->%s:%s)", t.Type(), t.files[0].Name, t.Storage.Name(), t.StorPath)
}
// DownloadedBytes implements TaskInfo.
func (t *Task) DownloadedBytes() int64 {
return t.downloadedBytes.Load()
}
// Processing implements TaskInfo.
func (t *Task) Processing() []FileInfo {
t.processingMu.RLock()
defer t.processingMu.RUnlock()
infos := make([]FileInfo, 0, len(t.processing))
for _, f := range t.processing {
infos = append(infos, f)
}
return infos
}
// StorageName implements TaskInfo.
func (t *Task) StorageName() string {
return t.Storage.Name()
}
// StoragePath implements TaskInfo.
func (t *Task) StoragePath() string {
return t.StorPath
}
// TotalBytes implements TaskInfo.
func (t *Task) TotalBytes() int64 {
return t.totalBytes
}
// TotalFiles implements TaskInfo.
func (t *Task) TotalFiles() int {
return int(t.totalFiles)
}
func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeDirectlinks
}
func (t *Task) TaskID() string {
return t.ID
}
func NewTask(
id string,
ctx context.Context,
links []string,
stor storage.Storage,
storPath string,
progressTracker ProgressTracker,
) *Task {
_, ok := stor.(storage.StorageCannotStream)
stream := config.C().Stream && !ok
files := make([]*File, 0, len(links))
for _, link := range links {
files = append(files, &File{
URL: link,
})
}
return &Task{
ID: id,
ctx: ctx,
files: files,
Storage: stor,
StorPath: storPath,
Progress: progressTracker,
stream: stream,
client: http.DefaultClient,
processing: make(map[string]*File),
processingMu: sync.RWMutex{},
failed: make(map[string]error),
totalFiles: int64(len(files)),
}
}

View File

@@ -0,0 +1,205 @@
package directlinks
import (
"mime"
"net/url"
"strings"
"unicode/utf8"
"golang.org/x/text/encoding/simplifiedchinese"
)
// parseFilename extracts filename from Content-Disposition header
// It handles multiple encoding scenarios:
// 1. RFC 5987/RFC 2231 format: filename*=UTF-8”%E6%B5%8B%E8%AF%95.zip (preferred, checked first)
// 2. MIME encoded-word: filename="=?UTF-8?B?5rWL6K+VLnppcA==?="
// 3. URL-encoded: filename="%E6%B5%8B%E8%AF%95.zip"
// 4. Plain ASCII filename
//
// The key fix is checking filename*= first before mime.ParseMediaType, because
// some servers send Content-Disposition headers with invalid characters that cause
// mime.ParseMediaType to fail, but the filename*= parameter is still valid.
func parseFilename(contentDisposition string) string {
// First, try to find filename*= (RFC 5987 format, most reliable for non-ASCII)
if filename := parseFilenameExtended(contentDisposition); filename != "" {
return filename
}
// Try standard MIME parsing for regular filename= parameter
_, params, err := mime.ParseMediaType(contentDisposition)
if err == nil {
if filename := params["filename"]; filename != "" {
return decodeFilenameParam(filename)
}
}
// Fallback: manual parsing if mime.ParseMediaType fails
return parseFilenameFallback(contentDisposition)
}
// parseFilenameExtended parses RFC 5987/RFC 2231 extended parameter format
// Format: filename*=charset'language'value (e.g., UTF-8”%E6%B5%8B%E8%AF%95.zip)
func parseFilenameExtended(cd string) string {
// Look for filename*= (case-insensitive)
lower := strings.ToLower(cd)
idx := strings.Index(lower, "filename*=")
if idx == -1 {
return ""
}
// Extract the value after filename*=
value := cd[idx+len("filename*="):]
// Find the end of the value (next ; or end of string)
if endIdx := strings.Index(value, ";"); endIdx != -1 {
value = value[:endIdx]
}
value = strings.TrimSpace(value)
// Parse charset'language'encoded-value format
// Common format: UTF-8''%E6%B5%8B%E8%AF%95.zip
parts := strings.SplitN(value, "''", 2)
if len(parts) == 2 {
// parts[0] is charset (e.g., "UTF-8")
// parts[1] is percent-encoded value
decoded, err := url.QueryUnescape(parts[1])
if err == nil {
return decoded
}
}
// Try with single quote delimiter as well (some servers use this)
parts = strings.SplitN(value, "'", 3)
if len(parts) >= 3 {
decoded, err := url.QueryUnescape(parts[2])
if err == nil {
return decoded
}
}
return ""
}
// TryUrlQueryUnescape tries to unescape a URL-encoded string.
//
// If unescaping fails, it returns the original string.
func tryUrlQueryUnescape(s string) string {
if decoded, err := url.QueryUnescape(s); err == nil {
return decoded
}
return s
}
// decodeFilenameParam decodes a filename parameter value
// Handles MIME encoded-word, URL encoding, and GBK encoding fallback
func decodeFilenameParam(filename string) string {
// Check if the filename is MIME encoded-word (e.g., =?UTF-8?B?...?=)
if strings.HasPrefix(filename, "=?") {
decoder := new(mime.WordDecoder)
// Some servers use "UTF8" instead of "UTF-8", create a normalized copy
normalizedFilename := strings.Replace(filename, "UTF8", "UTF-8", 1)
if decoded, err := decoder.Decode(normalizedFilename); err == nil {
return decoded
}
}
// Try URL decoding
decoded := tryUrlQueryUnescape(filename)
// Check if the result is valid UTF-8. If not, try GBK decoding.
// This handles the case where Chinese Windows servers send GBK-encoded filenames
// which appear as garbled characters (e.g., "下载地址.zip" -> "<22><><EFBFBD>ص<EFBFBD>ַ.zip")
if !utf8.ValidString(decoded) {
if gbkDecoded := tryDecodeGBK(decoded); gbkDecoded != "" {
return gbkDecoded
}
}
return decoded
}
// gbkDecoder is a reusable GBK decoder for better performance
var gbkDecoder = simplifiedchinese.GBK.NewDecoder()
// tryDecodeGBK attempts to decode a string as GBK/GB2312/GB18030 encoding
// Returns empty string if decoding fails or result is not valid UTF-8
func tryDecodeGBK(s string) string {
// GBK uses 1-2 bytes per character. Single-byte chars are 0x00-0x7F (ASCII compatible).
// Double-byte chars have first byte 0x81-0xFE and second byte 0x40-0xFE.
// Skip if string is empty or all ASCII (valid UTF-8)
if len(s) == 0 {
return ""
}
// Create a fresh decoder since the transform state may be corrupted
decoder := gbkDecoder
decoded, err := decoder.Bytes([]byte(s))
if err != nil {
return ""
}
result := string(decoded)
if utf8.ValidString(result) {
return result
}
return ""
}
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
func parseFilenameFallback(cd string) string {
// Look for filename= (case-insensitive)
lower := strings.ToLower(cd)
idx := strings.Index(lower, "filename=")
if idx == -1 {
return ""
}
// Skip "filename=" prefix
value := cd[idx+len("filename="):]
// Find the end of the value
if endIdx := strings.Index(value, ";"); endIdx != -1 {
value = value[:endIdx]
}
value = strings.TrimSpace(value)
// Remove quotes if present
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
return decodeFilenameParam(value)
}
var progressUpdatesLevels = []struct {
size int64 // 文件大小阈值
stepPercent int // 每多少 % 更新一次
}{
{10 << 20, 100},
{50 << 20, 50},
{200 << 20, 20},
{500 << 20, 10},
}
func shouldUpdateProgress(total, downloaded int64, lastUpdatePercent int) bool {
if total <= 0 || downloaded <= 0 {
return false
}
percent := int((downloaded * 100) / total)
if percent <= lastUpdatePercent {
return false
}
step := progressUpdatesLevels[len(progressUpdatesLevels)-1].stepPercent
for _, lvl := range progressUpdatesLevels {
if total < lvl.size {
step = lvl.stepPercent
break
}
}
return percent >= lastUpdatePercent+step
}

View File

@@ -12,6 +12,8 @@ import (
"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/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
@@ -68,7 +70,9 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain(fmt.Sprintf("开始下载 %s 的资源\n总大小: ", info.Site())),
styling.Plain(i18n.T(i18nk.BotMsgProgressParsedStartPrefix, map[string]any{
"Site": info.Site(),
})),
styling.Code(fmt.Sprintf("%.2f MB (%d个资源)", float64(info.TotalBytes())/(1024*1024), info.TotalResources())),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
@@ -109,22 +113,22 @@ func (p *Progress) OnProgress(ctx context.Context, info TaskInfo) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("正在下载\n总大小: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressDownloadingPrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB (%d个文件)", float64(info.TotalBytes())/(1024*1024), info.TotalResources())),
styling.Plain("\n正在处理:\n"),
styling.Plain(i18n.T(i18nk.BotMsgProgressProcessingListPrefix, nil)),
func() styling.StyledTextOption {
var lines []string
for _, elem := range info.Processing() {
lines = append(lines, fmt.Sprintf(" - %s (%.2f MB)", elem.FileName(), float64(elem.FileSize())/(1024*1024)))
}
if len(lines) == 0 {
lines = append(lines, " - 无")
lines = append(lines, i18n.T(i18nk.BotMsgProgressProcessingNone, nil))
}
return styling.Plain(slice.Join(lines, "\n"))
}(),
styling.Plain("\n平均速度: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(info.DownloadedBytes(), p.start)/(1024*1024))),
styling.Plain("\n当前进度: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f%%", float64(info.DownloadedBytes())/float64(info.TotalBytes())*100)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
@@ -160,8 +164,10 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
ID: p.MessageID,
Message: fmt.Sprintf("处理已取消: %s", info.TaskID()),
ID: p.MessageID,
Message: i18n.T(i18nk.BotMsgProgressTaskCanceledWithId, map[string]any{
"TaskID": info.TaskID(),
}),
})
}
} else {
@@ -169,8 +175,10 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.ChatID, &tg.MessagesEditMessageRequest{
ID: p.MessageID,
Message: fmt.Sprintf("处理失败: %s", err.Error()),
ID: p.MessageID,
Message: i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
"Error": err.Error(),
}),
})
}
}
@@ -180,9 +188,9 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain("处理完成, 资源数量: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressParsedDonePrefix, nil)),
styling.Code(fmt.Sprintf("%d", info.TotalResources())),
styling.Plain("\n保存路径: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
styling.Code(fmt.Sprintf("[%s]:%s", info.StorageName(), info.StoragePath())),
); err != nil {
logger.Errorf("Failed to build entities: %s", err)

View File

@@ -2,23 +2,28 @@ package parsed
import (
"context"
"fmt"
"net/http"
"sync"
"sync/atomic"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/parser"
"github.com/krau/SaveAny-Bot/storage"
)
var _ core.Executable = (*Task)(nil)
type Task struct {
ID string
Ctx context.Context
Stor storage.Storage
StorPath string
item *parser.Item
httpClient *http.Client
httpClient *http.Client // [TODO] btorrent support?
progress ProgressTracker
stream bool
@@ -31,6 +36,11 @@ type Task struct {
failed map[string]error // [TODO] errors for each resource
}
// Title implements core.Exectable.
func (t *Task) Title() string {
return fmt.Sprintf("[%s](%s->%s:%s)", t.Type(), t.item.Title, t.Stor.Name(), t.StorPath)
}
func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeParseditem
}
@@ -47,12 +57,7 @@ func NewTask(
item *parser.Item,
progressTracker ProgressTracker,
) *Task {
client := &http.Client{
Transport: &http.Transport{
// [TODO] configure it via config
Proxy: http.ProxyFromEnvironment,
},
}
client := netutil.DefaultParserHTTPClient()
_, ok := stor.(storage.StorageCannotStream)
stream := config.C().Stream && !ok
return &Task{

View File

@@ -11,7 +11,6 @@ import (
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"go.uber.org/multierr"
"golang.org/x/sync/errgroup"
)
@@ -48,13 +47,10 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
retry.Context(ctx),
retry.RetryTimes(uint(config.C().Retry)),
}
var lastErr error
err := retry.Retry(func() error {
var body io.ReadCloser
body, lastErr = t.client.Download(ctx, picUrl)
if lastErr != nil {
lastErr = fmt.Errorf("failed to download picture %s: %w", picUrl, lastErr)
return lastErr
body, err := t.client.Download(ctx, picUrl)
if err != nil {
return fmt.Errorf("failed to download picture %s: %w", picUrl, err)
}
defer body.Close()
filename := fmt.Sprintf("%d%s", index+1, path.Ext(picUrl))
@@ -63,8 +59,7 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
fmt.Sprintf("tph_%s_%s", t.TaskID(), filename),
))
if err != nil {
lastErr = fmt.Errorf("failed to create cache file for picture %s: %w", filename, err)
return lastErr
return fmt.Errorf("failed to create cache file for picture %s: %w", filename, err)
}
defer func() {
if err := cacheFile.CloseAndRemove(); err != nil {
@@ -72,26 +67,26 @@ func (t *Task) processPic(ctx context.Context, picUrl string, index int) error {
logger.Errorf("Failed to close and remove cache file for picture %s: %v", filename, err)
}
}()
_, lastErr = io.Copy(cacheFile, body)
if lastErr != nil {
lastErr = fmt.Errorf("failed to copy picture %s to cache file: %w", filename, lastErr)
return lastErr
_, err = io.Copy(cacheFile, body)
if err != nil {
return fmt.Errorf("failed to copy picture %s to cache file: %w", filename, err)
}
_, err = cacheFile.Seek(0, 0)
if err != nil {
lastErr = fmt.Errorf("failed to seek cache file for picture %s: %w", filename, err)
return lastErr
return fmt.Errorf("failed to seek cache file for picture %s: %w", filename, err)
}
err = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
if err != nil {
return fmt.Errorf("failed to save picture %s: %w", filename, err)
}
lastErr = t.Stor.Save(ctx, cacheFile, path.Join(t.StorPath, filename))
} else {
lastErr = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
err = t.Stor.Save(ctx, body, path.Join(t.StorPath, filename))
}
if lastErr != nil {
lastErr = fmt.Errorf("failed to save picture %s: %w", filename, lastErr)
return lastErr
if err != nil {
return fmt.Errorf("failed to save picture %s: %w", filename, err)
}
return nil
}, retryOpts...)
return multierr.Combine(err, lastErr)
return err
}

Some files were not shown because too many files have changed in this diff Show More