Compare commits

...

100 Commits

Author SHA1 Message Date
krau
58ce8275b2 fix: upgrade deps 2026-04-14 09:55:15 +08:00
krau
8f67f778a3 feat(config): add log level configuration and update logging initialization, close #202 2026-04-14 09:41:02 +08:00
krau
159dba6224 fix(config): comment out default config saving logic 2026-03-30 18:33:03 +08:00
krau
22d773da10 fix(entrypoint): remove config.toml check 2026-03-30 18:17:44 +08:00
krau
69ccfa664f chore: bump gotd version 2026-03-30 16:14:13 +08:00
krau
38355dfd14 docs: Add documentation for various features including Aria2, direct download links, parsers, storage rules, silent mode, storage transfer, chat watching, and yt-dlp video download
- Created new markdown files for Aria2 download, direct download links, parsers, storage rules, silent mode, storage transfer, chat watching, and yt-dlp video download.
- Included usage examples, configuration instructions, and detailed explanations for each feature.
- Translated documentation into Chinese for accessibility.
2026-03-11 19:37:25 +08:00
krau
0940258b4d fix(watch): enhance group media handling 2026-03-11 19:11:44 +08:00
krau
602fc251d8 fix: upgrade deps and medernize codes 2026-03-05 19:40:25 +08:00
krau
af28738235 test(api): add api tests 2026-03-05 19:30:23 +08:00
krau
3eb3b6e3c8 feat(api): implement task management API with handlers for creating, listing, retrieving, and canceling tasks
- Added Handlers struct and methods for task operations
- Implemented task progress tracking and storage
- Created server setup with middleware for logging and recovery
- Added support for Telegram file extraction and Telegraph image extraction
- Introduced webhook functionality for task status updates
- Defined request and response types for API interactions
2026-03-05 19:11:30 +08:00
krau
f377ee3ca4 chore: format code for consistency and readability 2026-03-05 17:20:45 +08:00
krau
70f7172162 docs: add rclone support to configuration guide 2026-01-31 21:17:30 +08:00
krau
091f581881 Merge branch 'main' of https://github.com/krau/SaveAny-Bot 2026-01-30 13:34:56 +08:00
Krau
8b86330f5c feat: add rclone storage backend (#191)
* fix: update StoragePath method to return specific path for single file

* feat: add Rclone storage support with configuration and file operations

* docs: add Rclone support to documentation for configuration and usage
2026-01-30 13:34:29 +08:00
krau
b431fa08e2 fix: update StoragePath method to return specific path for single file 2026-01-30 13:09:21 +08:00
krau
a02e8a8d90 fix: update storage path handling in Save method 2026-01-30 12:43:47 +08:00
krau
4d2c345003 fix: enhance filename extraction logic for downloads and add unit tests 2026-01-29 17:11:25 +08:00
krau
33a886fac9 docs: update with new features 2026-01-19 21:55:26 +08:00
krau
57539ec3da docs: add file transfer capabilities between different storage backends in README 2026-01-19 21:48:57 +08:00
krau
82e1efb518 refactor: update logging messages for transfer task execution and progress tracking 2026-01-19 21:34:57 +08:00
krau
9b52a3e0ce refactor: simplify storage path handling across various tasks and storage implementations 2026-01-19 21:27:53 +08:00
krau
6990543c9f feat: update transfer command to remove target path requirement and adjust usage instructions 2026-01-19 21:20:27 +08:00
krau
dd0dea8cb5 feat!: Refactor batch import task to transfer task
- Updated command usage in English and Simplified Chinese localization files to reflect changes in transfer command syntax.
- Removed batch import task implementation, replacing it with a new transfer task implementation.
- Introduced new task structure and progress tracking for file transfers.
- Updated task type enumeration to replace batch import with transfer.
- Added new fields in data structures to support transfer operations.
- Implemented file handling and progress reporting for the transfer task.
2026-01-19 21:14:01 +08:00
krau
3d20fbd0fe feat: implement transfer command for file transfers between storages 2026-01-19 21:01:50 +08:00
krau
e6d8cc775a feat: add yt-dlp to Dockerfile for enhanced media handling 2026-01-19 20:40:00 +08:00
krau
17e340fff1 fix: improve file listing and path handling in Webdav storage 2026-01-19 20:35:48 +08:00
Krau
f92c43b9c8 feat: support import files from storages to telegram (#183)
* feat: add import command and batch import functionality

- Implemented the `/import` command to allow users to import files from storage to Telegram.
- Added support for listing files in storage and filtering based on regex patterns.
- Created a batch import task to handle multiple file uploads concurrently.
- Introduced progress tracking for batch imports, providing real-time updates to users.
- Enhanced storage interfaces to support file listing and reading capabilities.
- Updated localization files for the new import command and its usage instructions.
- Added utility functions for file size formatting and speed calculation.
- Refactored Telegram storage handling to support reading from non-seekable streams.

* feat: add  i18n for import command

* feat: implement ListFiles and OpenFile methods for WebDAV and Alist storage

* Update common/i18n/locale/zh-Hans.yaml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update core/tasks/batchimport/progress.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update core/tasks/batchimport/execute.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update storage/alist/alist.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update common/i18n/locale/en.yaml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update pkg/storagetypes/fileinfo.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update common/i18n/locale/en.yaml

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update core/tasks/batchimport/execute.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update storage/webdav/webdav.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update storage/telegram/telegram.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update core/tasks/batchimport/execute.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update storage/webdav/webdav.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* fix: missing progress stats i18n

* refactor: use strutil to parse args

* chore: update generated code files for consistency

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-19 17:39:47 +08:00
Copilot
3e20dc2c5f feat: add custom parameter support to /ytdlp command (#185), close #184
* Initial plan

* Implement parameter support for /ytdlp command

Co-authored-by: krau <71133316+krau@users.noreply.github.com>

* Add comprehensive tests for ytdlp parameter parsing

Co-authored-by: krau <71133316+krau@users.noreply.github.com>

* Improve flag parsing logic and clarify argument order

Co-authored-by: krau <71133316+krau@users.noreply.github.com>

* Preserve critical defaults and improve comments

Co-authored-by: krau <71133316+krau@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: krau <71133316+krau@users.noreply.github.com>
2026-01-19 13:10:21 +08:00
krau
3ce00884a0 feat: add yt-dlp support for downloading video/audio and enhance related commands 2026-01-17 17:42:11 +08:00
krau
cd7cf4964d fix: handle context cancellation during aria2 download and improve cleanup logic 2026-01-17 15:22:41 +08:00
krau
bc3c841d1d fix: update aria2 configuration to include KeepFile option and improve download handling 2026-01-17 15:19:49 +08:00
krau
743c15f1a5 fix: enhance aria2 download handling and logging for follow-up downloads 2026-01-17 15:10:37 +08:00
krau
b05d86509c feat: basic aria2 integration 2026-01-17 14:57:03 +08:00
krau
f17a380579 fix: implement watch media group handler and enhance media processing logic 2026-01-17 13:23:47 +08:00
krau
cabe1189e2 chore: delete copilot guideline 2026-01-16 22:27:40 +08:00
krau
a988d05e24 feat: add agent guidelines 2026-01-16 22:27:17 +08:00
krau
1af2c1f7c7 fix: upgrade deps 2026-01-16 20:43:19 +08:00
krau
7b36fb45f5 refactor: simplify T function and remove unused localization functions 2026-01-16 20:41:05 +08:00
krau
62cceee592 fix: enhance handleBatchSave to support user context and improve message retrieval 2026-01-08 13:25:59 +08:00
krau
6d315f7af2 refactor: simplify GetMessagesRange function and improve error message formatting 2026-01-08 12:42:44 +08:00
krau
5352491c76 chore: update issue template 2026-01-06 10:05:59 +08:00
krau
3f914f7a64 docs: update proxy configuration comments for clarity in README and example config files 2026-01-06 09:35:21 +08:00
krau
8972d8a169 feat: implement httpProxyDialer for HTTP CONNECT proxies and enhance newProxyDialer function 2026-01-06 09:29:45 +08:00
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
188 changed files with 13186 additions and 1501 deletions

View File

@@ -1,35 +1,41 @@
name: "👾 报告 bug"
description: "报告 bug"
name: "👾 Bug Report"
description: "Report a bug or unexpected behavior"
labels:
- "bug"
assignees:
- krau
body:
- type: markdown
attributes:
value: |
# Please Search Before Submitting / 提交前请搜索
Please make sure to search existing issues before submitting a new bug report.
提交新的 Bug 报告前请务必搜索已有的 issue避免重复
- type: textarea
attributes:
label: "👾 问题描述"
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,69 @@ body:
- type: markdown
attributes:
value: |
# 请详细描述你想要的功能
Please describe the feature you want in detail.
请详细描述你想要的功能。
---
## ⚠️ IMPORTANT NOTICE / 说明
Save Any Bot supports multiple storage backends, **including Telegram**.
However, **all backends are treated equally**, keep this in mind when submitting feature requests.
Save Any Bot 支持多种存储后端,**包括 Telegram**。
但**所有后端在设计上是平等的**,请在提出功能请求前务必理解这一点。
### ❌ Out of scope requests / 不在项目范围内的请求
The following requests are **out of scope** and will be closed without discussion:
以下请求**不属于本项目设计范围**,将被直接关闭,不再讨论:
- Adding **Telegram-specific behaviors or exceptions**
添加 **仅针对 Telegram 的特殊行为或例外逻辑**
- Treating Telegram as anything other than a **generic file storage backend**
将 Telegram 视为非“通用文件存储后端”的特殊存在
- Saving or syncing **non-file content** (text messages, chat history, etc.)
保存或同步 **非文件内容**(文本消息、聊天记录等)
- Preserving or reconstructing original messages (e.g. 1:1 forwarding)
保留或还原原始消息形态(例如 1:1 转发)
- Perform special reprocessing on files to adapt to specific storage backends
(e.g. splitting, re-encoding, transforming, etc.)
为适配特定存储后端而对文件进行特殊处理
(如分割、转码、重编码、转换格式等)
- Any request that requires different logic *only because the backend is Telegram*
任何**仅因后端是 Telegram 而需要不同逻辑**的请求
### ❌ Abuse-leaning or high-risk requests / 滥用倾向的请求
Requests that may **enable or encourage** the following will NOT be accepted:
可能**促成或鼓励**以下行为的请求将不会被接受:
- Violating Telegram Terms of Service
违反 Telegram 服务条款
- Building traffic, mirror, or profit-oriented channels using third-party content
利用第三方内容构建引流、镜像或牟利用途的频道
### ⚖️ Design principle / 设计原则
Save Any Bot follows a **backend-agnostic design**:
Save Any Bot 遵循 **后端无关backend-agnostic** 的设计原则:
- If a feature cannot be implemented **uniformly across all backends**, it will not be added.
如果某个功能无法在 **所有后端** 中统一实现,则不会被添加。
- No backend-specific hacks or special cases will be introduced.
不会引入任何后端特有的 hack 或特殊处理逻辑。
---
If your request falls into any of the categories above, please do not open an issue.
Such issues will be closed.
如果你的请求符合以上任一情况,请不要提交 issue
相关 issue 将被直接关闭。
Thank you for respecting the scope and design principles of this project.
感谢你的理解与支持。
- type: textarea
attributes:
label: "⭐️ Feature description"
@@ -30,4 +92,4 @@ body:
- type: markdown
attributes:
value: |
## Thank you for contributing to the project :slightly_smiling_face:
## Thank you for contributing to the project :slightly_smiling_face:

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

5
.gitignore vendored
View File

@@ -9,4 +9,7 @@ cache.db
temp/
.hugo_build.lock
playwright/
testplugins/
testplugins/
*.exe
tmp-*
saveany-bot

301
AGENTS.md Normal file
View File

@@ -0,0 +1,301 @@
# SaveAny-Bot Agent Guidelines
This document provides essential information for AI coding agents working on the SaveAny-Bot project.
## Project Overview
SaveAny-Bot is a Telegram bot written in Go that saves files/messages from Telegram and various websites to multiple storage backends (local, S3, MinIO, WebDAV, AList, Telegram). It features a plugin system for parsing web content and extensible storage backends.
**Tech Stack**: Go 1.24.2, gotd/td (Telegram MTProto), Cobra (CLI), Viper (config), GORM (ORM), SQLite, Goja (JS runtime), Playwright (browser automation)
## Build & Test Commands
### Build
```bash
# Standard build
go build -o saveany-bot .
# Run directly
go run ./cmd
# Docker build (multi-stage, Alpine-based)
docker build -t saveany-bot .
docker compose up -d
```
### Test
```bash
# Run all tests
go test ./...
# Run tests in specific package
go test ./pkg/queue
go test ./storage/telegram
# Run tests with verbose output
go test -v ./...
# Run a single test
go test -run TestQueueBasic ./pkg/queue
# Run with coverage
go test -cover ./...
```
### Lint & Format
```bash
# Format code (standard Go formatting)
go fmt ./...
# Vet code for common issues
go vet ./...
# Generate code (i18n keys)
go generate ./...
```
### Other Commands
```bash
# Update dependencies
go mod tidy
# View documentation
cd docs && hugo server -D
```
## Code Style Guidelines
### Imports
- Standard library first, then third-party, then project-internal
- Group imports with blank lines between groups
- Use explicit import aliases for clarity when needed (e.g., `storconfig`, `storenum`)
```go
import (
"context"
"fmt"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
```
### Formatting
- Line length: reasonable (no hard limit, but be sensible)
- Organize code with blank lines between logical sections
- Follow standard Go conventions for braces, spacing, etc.
### Types & Interfaces
- Use clear, descriptive type names (PascalCase for exported, camelCase for unexported)
- Define interfaces where abstraction is needed (e.g., `Executable`, `StorageConfig`)
- Embed context in method signatures, not structs: `func (s *Service) Do(ctx context.Context) error`
- Prefer composition over inheritance
```go
// Interfaces define behavior
type Executable interface {
Type() tasktype.TaskType
Title() string
TaskID() string
Execute(ctx context.Context) error
}
// Structs compose behavior
type Local struct {
config config.LocalStorageConfig
logger *log.Logger
}
```
### Naming Conventions
- **Packages**: lowercase, single word when possible (avoid underscores)
- **Files**: lowercase with underscores for multiword (e.g., `auth_terminal.go`, `progress_reader.go`)
- **Variables**: camelCase for unexported, PascalCase for exported
- **Constants**: PascalCase for exported, camelCase for unexported (not ALL_CAPS)
- **Functions/Methods**: PascalCase for exported, camelCase for unexported
- **Test files**: `*_test.go` pattern
### Error Handling
- Always handle errors explicitly; never ignore them
- Wrap errors with context using `fmt.Errorf("context: %w", err)`
- Use `errors.Is()` and `errors.As()` for error checking
- Log errors with appropriate level (Error, Warn, Info)
- Return errors from functions rather than panicking (except for truly unrecoverable situations)
```go
// Good error handling
if err := db.Save(user).Error; err != nil {
return fmt.Errorf("failed to save user %d: %w", user.ChatID, err)
}
// Check specific errors
if errors.Is(err, context.Canceled) {
logger.Info("Operation was canceled")
return nil
}
```
### Logging
- Use `github.com/charmbracelet/log` package
- Get logger from context: `log.FromContext(ctx)`
- Create prefixed loggers for components: `logger.WithPrefix("component")`
- Use appropriate levels: Debug, Info, Warn, Error
- Include context in log messages (e.g., task IDs, file names)
```go
logger := log.FromContext(ctx)
logger.Infof("Processing task: %s", task.ID)
logger.Errorf("Failed to save file %s: %v", filename, err)
```
### Concurrency
- Use channels for communication between goroutines
- Protect shared state with `sync.Mutex` or `sync.RWMutex`
- Use `sync.WaitGroup` for coordinating goroutine completion
- Always pass `context.Context` for cancellation support
- Use `context.WithCancel/WithTimeout` for managing goroutine lifetimes
```go
// Example from queue implementation
func (tq *TaskQueue[T]) Add(task *Task[T]) error {
tq.mu.Lock()
defer tq.mu.Unlock()
// ... critical section
tq.cond.Signal()
return nil
}
```
### Comments
- Document exported types, functions, and packages with doc comments
- Start doc comments with the name being documented
- Use `//` for single-line comments
- Explain *why*, not *what* (code should be self-explanatory for "what")
- Add `[NOTE]`, `[WARN]`, `[IMPORTANT]` tags for important clarifications
```go
// GetUserByChatID retrieves a user by their Telegram chat ID.
// Returns an error if the user is not found.
func GetUserByChatID(ctx context.Context, chatID int64) (*User, error) {
```
## Architecture & Conventions
### Application Structure
- **Entry point**: `main.go``cmd.Execute(ctx)`
- **CLI root**: `cmd/root.go` (Cobra), implementation in `cmd/run.go`
- **Startup sequence**: Config → Cache → i18n → Database → Storage → Parsers → Userbot → Bot → Queue
- Follow this order when adding new initialization steps in `cmd/run.go::initAll`
### Configuration (Viper)
- Config defined in `config/viper.go::Config`
- Read from `config.toml` (see `config.example.toml`)
- Environment variables: `SAVEANY_*` prefix (e.g., `SAVEANY_TELEGRAM_TOKEN`)
- Access via `config.C()` (returns a copy, don't modify the return value)
- Storage configs validated via `config/storage/factory.go::LoadStorageConfigs`
### Telegram Client
- **Bot client**: `client/bot/bot.go::Init` (uses gotgproto)
- **Handlers**: Centralized in `client/bot/handlers/` directory
- **Registration**: All handlers registered in `handlers.Register`
- **Commands**: Add to `CommandHandlers` slice for automatic `/help` and bot command list updates
- **Middleware**: Common middleware in `client/middleware/` (floodwait, retry, etc.)
### Tasks & Queue
- **Task interface**: `core/core.go::Executable` (Type, Title, TaskID, Execute methods)
- **Queue**: `pkg/queue.TaskQueue[Executable]` (generic, thread-safe)
- **Workers**: Count from `config.C().Workers`
- **Task types**: Implementations in `core/tasks/**` (tfile, parsed, telegraph, directlinks, batchtfile)
- **Lifecycle hooks**: `TaskBeforeStart`, `TaskSuccess`, `TaskFail`, `TaskCancel` (defined in config)
- **Adding tasks**: Use `core.AddTask(ctx, task)`
### Database (GORM + SQLite)
- **Init**: `database.Init` using `config.C().DB.Path`
- **Models**: User, Dir, Rule, WatchChat (in `database/*.go`)
- **Migrations**: Automatic via `db.AutoMigrate`
- **User sync**: `database.syncUsers` syncs DB with `config.C().Users` (don't manually create/delete users)
- **Context**: Always use `db.WithContext(ctx)` for operations
### Storage Backends
- **Interface**: Defined in `config/storage/types.go` and `storage/`
- **Implementations**: local, alist, s3/minio, webdav, telegram (each in subdirectory)
- **Adding new storage**:
1. Add enum to `pkg/enums/storage`
2. Create config struct in `config/storage/` with `Validate()` method
3. Implement storage in `storage/<name>/`
4. Register in `storageFactories` mapping
5. Update `config.example.toml` with example
### Parser Plugins (JavaScript)
- **Runtime**: Goja (JS runtime) + Playwright (browser automation)
- **Plugin API**: `registerParser({ metadata, canHandle, parse })` in JS
- **Integration**: Defined in `parsers/` directory
- **Documentation**: See `plugins/README.md`
- Plugin `parse` returns `Item`/`Resource` which becomes download/transfer task
### Internationalization (i18n)
- **Usage**: `i18n.T(i18nk.SomeKey, map[string]any{"Name": value})`
- **Locale files**: `common/i18n/locale/*.yaml`
- **Key generation**: Run `go generate ./...` to generate `common/i18n/i18nk/keys.go`
- **Adding new strings**: Add to YAML → run `go generate` → use in code
- All user-facing strings should be internationalized
### Context Usage
- Always pass `context.Context` as first parameter
- Use `log.FromContext(ctx)` to get contextual logger
- Respect context cancellation in long-running operations
- Store request-scoped data in context (e.g., `ctxkey.ContentLength`)
## Special Rules from .github/copilot-instructions.md
1. **Never modify `config.C()` return values** - it returns a copy. Modify config in `config.Init` or via Viper.
2. **Handlers must update `CommandHandlers` slice** - ensures `/help` and bot commands stay in sync.
3. **Task execution must preserve hooks** - don't remove `TaskBeforeStart`, `TaskSuccess`, `TaskFail`, `TaskCancel` hook calls.
4. **User sync is automatic** - don't manually create/delete users in DB; use config-based sync.
5. **Prefer context logger** - use `log.FromContext(ctx)` over global logger when context is available.
6. **Storage factory pattern** - new storage types must register in `storageFactories` mapping.
7. **Plugin API compatibility** - changes to `Item`/`Resource` structures require updating `plugins/README.md`.
## Common Patterns
### Adding a New Command
1. Create handler function in `client/bot/handlers/<name>.go`
2. Add to `CommandHandlers` slice in `register.go`
3. Add i18n key to `common/i18n/locale/*.yaml`
4. Run `go generate ./...`
5. Test with Telegram bot
### Adding a New Task Type
1. Create struct implementing `core.Executable` in `core/tasks/<type>/`
2. Implement `Type()`, `Title()`, `TaskID()`, `Execute(ctx)` methods
3. Add task type enum to `pkg/enums/tasktype`
4. Use `core.AddTask(ctx, task)` to enqueue
### Adding a New Storage Backend
1. Define config struct in `config/storage/<name>.go` with `Validate()` method
2. Implement storage interface in `storage/<name>/<name>.go`
3. Add storage type enum to `pkg/enums/storage`
4. Register factory in `config/storage/factory.go::storageFactories`
5. Update `config.example.toml` with configuration example
## File References
When referencing code locations, use `path/to/file.go:line` format (e.g., `core/core.go:23` for the worker function).
## Testing Guidelines
- Write tests for new functionality (place in `*_test.go` files)
- Test files should be in same package as code being tested
- Use table-driven tests for multiple test cases
- Mock external dependencies (databases, network calls)
- Aim for meaningful tests, not just coverage numbers
## Notes
- Binary size matters: use `CGO_ENABLED=0` for static binaries
- FFmpeg is included in Docker images for media processing
- Build process supports cross-compilation (amd64/arm64, Linux/macOS/Windows)
- Documentation site uses Hugo; edit files in `docs/` directory
- Session data stored in SQLite; delete `data/session.db` if changing bot token

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 ffmpeg
RUN apk add --no-cache curl ffmpeg yt-dlp
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"]

101
README.md
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,51 @@
## 🎯 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
- Transfer files between different storage backends
- Integrate with yt-dlp to download and save media from 1000+ websites
- Aria2 integration to download files from URLs/magnets and save to storages
- Write JS parser plugins to save files from almost any website
- Storage backends:
- Alist
- S3
- WebDAV
- 本地磁盘
- Telegram (重传回指定聊天)
- Local filesystem
- Rclone (via command line)
- 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
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 +72,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

94
README_zh.md Normal file
View File

@@ -0,0 +1,94 @@
<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/)
- 破解禁止保存的文件
- 批量下载
- 流式传输
- 多用户使用
- 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤
- 在不同存储端之间转存文件
- 集成 yt-dlp, 从所支持的网站下载并转存媒体文件
- 集成 Aria2, 支持直链/磁力下载和转存
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist
- S3
- WebDAV
- 本地磁盘
- Rclone
- Telegram (重传回指定聊天)
## 快速开始
创建文件 `config.toml` 并填入以下内容:
```toml
[telegram]
token = "" # 你的 Bot Token, 在 @BotFather 获取
[telegram.proxy]
# 启用代理连接 telegram
enable = false
url = "socks5://127.0.0.1:7890"
[[storages]]
name = "本地磁盘"
type = "local"
enable = true
base_path = "./downloads"
[[users]]
id = 114514 # 你的 Telegram 账号 id
storages = []
blacklist = true
```
使用 Docker 运行 Save Any Bot:
```bash
docker run -d --name saveany-bot \
-v ./config.toml:/app/config.toml \
-v ./downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
## 赞助
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
- [爱发电](https://afdian.com/a/unvapp)
## 鸣谢
- [gotd](https://github.com/gotd/td)
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
- [gotgproto](https://github.com/celestix/gotgproto)
- [tdl](https://github.com/iyear/tdl)
- All the dependencies, contributors, sponsors and users.
## 社区和关于作者
- [![通知群组](https://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)

48
api/auth.go Normal file
View File

@@ -0,0 +1,48 @@
package api
import (
"context"
"crypto/subtle"
"net/http"
"strings"
"github.com/krau/SaveAny-Bot/config"
)
// tokenContextKey 用于在 context 中存储 token
type tokenContextKey struct{}
// AuthMiddleware 返回认证中间件
func AuthMiddleware() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := config.C().API
// 从请求头获取 token
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
WriteError(w, http.StatusUnauthorized, "unauthorized", "missing authorization header")
return
}
// 提取 Bearer token
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid authorization header format")
return
}
token := parts[1]
// 验证 token
if subtle.ConstantTimeCompare([]byte(token), []byte(cfg.Token)) != 1 {
WriteError(w, http.StatusUnauthorized, "unauthorized", "invalid token")
return
}
// 将 token 添加到 context
ctx := context.WithValue(r.Context(), tokenContextKey{}, token)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}

355
api/factory.go Normal file
View File

@@ -0,0 +1,355 @@
package api
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/aria2dl"
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
"github.com/krau/SaveAny-Bot/core/tasks/directlinks"
"github.com/krau/SaveAny-Bot/core/tasks/parsed"
tphtask "github.com/krau/SaveAny-Bot/core/tasks/telegraph"
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
"github.com/krau/SaveAny-Bot/core/tasks/transfer"
"github.com/krau/SaveAny-Bot/core/tasks/ytdlp"
"github.com/krau/SaveAny-Bot/parsers/parsers"
"github.com/krau/SaveAny-Bot/pkg/aria2"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/parser"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
// TaskFactory 任务工厂
type TaskFactory struct {
ctx context.Context
}
// NewTaskFactory 创建任务工厂
func NewTaskFactory(ctx context.Context) *TaskFactory {
return &TaskFactory{ctx: ctx}
}
// CreateTask 创建任务
func (f *TaskFactory) CreateTask(req *CreateTaskRequest) (*CreateTaskResponse, error) {
// 验证存储
stor, ok := storage.Storages[req.Storage]
if !ok {
return nil, fmt.Errorf("storage not found: %s", req.Storage)
}
taskID := xid.New().String()
createdAt := time.Now()
switch req.Type {
case tasktype.TaskTypeDirectlinks:
return f.createDirectLinksTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeYtdlp:
return f.createYTDLPTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeAria2:
return f.createAria2Task(taskID, createdAt, req, stor)
case tasktype.TaskTypeParseditem:
return f.createParsedTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeTgfiles:
return f.createTGFilesTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeTphpics:
return f.createTPHPicsTask(taskID, createdAt, req, stor)
case tasktype.TaskTypeTransfer:
return f.createTransferTask(taskID, createdAt, req)
default:
return nil, fmt.Errorf("unsupported task type: %s", req.Type)
}
}
// createDirectLinksTask 创建直链下载任务
func (f *TaskFactory) createDirectLinksTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params DirectLinksParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if len(params.URLs) == 0 {
return nil, fmt.Errorf("no URLs provided")
}
task := directlinks.NewTask(taskID, f.ctx, params.URLs, stor, req.Path, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeDirectlinks,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createYTDLPTask 创建 yt-dlp 任务
func (f *TaskFactory) createYTDLPTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params YTDLPParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if len(params.URLs) == 0 {
return nil, fmt.Errorf("no URLs provided")
}
task := ytdlp.NewTask(taskID, f.ctx, params.URLs, params.Flags, stor, req.Path, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeYtdlp,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createAria2Task 创建 Aria2 任务
func (f *TaskFactory) createAria2Task(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params Aria2Params
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if len(params.URLs) == 0 {
return nil, fmt.Errorf("no URLs provided")
}
// 检查 Aria2 是否启用
cfg := config.C().Aria2
if !cfg.Enable {
return nil, fmt.Errorf("aria2 is not enabled")
}
aria2Client, err := aria2.NewClient(cfg.Url, cfg.Secret)
if err != nil {
return nil, fmt.Errorf("failed to create aria2 client: %w", err)
}
// 添加下载任务到 Aria2
gid, err := aria2Client.AddURI(f.ctx, params.URLs, nil)
if err != nil {
return nil, fmt.Errorf("failed to add aria2 task: %w", err)
}
task := aria2dl.NewTask(taskID, f.ctx, gid, params.URLs, aria2Client, stor, req.Path, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeAria2,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createParsedTask 创建解析任务
func (f *TaskFactory) createParsedTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params ParsedParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if params.URL == "" {
return nil, fmt.Errorf("no URL provided")
}
// 查找合适的解析器
var p parser.Parser
for _, parserItem := range parsers.Get() {
if parserItem.CanHandle(params.URL) {
p = parserItem
break
}
}
if p == nil {
return nil, fmt.Errorf("no parser found for URL: %s", params.URL)
}
// 解析 URL
item, err := p.Parse(f.ctx, params.URL)
if err != nil {
return nil, fmt.Errorf("failed to parse URL: %w", err)
}
task := parsed.NewTask(taskID, f.ctx, stor, req.Path, item, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeParseditem,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createTGFilesTask 创建 Telegram 文件下载任务
func (f *TaskFactory) createTGFilesTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params TGFilesParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if len(params.MessageLinks) == 0 {
return nil, fmt.Errorf("no message links provided")
}
// 提取文件
files, err := ExtractFilesFromLinks(f.ctx, params.MessageLinks)
if err != nil {
return nil, fmt.Errorf("failed to extract files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("no files found in provided links")
}
if len(files) == 1 {
// 单个文件任务
tfileTask, err := tfile.NewTGFileTask(taskID, f.ctx, files[0], stor, req.Path, nil)
if err != nil {
return nil, fmt.Errorf("failed to create tfile task: %w", err)
}
if err := core.AddTask(f.ctx, tfileTask); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
} else {
// 批量文件任务
elems := make([]batchtfile.TaskElement, 0, len(files))
for _, file := range files {
elem, err := batchtfile.NewTaskElement(stor, req.Path, file)
if err != nil {
return nil, fmt.Errorf("failed to create task element: %w", err)
}
elems = append(elems, *elem)
}
task := batchtfile.NewBatchTGFileTask(taskID, f.ctx, elems, nil, true)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeTgfiles,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createTPHPicsTask 创建 Telegraph 图片下载任务
func (f *TaskFactory) createTPHPicsTask(taskID string, createdAt time.Time, req *CreateTaskRequest, stor storage.Storage) (*CreateTaskResponse, error) {
var params TPHPicsParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
if params.TelegraphURL == "" {
return nil, fmt.Errorf("no telegraph URL provided")
}
// 提取图片
pics, phPath, err := ExtractTelegraphImages(f.ctx, params.TelegraphURL)
if err != nil {
return nil, fmt.Errorf("failed to extract telegraph images: %w", err)
}
if len(pics) == 0 {
return nil, fmt.Errorf("no images found in telegraph page")
}
client := telegraph.NewClient()
task := tphtask.NewTask(taskID, f.ctx, phPath, pics, stor, req.Path, client, nil)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeTphpics,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}
// createTransferTask 创建存储间传输任务
func (f *TaskFactory) createTransferTask(taskID string, createdAt time.Time, req *CreateTaskRequest) (*CreateTaskResponse, error) {
var params TransferParams
if err := json.Unmarshal(req.Params, &params); err != nil {
return nil, fmt.Errorf("invalid params: %w", err)
}
// 验证源存储和目标存储
sourceStor, ok := storage.Storages[params.SourceStorage]
if !ok {
return nil, fmt.Errorf("source storage not found: %s", params.SourceStorage)
}
targetStor, ok := storage.Storages[params.TargetStorage]
if !ok {
return nil, fmt.Errorf("target storage not found: %s", params.TargetStorage)
}
// 检查源存储是否可读
sourceReadable, ok := sourceStor.(storage.StorageReadable)
if !ok {
return nil, fmt.Errorf("source storage does not support reading: %s", params.SourceStorage)
}
// 检查源存储是否可列
sourceListable, ok := sourceStor.(storage.StorageListable)
if !ok {
return nil, fmt.Errorf("source storage does not support listing: %s", params.SourceStorage)
}
// 列出源文件
files, err := sourceListable.ListFiles(f.ctx, params.SourcePath)
if err != nil {
return nil, fmt.Errorf("failed to list source files: %w", err)
}
if len(files) == 0 {
return nil, fmt.Errorf("no files found at source path: %s", params.SourcePath)
}
// 创建传输元素
elems := make([]transfer.TaskElement, 0, len(files))
for _, file := range files {
elem := transfer.NewTaskElement(sourceReadable, file, targetStor, params.TargetPath)
elems = append(elems, *elem)
}
task := transfer.NewTransferTask(taskID, f.ctx, elems, nil, true)
if err := core.AddTask(f.ctx, task); err != nil {
return nil, fmt.Errorf("failed to add task: %w", err)
}
return &CreateTaskResponse{
TaskID: taskID,
Type: tasktype.TaskTypeTransfer,
Status: TaskStatusQueued,
CreatedAt: createdAt,
}, nil
}

222
api/handlers.go Normal file
View File

@@ -0,0 +1,222 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/storage"
)
// Handlers 处理器结构体
type Handlers struct {
factory *TaskFactory
}
// NewHandlers 创建处理器
func NewHandlers(factory *TaskFactory) *Handlers {
return &Handlers{factory: factory}
}
// CreateTaskHandler 创建任务处理器
func (h *Handlers) CreateTaskHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only POST method is allowed")
return
}
var req CreateTaskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
WriteError(w, http.StatusBadRequest, "invalid_request", "failed to decode request body: "+err.Error())
return
}
// 验证请求
if req.Type == "" {
WriteError(w, http.StatusBadRequest, "invalid_request", "task type is required")
return
}
if req.Storage == "" {
WriteError(w, http.StatusBadRequest, "invalid_request", "storage is required")
return
}
// 创建任务
resp, err := h.factory.CreateTask(&req)
if err != nil {
WriteError(w, http.StatusBadRequest, "task_creation_failed", err.Error())
return
}
WriteJSON(w, http.StatusCreated, resp)
}
// ListTasksHandler 列出任务处理器
func (h *Handlers) ListTasksHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
return
}
tasks := GetAllTasks()
response := make([]TaskInfoResponse, 0, len(tasks))
for _, task := range tasks {
info := convertTaskProgressToResponse(task)
response = append(response, info)
}
WriteJSON(w, http.StatusOK, TasksListResponse{
Tasks: response,
Total: len(response),
})
}
// GetTaskHandler 获取单个任务处理器
func (h *Handlers) GetTaskHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
return
}
taskID := extractTaskIDFromPath(r.URL.Path)
if taskID == "" {
WriteError(w, http.StatusBadRequest, "invalid_request", "task ID is required")
return
}
task, ok := GetTask(taskID)
if !ok {
WriteError(w, http.StatusNotFound, "task_not_found", "task not found: "+taskID)
return
}
resp := convertTaskProgressToResponse(task)
WriteJSON(w, http.StatusOK, resp)
}
// CancelTaskHandler 取消任务处理器
func (h *Handlers) CancelTaskHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only DELETE method is allowed")
return
}
taskID := extractTaskIDFromPath(r.URL.Path)
if taskID == "" {
WriteError(w, http.StatusBadRequest, "invalid_request", "task ID is required")
return
}
task, ok := GetTask(taskID)
if !ok {
WriteError(w, http.StatusNotFound, "task_not_found", "task not found: "+taskID)
return
}
// 取消任务
if err := core.CancelTask(r.Context(), taskID); err != nil {
WriteError(w, http.StatusInternalServerError, "cancel_failed", "failed to cancel task: "+err.Error())
return
}
task.UpdateStatus(TaskStatusCancelled)
WriteJSON(w, http.StatusOK, map[string]string{"message": "task cancelled successfully"})
}
// ListStoragesHandler 列出存储处理器
func (h *Handlers) ListStoragesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
return
}
storages := make([]StorageInfo, 0, len(storage.Storages))
for name, stor := range storage.Storages {
storages = append(storages, StorageInfo{
Name: name,
Type: string(stor.Type()),
})
}
WriteJSON(w, http.StatusOK, StoragesResponse{Storages: storages})
}
// GetTaskTypesHandler 获取支持的任务类型
func (h *Handlers) GetTaskTypesHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "only GET method is allowed")
return
}
types := []tasktype.TaskType{
tasktype.TaskTypeDirectlinks,
tasktype.TaskTypeYtdlp,
tasktype.TaskTypeAria2,
tasktype.TaskTypeParseditem,
tasktype.TaskTypeTgfiles,
tasktype.TaskTypeTphpics,
tasktype.TaskTypeTransfer,
}
WriteJSON(w, http.StatusOK, map[string]any{
"types": types,
})
}
// HealthCheckHandler 健康检查处理器
func (h *Handlers) HealthCheckHandler(w http.ResponseWriter, r *http.Request) {
WriteJSON(w, http.StatusOK, map[string]string{
"status": "ok",
})
}
// extractTaskIDFromPath 从路径中提取任务 ID
// 路径格式: /api/v1/tasks/:id
func extractTaskIDFromPath(path string) string {
parts := strings.Split(strings.Trim(path, "/"), "/")
if len(parts) < 4 {
return ""
}
return parts[3]
}
// convertTaskProgressToResponse 将任务进度转换为响应格式
func convertTaskProgressToResponse(task *TaskProgressInfo) TaskInfoResponse {
resp := TaskInfoResponse{
TaskID: task.TaskID,
Type: tasktype.TaskType(task.Type),
Status: task.Status,
Title: task.Title,
Storage: task.Storage,
Path: task.Path,
Error: task.Error,
CreatedAt: task.CreatedAt,
UpdatedAt: task.UpdatedAt,
}
// 计算进度
if task.TotalBytes > 0 {
percent := float64(task.DownloadedBytes) * 100 / float64(task.TotalBytes)
resp.Progress = &TaskProgress{
TotalBytes: task.TotalBytes,
DownloadedBytes: task.DownloadedBytes,
Percent: percent,
}
}
return resp
}
// NotFoundHandler 404 处理器
func NotFoundHandler(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusNotFound, "not_found", "endpoint not found: "+r.URL.Path)
}
// MethodNotAllowedHandler 405 处理器
func MethodNotAllowedHandler(w http.ResponseWriter, r *http.Request) {
WriteError(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed: "+r.Method)
}

689
api/handlers_test.go Normal file
View File

@@ -0,0 +1,689 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
)
// setupTestServer creates a test server with handlers
func setupTestServer(t *testing.T) (*Handlers, *TaskFactory) {
factory := NewTaskFactory(t.Context())
handlers := NewHandlers(factory)
return handlers, factory
}
// TestCreateTaskHandler tests the create task endpoint
func TestCreateTaskHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
tests := []struct {
name string
method string
body any
wantStatus int
wantErr bool
}{
{
name: "Method not allowed",
method: http.MethodGet,
body: nil,
wantStatus: http.StatusMethodNotAllowed,
wantErr: true,
},
{
name: "Invalid JSON body",
method: http.MethodPost,
body: "invalid json",
wantStatus: http.StatusBadRequest,
wantErr: true,
},
{
name: "Empty request body",
method: http.MethodPost,
body: CreateTaskRequest{},
// Will fail validation for missing type
wantStatus: http.StatusBadRequest,
wantErr: true,
},
{
name: "Missing type",
method: http.MethodPost,
body: CreateTaskRequest{
Storage: "test-storage",
Path: "downloads",
},
wantStatus: http.StatusBadRequest,
wantErr: true,
},
{
name: "Missing storage",
method: http.MethodPost,
body: CreateTaskRequest{
Type: tasktype.TaskTypeDirectlinks,
Path: "downloads",
},
wantStatus: http.StatusBadRequest,
wantErr: true,
},
{
name: "Storage not found",
method: http.MethodPost,
body: CreateTaskRequest{
Type: tasktype.TaskTypeDirectlinks,
Storage: "non-existent-storage",
Path: "downloads",
Params: json.RawMessage(`{"urls":["https://example.com/file.zip"]}`),
},
wantStatus: http.StatusBadRequest,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var bodyBytes []byte
var err error
if tt.body != nil {
switch v := tt.body.(type) {
case string:
bodyBytes = []byte(v)
default:
bodyBytes, err = json.Marshal(tt.body)
if err != nil {
t.Fatalf("failed to marshal body: %v", err)
}
}
}
req := httptest.NewRequest(tt.method, "/api/v1/tasks", bytes.NewReader(bodyBytes))
if tt.body != nil {
req.Header.Set("Content-Type", "application/json")
}
rr := httptest.NewRecorder()
handlers.CreateTaskHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.wantErr {
var errResp ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &errResp); err != nil {
t.Errorf("expected error response, got: %s", rr.Body.String())
}
}
})
}
}
// TestListTasksHandler tests the list tasks endpoint
func TestListTasksHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
tests := []struct {
name string
method string
wantStatus int
}{
{
name: "Method not allowed",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Success",
method: http.MethodGet,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/v1/tasks", nil)
rr := httptest.NewRecorder()
handlers.ListTasksHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.method == http.MethodGet {
var resp TasksListResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Errorf("failed to unmarshal response: %v", err)
}
if resp.Tasks == nil {
t.Error("expected non-nil tasks array")
}
}
})
}
}
// TestGetTaskHandler tests the get task endpoint
func TestGetTaskHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
// Register a test task
testTaskID := "test-get-task"
RegisterTask(testTaskID, "directlinks", "local", "downloads", "Test", "")
defer DeleteTask(testTaskID)
tests := []struct {
name string
method string
path string
wantStatus int
wantFound bool
}{
{
name: "Method not allowed",
method: http.MethodPost,
path: "/api/v1/tasks/test-id",
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Missing task ID",
method: http.MethodGet,
path: "/api/v1/tasks",
wantStatus: http.StatusBadRequest,
},
{
name: "Task not found",
method: http.MethodGet,
path: "/api/v1/tasks/non-existent-task",
wantStatus: http.StatusNotFound,
},
{
name: "Task found",
method: http.MethodGet,
path: "/api/v1/tasks/" + testTaskID,
wantStatus: http.StatusOK,
wantFound: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, nil)
rr := httptest.NewRecorder()
handlers.GetTaskHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.wantFound {
var resp TaskInfoResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Errorf("failed to unmarshal response: %v", err)
}
if resp.TaskID != testTaskID {
t.Errorf("expected task ID %s, got %s", testTaskID, resp.TaskID)
}
}
})
}
}
// TestCancelTaskHandler tests the cancel task endpoint
func TestCancelTaskHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
// Register a test task
testTaskID := "test-cancel-task"
RegisterTask(testTaskID, "directlinks", "local", "downloads", "Test", "")
defer DeleteTask(testTaskID)
tests := []struct {
name string
method string
path string
wantStatus int
skipCore bool // Skip if core is not initialized
}{
{
name: "Method not allowed",
method: http.MethodGet,
path: "/api/v1/tasks/test-id",
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Missing task ID",
method: http.MethodDelete,
path: "/api/v1/tasks",
wantStatus: http.StatusBadRequest,
},
{
name: "Task not found",
method: http.MethodDelete,
path: "/api/v1/tasks/non-existent-task",
wantStatus: http.StatusNotFound,
},
{
name: "Cancel task",
method: http.MethodDelete,
path: "/api/v1/tasks/" + testTaskID,
wantStatus: http.StatusOK,
skipCore: true, // Requires initialized core task queue
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.skipCore {
t.Skip("Skipping test: requires initialized core")
}
req := httptest.NewRequest(tt.method, tt.path, nil)
rr := httptest.NewRecorder()
handlers.CancelTaskHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
})
}
}
// TestListStoragesHandler tests the list storages endpoint
func TestListStoragesHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
tests := []struct {
name string
method string
wantStatus int
}{
{
name: "Method not allowed",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Success",
method: http.MethodGet,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/v1/storages", nil)
rr := httptest.NewRecorder()
handlers.ListStoragesHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.method == http.MethodGet {
var resp StoragesResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Errorf("failed to unmarshal response: %v", err)
}
if resp.Storages == nil {
t.Error("expected non-nil storages array")
}
}
})
}
}
// TestConcurrentProgressStore tests concurrent access to progress store
func TestConcurrentProgressStore(t *testing.T) {
// Clear store before test
t.Cleanup(func() {
tasks := GetAllTasks()
for _, task := range tasks {
if strings.HasPrefix(task.TaskID, "concurrent-test-") {
DeleteTask(task.TaskID)
}
}
})
var wg sync.WaitGroup
numGoroutines := 100
// Concurrent registrations
for i := range numGoroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
taskID := fmt.Sprintf("concurrent-test-%d", id)
RegisterTask(taskID, "directlinks", "local", "downloads", "Test", "")
}(i)
}
// Concurrent reads
for i := range numGoroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
taskID := fmt.Sprintf("concurrent-test-%d", id)
GetTask(taskID)
}(i)
}
// Concurrent updates
for i := range numGoroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
taskID := fmt.Sprintf("concurrent-test-%d", id)
info, ok := GetTask(taskID)
if ok {
info.UpdateStatus(TaskStatusRunning)
}
}(i)
}
wg.Wait()
// Verify all tasks exist
for i := range numGoroutines {
taskID := fmt.Sprintf("concurrent-test-%d", i)
if _, ok := GetTask(taskID); !ok {
t.Errorf("task %s not found after concurrent operations", taskID)
}
}
}
// TestProgressTrackerConcurrentUpdates tests concurrent progress updates
func TestProgressTrackerConcurrentUpdates(t *testing.T) {
tracker := NewProgressTracker("concurrent-progress", "directlinks", "local", "downloads", "Test", "")
tracker.OnStart(10000, 10)
var wg sync.WaitGroup
numGoroutines := 50
updatesPerGoroutine := 100
// Concurrent progress updates
for i := range numGoroutines {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := range updatesPerGoroutine {
tracker.OnProgress(int64(id*updatesPerGoroutine+j), j)
}
}(i)
}
wg.Wait()
info := tracker.GetInfo()
if info.Status != TaskStatusRunning {
t.Errorf("expected status Running after concurrent updates, got %s", info.Status)
}
// Note: Due to race conditions in the simple implementation,
// we can't reliably check exact values without proper synchronization
}
// TestTaskFactoryValidation tests TaskFactory parameter validation
func TestTaskFactoryValidation(t *testing.T) {
factory := NewTaskFactory(context.Background())
tests := []struct {
name string
request *CreateTaskRequest
wantErr bool
errMsg string
}{
{
name: "Storage not found",
request: &CreateTaskRequest{
Type: tasktype.TaskTypeDirectlinks,
Storage: "non-existent",
Path: "downloads",
Params: json.RawMessage(`{"urls":["https://example.com/file.zip"]}`),
},
wantErr: true,
errMsg: "storage not found",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := factory.CreateTask(tt.request)
if (err != nil) != tt.wantErr {
t.Errorf("CreateTask() error = %v, wantErr %v", err, tt.wantErr)
return
}
if err != nil && tt.errMsg != "" {
if !strings.Contains(strings.ToLower(err.Error()), tt.errMsg) {
t.Errorf("error message %q does not contain %q", err.Error(), tt.errMsg)
}
}
})
}
}
// TestEdgeCases tests various edge cases
func TestEdgeCases(t *testing.T) {
tests := []struct {
name string
fn func(t *testing.T)
}{
{
name: "Empty request body",
fn: func(t *testing.T) {
handlers, _ := setupTestServer(t)
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", nil)
rr := httptest.NewRecorder()
handlers.CreateTaskHandler(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected %d, got %d", http.StatusBadRequest, rr.Code)
}
},
},
{
name: "Very long task ID in path",
fn: func(t *testing.T) {
handlers, _ := setupTestServer(t)
longID := strings.Repeat("a", 1000)
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/"+longID, nil)
rr := httptest.NewRecorder()
handlers.GetTaskHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected %d, got %d", http.StatusNotFound, rr.Code)
}
},
},
{
name: "Path with special characters",
fn: func(t *testing.T) {
path := "/api/v1/tasks/test%20id/with/slashes"
got := extractTaskIDFromPath(path)
expected := "test%20id"
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
},
},
{
name: "Double slashes in path",
fn: func(t *testing.T) {
path := "/api/v1/tasks//task-id"
got := extractTaskIDFromPath(path)
expected := ""
if got != expected {
t.Errorf("expected %q, got %q", expected, got)
}
},
},
{
name: "Progress tracker with empty webhook",
fn: func(t *testing.T) {
tracker := NewProgressTracker("test", "type", "storage", "path", "title", "")
info := tracker.GetInfo()
if info.Webhook != "" {
t.Error("expected empty webhook")
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, tt.fn)
}
}
// TestHealthCheckHandler tests the health check endpoint
func TestHealthCheckHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/health", nil)
rr := httptest.NewRecorder()
handlers.HealthCheckHandler(rr, req)
if rr.Code != http.StatusOK {
t.Errorf("expected status %d, got %d", http.StatusOK, rr.Code)
}
var resp map[string]string
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp["status"] != "ok" {
t.Errorf("expected status 'ok', got %q", resp["status"])
}
}
// TestGetTaskTypesHandler tests the task types endpoint
func TestGetTaskTypesHandler(t *testing.T) {
handlers, _ := setupTestServer(t)
tests := []struct {
name string
method string
wantStatus int
}{
{
name: "Method not allowed",
method: http.MethodPost,
wantStatus: http.StatusMethodNotAllowed,
},
{
name: "Success",
method: http.MethodGet,
wantStatus: http.StatusOK,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "/api/v1/task-types", nil)
rr := httptest.NewRecorder()
handlers.GetTaskTypesHandler(rr, req)
if rr.Code != tt.wantStatus {
t.Errorf("expected status %d, got %d", tt.wantStatus, rr.Code)
}
if tt.method == http.MethodGet {
var resp map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Errorf("failed to unmarshal response: %v", err)
}
if _, ok := resp["types"]; !ok {
t.Error("expected 'types' field in response")
}
}
})
}
}
// TestNotFoundHandler tests the 404 handler
func TestNotFoundHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/non-existent-path", nil)
rr := httptest.NewRecorder()
NotFoundHandler(rr, req)
if rr.Code != http.StatusNotFound {
t.Errorf("expected status %d, got %d", http.StatusNotFound, rr.Code)
}
var resp ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Error != "not_found" {
t.Errorf("expected error 'not_found', got %q", resp.Error)
}
}
// TestMethodNotAllowedHandler tests the 405 handler
func TestMethodNotAllowedHandler(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/v1/tasks", nil)
rr := httptest.NewRecorder()
MethodNotAllowedHandler(rr, req)
if rr.Code != http.StatusMethodNotAllowed {
t.Errorf("expected status %d, got %d", http.StatusMethodNotAllowed, rr.Code)
}
var resp ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Error != "method_not_allowed" {
t.Errorf("expected error 'method_not_allowed', got %q", resp.Error)
}
}
// TestTaskProgressInfoTimeUpdate tests that timestamps are updated correctly
func TestTaskProgressInfoTimeUpdate(t *testing.T) {
info := RegisterTask("time-test", "directlinks", "local", "downloads", "Test", "")
defer DeleteTask("time-test")
originalTime := info.UpdatedAt
time.Sleep(10 * time.Millisecond) // Ensure time difference
info.UpdateStatus(TaskStatusRunning)
if !info.UpdatedAt.After(originalTime) {
t.Error("expected UpdatedAt to be updated")
}
}
// TestWebhookPayloadWithNilCompletedAt tests webhook payload with nil completed_at
func TestWebhookPayloadWithNilCompletedAt(t *testing.T) {
payload := WebhookPayload{
TaskID: "test-id",
Type: "directlinks",
Status: TaskStatusRunning,
Storage: "local",
Path: "downloads/file.zip",
CompletedAt: nil,
Error: "",
}
data, err := json.Marshal(payload)
if err != nil {
t.Fatalf("failed to marshal: %v", err)
}
var decoded map[string]any
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal: %v", err)
}
// completed_at should be omitted when nil
if _, ok := decoded["completed_at"]; ok {
t.Error("expected completed_at to be omitted when nil")
}
}

150
api/progress.go Normal file
View File

@@ -0,0 +1,150 @@
package api
import (
"sync"
"sync/atomic"
"time"
)
// TaskProgressInfo 存储任务的进度信息
type TaskProgressInfo struct {
TaskID string
Type string
Status TaskStatus
Title string
TotalBytes int64
DownloadedBytes int64
TotalFiles int
DownloadedFiles int
Storage string
Path string
Error string
CreatedAt time.Time
UpdatedAt time.Time
Webhook string
}
// progressStore 存储所有 API 任务的进度信息
type progressStore struct {
mu sync.RWMutex
tasks map[string]*TaskProgressInfo
}
var store = &progressStore{
tasks: make(map[string]*TaskProgressInfo),
}
// RegisterTask 注册一个新的 API 任务
func RegisterTask(taskID, taskType, storage, path, title, webhook string) *TaskProgressInfo {
info := &TaskProgressInfo{
TaskID: taskID,
Type: taskType,
Status: TaskStatusQueued,
Title: title,
Storage: storage,
Path: path,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Webhook: webhook,
}
store.mu.Lock()
store.tasks[taskID] = info
store.mu.Unlock()
return info
}
// GetTask 获取任务进度信息
func GetTask(taskID string) (*TaskProgressInfo, bool) {
store.mu.RLock()
defer store.mu.RUnlock()
info, ok := store.tasks[taskID]
return info, ok
}
// GetAllTasks 获取所有任务
func GetAllTasks() []*TaskProgressInfo {
store.mu.RLock()
defer store.mu.RUnlock()
tasks := make([]*TaskProgressInfo, 0, len(store.tasks))
for _, info := range store.tasks {
tasks = append(tasks, info)
}
return tasks
}
// DeleteTask 删除任务记录
func DeleteTask(taskID string) {
store.mu.Lock()
defer store.mu.Unlock()
delete(store.tasks, taskID)
}
// UpdateStatus 更新任务状态
func (t *TaskProgressInfo) UpdateStatus(status TaskStatus) {
t.Status = status
t.UpdatedAt = time.Now()
}
// SetError 设置错误信息
func (t *TaskProgressInfo) SetError(err string) {
t.Error = err
t.Status = TaskStatusFailed
t.UpdatedAt = time.Now()
}
// ProgressTracker 用于 API 任务的进度追踪
type ProgressTracker struct {
info *TaskProgressInfo
}
// NewProgressTracker 创建新的进度追踪器
func NewProgressTracker(taskID, taskType, storage, path, title, webhook string) *ProgressTracker {
info := RegisterTask(taskID, taskType, storage, path, title, webhook)
return &ProgressTracker{info: info}
}
// OnStart 任务开始
func (p *ProgressTracker) OnStart(totalBytes int64, totalFiles int) {
p.info.Status = TaskStatusRunning
p.info.TotalBytes = totalBytes
p.info.TotalFiles = totalFiles
p.info.UpdatedAt = time.Now()
}
// OnProgress 进度更新
func (p *ProgressTracker) OnProgress(downloadedBytes int64, downloadedFiles int) {
atomic.StoreInt64(&p.info.DownloadedBytes, downloadedBytes)
p.info.DownloadedFiles = downloadedFiles
p.info.UpdatedAt = time.Now()
}
// OnDone 任务完成
func (p *ProgressTracker) OnDone(err error) {
if err != nil {
p.info.Status = TaskStatusFailed
p.info.Error = err.Error()
} else {
p.info.Status = TaskStatusCompleted
}
p.info.UpdatedAt = time.Now()
}
// GetInfo 获取任务信息
func (p *ProgressTracker) GetInfo() *TaskProgressInfo {
return p.info
}
// UpdateProgressBytes 更新下载字节数
func (p *ProgressTracker) UpdateProgressBytes(bytes int64) {
atomic.StoreInt64(&p.info.DownloadedBytes, bytes)
p.info.UpdatedAt = time.Now()
}
// UpdateProgressFiles 更新下载文件数
func (p *ProgressTracker) UpdateProgressFiles(files int) {
p.info.DownloadedFiles = files
p.info.UpdatedAt = time.Now()
}

163
api/server.go Normal file
View File

@@ -0,0 +1,163 @@
package api
import (
"context"
"fmt"
"net/http"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
)
// Server API 服务器
type Server struct {
httpServer *http.Server
factory *TaskFactory
}
// NewServer 创建新的 API 服务器
func NewServer(ctx context.Context) *Server {
cfg := config.C().API
factory := NewTaskFactory(ctx)
handlers := NewHandlers(factory)
// 设置路由
mux := http.NewServeMux()
// 健康检查
mux.HandleFunc("/health", handlers.HealthCheckHandler)
// API v1 路由
mux.HandleFunc("/api/v1/tasks", handlers.CreateTaskHandler)
mux.HandleFunc("/api/v1/tasks/", func(w http.ResponseWriter, r *http.Request) {
// 根据方法和路径分发
switch r.Method {
case http.MethodGet:
if r.URL.Path == "/api/v1/tasks" {
handlers.ListTasksHandler(w, r)
} else {
handlers.GetTaskHandler(w, r)
}
case http.MethodDelete:
handlers.CancelTaskHandler(w, r)
default:
MethodNotAllowedHandler(w, r)
}
})
mux.HandleFunc("/api/v1/storages", handlers.ListStoragesHandler)
mux.HandleFunc("/api/v1/task-types", handlers.GetTaskTypesHandler)
// 404 处理
mux.HandleFunc("/", NotFoundHandler)
// 应用中间件
var handler http.Handler = mux
// 添加认证中间件
token := cfg.Token
if token == "" {
log.FromContext(ctx).Warn("API server is enabled but no token is set, this is insecure!")
}
if token != "" {
handler = AuthMiddleware()(handler)
}
// 添加日志中间件
handler = loggingMiddleware(handler)
// 添加恢复中间件
handler = recoveryMiddleware(handler)
return &Server{
httpServer: &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
},
factory: factory,
}
}
// Start 启动服务器
func (s *Server) Start(ctx context.Context) error {
logger := log.FromContext(ctx).With("module", "api")
logger.Infof("Starting API server on %s", s.httpServer.Addr)
// 在 goroutine 中启动服务器
go func() {
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Errorf("API server error: %v", err)
}
}()
// 监听 context 取消
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
logger.Errorf("API server shutdown error: %v", err)
}
}()
return nil
}
// loggingMiddleware 日志中间件
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 包装 ResponseWriter 以获取状态码
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
log.Infof("%s %s %d %s", r.Method, r.URL.Path, wrapped.statusCode, time.Since(start))
})
}
// recoveryMiddleware 恢复中间件
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Errorf("Panic recovered: %v", err)
WriteError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
// responseWriter 包装 http.ResponseWriter 以捕获状态码
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Start 初始化并启动 API 服务器
func Start(ctx context.Context) error {
cfg := config.C().API
if !cfg.Enable {
return nil
}
if cfg.Token == "" {
log.FromContext(ctx).Warn("API server is enabled but no token is set, this is insecure!")
}
server := NewServer(ctx)
return server.Start(ctx)
}

272
api/tgfiles.go Normal file
View File

@@ -0,0 +1,272 @@
package api
import (
"context"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
// MessageContext 保存消息和获取它所用的 context
type MessageContext struct {
Message *tg.Message
Client *ext.Context
}
// getClientContext 获取可用的客户端上下文
// 优先使用 Bot失败后回退到 Userbot
func getClientContext() (*ext.Context, error) {
// 首先尝试获取 Bot context
if botCtx := bot.ExtContext(); botCtx != nil {
return botCtx, nil
}
// 回退到 Userbot
if uc := userclient.GetCtx(); uc != nil {
return uc, nil
}
return nil, fmt.Errorf("no client available (bot and userbot are not initialized)")
}
// resolveChatID 解析聊天 ID
func resolveChatID(_ context.Context, idOrUsername string) (int64, error) {
// 如果是数字 ID
if id, err := strconv.ParseInt(idOrUsername, 10, 64); err == nil {
// 私有频道 ID 需要加上 -100 前缀
if id > 0 {
return -1000000000000 - id, nil
}
return id, nil
}
// 获取可用的客户端上下文
clientCtx, err := getClientContext()
if err != nil {
return 0, err
}
// 使用 tgutil 的 ParseChatID
return tgutil.ParseChatID(clientCtx, idOrUsername)
}
// ParseMessageLink 解析 Telegram 消息链接
// 支持格式:
// - https://t.me/username/123
// - https://t.me/c/123456789/123
// - https://t.me/c/123456789/111/456 (topic id)
// - https://t.me/username/123?comment=2 (评论)
func ParseMessageLink(ctx context.Context, link string) (int64, int, error) {
u, err := url.Parse(link)
if err != nil {
return 0, 0, fmt.Errorf("invalid URL: %w", err)
}
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if cmt := u.Query().Get("comment"); cmt != "" {
// 频道评论的消息链接
if len(paths) < 1 {
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
// 简化处理:返回错误,提示不支持评论链接
return 0, 0, fmt.Errorf("comment links are not supported")
}
switch len(paths) {
case 2: // https://t.me/username/123
chatID, err := resolveChatID(ctx, paths[0])
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 3:
// https://t.me/c/123456789/123
// https://t.me/username/123/456 , 123: topic id
chatPart, msgPart := paths[1], paths[2]
if paths[0] != "c" {
chatPart = paths[0]
}
chatID, err := resolveChatID(ctx, chatPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(msgPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 4:
// https://t.me/c/123456789/111/456 111: topic id
if paths[0] != "c" {
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
chatID, err := resolveChatID(ctx, paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to resolve chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[3])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
}
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
// getMessageWithContext 通过 ID 获取消息,返回消息和使用的 context
// 确保消息获取和后续文件创建使用同一个 context
func getMessageWithContext(_ context.Context, chatID int64, msgID int) (*MessageContext, error) {
// 首先尝试使用 Bot
if botCtx := bot.ExtContext(); botCtx != nil {
msg, err := tgutil.GetMessageByID(botCtx, chatID, msgID)
if err == nil {
return &MessageContext{Message: msg, Client: botCtx}, nil
}
}
// 回退到 Userbot
uc := userclient.GetCtx()
if uc == nil {
return nil, fmt.Errorf("userbot not initialized and bot cannot access this message")
}
msg, err := tgutil.GetMessageByID(uc, chatID, msgID)
if err != nil {
return nil, err
}
return &MessageContext{Message: msg, Client: uc}, nil
}
// getGroupedMessagesWithContext 获取媒体组消息,返回消息列表和使用的 context
// 确保消息获取和后续文件创建使用同一个 context
func getGroupedMessagesWithContext(ctx *MessageContext, chatID int64) ([]*tg.Message, error) {
msg := ctx.Message
clientCtx := ctx.Client
groupID, ok := msg.GetGroupedID()
if !ok || groupID == 0 {
return []*tg.Message{msg}, nil
}
// 使用获取原始消息的同一个 client 获取媒体组
msgs, err := tgutil.GetGroupedMessages(clientCtx, chatID, msg)
if err != nil || len(msgs) == 0 {
// 如果获取失败,至少返回原始消息
return []*tg.Message{msg}, nil
}
return msgs, nil
}
// ExtractFilesFromLinks 从消息链接中提取文件
// 每个文件的处理流程:解析链接 -> 获取消息 -> 获取媒体组 -> 创建文件对象
// 对于单个文件,全程使用同一个 client context不会交叉
func ExtractFilesFromLinks(ctx context.Context, links []string) ([]tfile.TGFileMessage, error) {
logger := log.FromContext(ctx)
var files []tfile.TGFileMessage
for _, link := range links {
link = strings.TrimSpace(link)
if link == "" {
continue
}
// 验证链接格式
if !isValidMessageLink(link) {
logger.Errorf("Invalid message link format: %s", link)
continue
}
chatID, msgID, err := ParseMessageLink(ctx, link)
if err != nil {
logger.Errorf("Failed to parse message link %s: %v", link, err)
continue
}
// 解析链接 URL 检查是否有 single 参数
u, _ := url.Parse(link)
single := u != nil && u.Query().Has("single")
// 获取消息和使用的 contextBot 优先,失败回退 Userbot
msgCtx, err := getMessageWithContext(ctx, chatID, msgID)
if err != nil {
logger.Errorf("Failed to get message %d from chat %d: %v", msgID, chatID, err)
continue
}
msg := msgCtx.Message
clientCtx := msgCtx.Client
if msg.Media == nil {
logger.Warnf("Message %d has no media", msgID)
continue
}
media, ok := msg.GetMedia()
if !ok {
logger.Warnf("Failed to get media from message %d", msgID)
continue
}
// 检查是否是媒体组
groupID, isGroup := msg.GetGroupedID()
if isGroup && groupID != 0 && !single {
// 使用同一个 client context 获取媒体组
groupMsgs, err := getGroupedMessagesWithContext(msgCtx, chatID)
if err != nil {
logger.Errorf("Failed to get grouped messages: %v", err)
} else {
for _, gmsg := range groupMsgs {
if gmsg.Media == nil {
continue
}
gmedia, ok := gmsg.GetMedia()
if !ok {
continue
}
// 使用获取消息时使用的同一个 client context 创建文件
file, err := tfile.FromMediaMessage(gmedia, clientCtx.Raw, gmsg)
if err != nil {
logger.Errorf("Failed to create file from media: %v", err)
continue
}
files = append(files, file)
}
continue
}
}
// 单个文件 - 使用获取消息时使用的同一个 client context 创建文件
file, err := tfile.FromMediaMessage(media, clientCtx.Raw, msg)
if err != nil {
logger.Errorf("Failed to create file from media: %v", err)
continue
}
files = append(files, file)
}
if len(files) == 0 {
return nil, fmt.Errorf("no files found in provided links")
}
return files, nil
}
// isValidMessageLink 检查是否是有效的 Telegram 消息链接
func isValidMessageLink(link string) bool {
return strings.HasPrefix(link, "https://t.me/") || strings.HasPrefix(link, "http://t.me/")
}

80
api/tphpics.go Normal file
View File

@@ -0,0 +1,80 @@
package api
import (
"context"
"fmt"
"net/url"
"strings"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
)
// ExtractTelegraphImages 从 Telegraph URL 提取图片
func ExtractTelegraphImages(ctx context.Context, pageURL string) ([]string, string, error) {
logger := log.FromContext(ctx)
// 验证 URL 格式
if !isValidTelegraphURL(pageURL) {
return nil, "", fmt.Errorf("invalid telegraph URL format: %s", pageURL)
}
// 解析 URL 获取页面路径
pagepath, err := parseTelegraphPath(pageURL)
if err != nil {
return nil, "", err
}
logger.Debugf("Fetching telegraph page: %s", pagepath)
client := telegraph.NewClient()
page, err := client.GetPage(ctx, pagepath)
if err != nil {
return nil, "", fmt.Errorf("failed to get telegraph page: %w", err)
}
var imgs []string
for _, elem := range page.Content {
imgs = append(imgs, tphutil.GetNodeImages(elem)...)
}
if len(imgs) == 0 {
return nil, "", fmt.Errorf("no images found in telegraph page")
}
return imgs, pagepath, nil
}
// parseTelegraphPath 解析 Telegraph URL 获取页面路径
func parseTelegraphPath(pageURL string) (string, error) {
u, err := url.Parse(pageURL)
if err != nil {
return "", fmt.Errorf("invalid telegraph URL: %w", err)
}
if !strings.HasSuffix(u.Host, "telegra.ph") && !strings.HasSuffix(u.Host, "telegraph.co") {
return "", fmt.Errorf("invalid telegraph URL host: %s", u.Host)
}
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if len(paths) == 0 || paths[0] == "" {
return "", fmt.Errorf("invalid telegraph URL path: %s", u.Path)
}
pagepath := paths[len(paths)-1]
pagepath, err = url.PathUnescape(pagepath)
if err != nil {
return "", fmt.Errorf("failed to unescape telegraph path: %w", err)
}
return strings.TrimSpace(pagepath), nil
}
// isValidTelegraphURL 检查是否是有效的 Telegraph URL
func isValidTelegraphURL(url string) bool {
return strings.HasPrefix(url, "https://telegra.ph/") ||
strings.HasPrefix(url, "http://telegra.ph/") ||
strings.HasPrefix(url, "https://telegraph.co/") ||
strings.HasPrefix(url, "http://telegraph.co/")
}

161
api/types.go Normal file
View File

@@ -0,0 +1,161 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
)
// TaskStatus 表示任务状态
type TaskStatus string
const (
TaskStatusQueued TaskStatus = "queued"
TaskStatusRunning TaskStatus = "running"
TaskStatusCompleted TaskStatus = "completed"
TaskStatusFailed TaskStatus = "failed"
TaskStatusCancelled TaskStatus = "cancelled"
)
// CreateTaskRequest 创建任务请求
type CreateTaskRequest struct {
Type tasktype.TaskType `json:"type"`
Storage string `json:"storage"`
Path string `json:"path"`
Webhook string `json:"webhook,omitempty"`
Params json.RawMessage `json:"params"`
}
// CreateTaskResponse 创建任务响应
type CreateTaskResponse struct {
TaskID string `json:"task_id"`
Type tasktype.TaskType `json:"type"`
Status TaskStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// TaskProgress 任务进度
type TaskProgress struct {
TotalBytes int64 `json:"total_bytes,omitempty"`
DownloadedBytes int64 `json:"downloaded_bytes,omitempty"`
Percent float64 `json:"percent,omitempty"`
SpeedMBPS float64 `json:"speed_mbps,omitempty"`
}
// TaskInfoResponse 任务信息响应
type TaskInfoResponse struct {
TaskID string `json:"task_id"`
Type tasktype.TaskType `json:"type"`
Status TaskStatus `json:"status"`
Title string `json:"title"`
Progress *TaskProgress `json:"progress,omitempty"`
Storage string `json:"storage"`
Path string `json:"path"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// TasksListResponse 任务列表响应
type TasksListResponse struct {
Tasks []TaskInfoResponse `json:"tasks"`
Total int `json:"total"`
}
// StoragesResponse 存储列表响应
type StoragesResponse struct {
Storages []StorageInfo `json:"storages"`
}
// StorageInfo 存储信息
type StorageInfo struct {
Name string `json:"name"`
Type string `json:"type"`
}
// WebhookPayload Webhook 回调负载
type WebhookPayload struct {
TaskID string `json:"task_id"`
Type string `json:"type"`
Status TaskStatus `json:"status"`
Storage string `json:"storage"`
Path string `json:"path"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Error string `json:"error,omitempty"`
}
// ErrorResponse 错误响应
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message,omitempty"`
}
// APIError API 错误
type APIError struct {
StatusCode int
ErrorCode string
Message string
}
func (e *APIError) Error() string {
return e.Message
}
// WriteJSON 写入 JSON 响应
func WriteJSON(w http.ResponseWriter, statusCode int, data any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
return json.NewEncoder(w).Encode(data)
}
// WriteError 写入错误响应
func WriteError(w http.ResponseWriter, statusCode int, errCode, message string) error {
return WriteJSON(w, statusCode, ErrorResponse{
Error: errCode,
Message: message,
})
}
// Task 参数结构体
// DirectLinksParams directlinks 任务参数
type DirectLinksParams struct {
URLs []string `json:"urls"`
}
// YTDLPParams ytdlp 任务参数
type YTDLPParams struct {
URLs []string `json:"urls"`
Flags []string `json:"flags,omitempty"`
}
// Aria2Params aria2 任务参数
type Aria2Params struct {
URLs []string `json:"urls"`
Options map[string]string `json:"options,omitempty"`
}
// ParsedParams parsed 任务参数
type ParsedParams struct {
URL string `json:"url"`
}
// TransferParams transfer 任务参数
type TransferParams struct {
SourceStorage string `json:"source_storage"`
SourcePath string `json:"source_path"`
TargetStorage string `json:"target_storage"`
TargetPath string `json:"target_path"`
}
// TGFilesParams tgfiles 任务参数
type TGFilesParams struct {
MessageLinks []string `json:"message_links"`
}
// TPHPicsParams tphpics 任务参数
type TPHPicsParams struct {
TelegraphURL string `json:"telegraph_url"`
}

130
api/webhook.go Normal file
View File

@@ -0,0 +1,130 @@
package api
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/charmbracelet/log"
)
// webhookClient Webhook 客户端
var webhookClient = &http.Client{
Timeout: 30 * time.Second,
}
// SendWebhook 发送 Webhook 回调
func SendWebhook(ctx context.Context, payload *WebhookPayload) {
if payload == nil || payload.TaskID == "" {
return
}
// 获取任务信息以获取 webhook URL
info, ok := GetTask(payload.TaskID)
if !ok || info.Webhook == "" {
return
}
webhookURL := info.Webhook
// 异步发送 webhook
go func() {
logger := log.FromContext(ctx).With("task_id", payload.TaskID)
payloadBytes, err := json.Marshal(payload)
if err != nil {
logger.Errorf("Failed to marshal webhook payload: %v", err)
return
}
// 重试 3 次
for i := range 3 {
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
if err != nil {
logger.Errorf("Failed to create webhook request: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "SaveAny-Bot/1.0")
resp, err := webhookClient.Do(req)
if err != nil {
logger.Warnf("Webhook request failed (attempt %d/3): %v", i+1, err)
time.Sleep(time.Second * time.Duration(i+1))
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
logger.Debugf("Webhook sent successfully: %s", webhookURL)
return
}
logger.Warnf("Webhook returned non-2xx status (attempt %d/3): %d", i+1, resp.StatusCode)
time.Sleep(time.Second * time.Duration(i+1))
}
logger.Errorf("Failed to send webhook after 3 attempts")
}()
}
// CreateWebhookPayload 创建 Webhook 负载
func CreateWebhookPayload(taskID string, taskType string, status TaskStatus, storage, path string, err error) *WebhookPayload {
payload := &WebhookPayload{
TaskID: taskID,
Type: taskType,
Status: status,
Storage: storage,
Path: path,
}
if status == TaskStatusCompleted || status == TaskStatusFailed {
now := time.Now()
payload.CompletedAt = &now
}
if err != nil {
payload.Error = err.Error()
}
return payload
}
// WrapTaskWithWebhook 包装任务执行,添加 webhook 回调
func WrapTaskWithWebhook(ctx context.Context, taskID string, fn func() error) error {
info, ok := GetTask(taskID)
if !ok {
return fmt.Errorf("task not found: %s", taskID)
}
err := fn()
// 确定任务状态
status := TaskStatusCompleted
if err != nil {
if err == context.Canceled {
status = TaskStatusCancelled
} else {
status = TaskStatusFailed
}
}
// 更新任务状态
if err != nil {
info.SetError(err.Error())
} else {
info.UpdateStatus(TaskStatusCompleted)
}
// 发送 webhook
if info.Webhook != "" {
payload := CreateWebhookPayload(taskID, info.Type, status, info.Storage, info.Path, err)
SendWebhook(ctx, payload)
}
return err
}

View File

@@ -12,13 +12,20 @@ import (
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/ncruces/go-sqlite3/gormlite"
"github.com/krau/SaveAny-Bot/database"
)
var ectx *ext.Context
func ExtContext() *ext.Context {
return ectx
}
func Init(ctx context.Context) <-chan struct{} {
log.FromContext(ctx).Info("初始化 Bot...")
log.FromContext(ctx).Info("Initializing Bot...")
resultChan := make(chan struct {
client *gotgproto.Client
err error
@@ -39,7 +46,7 @@ func Init(ctx context.Context) <-chan struct{} {
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,
@@ -68,7 +75,7 @@ func Init(ctx context.Context) <-chan struct{} {
})
commands := make([]tg.BotCommand, 0, len(handlers.CommandHandlers))
for _, info := range handlers.CommandHandlers {
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: info.Desc})
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: i18n.T(info.Desc)})
}
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
Scope: &tg.BotCommandScopeDefault{},
@@ -82,13 +89,14 @@ func Init(ctx context.Context) <-chan struct{} {
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

@@ -12,6 +12,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/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"
@@ -33,12 +35,14 @@ 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 {
@@ -46,12 +50,14 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
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
@@ -61,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
@@ -82,6 +90,19 @@ func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
case tasktype.TaskTypeDirectlinks:
shortcut.CreateAndAddDirectTaskWithEdit(ctx, selectedStorage, dirPath, data.DirectLinks, msgID, userID)
case tasktype.TaskTypeAria2:
client := GetAria2Client()
if client == nil {
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgAria2ErrorAria2ClientInitFailed, map[string]any{
"Error": "aria2 client not initialized",
})))
return dispatcher.EndGroups
}
shortcut.CreateAndAddAria2TaskWithEdit(ctx, selectedStorage, dirPath, data.Aria2URIs, client, msgID, userID)
case tasktype.TaskTypeYtdlp:
shortcut.CreateAndAddYtdlpTaskWithEdit(ctx, selectedStorage, dirPath, data.YtdlpURLs, data.YtdlpFlags, msgID, userID)
case tasktype.TaskTypeTransfer:
return handleTransferCallback(ctx, userID, selectedStorage, dirPath, data, msgID)
default:
return fmt.Errorf("unexcept task type: %s", data.TaskType)
}

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

@@ -8,19 +8,22 @@ import (
"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"),
},
},
@@ -37,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
@@ -71,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
}
@@ -80,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),
})
}
@@ -96,8 +101,10 @@ 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
@@ -111,34 +118,27 @@ func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
}
args := strings.Fields(string(update.EffectiveMessage.Text))
if len(args) <= 1 {
text := `使用该命令设置文件名模板, 示例:
/fnametmpl 图片_{{.msgid}}_{{.msgdate}}.jpg
可用变量:
- {{.msgid}}: 消息ID
- {{.msgtags}}: 消息中的标签, 将以下划线分隔输出
- {{.msggen}}: 根据消息生成的文件名
- {{.msgdate}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
- {{.origname}}: 媒体的原始文件名 (如果有)
- {{.chatid}}: 消息的聊天ID
`
text := i18n.T(i18nk.BotMsgConfigFnametmplHelp, nil)
if user.FilenameTemplate != "" {
text += fmt.Sprintf("\n\n当前模板: %s", user.FilenameTemplate)
text += "\n\n" + i18n.T(i18nk.BotMsgConfigInfoCurrentTemplatePrefix, map[string]any{
"Template": user.FilenameTemplate,
})
}
text += "\n\n模板仅在文件名策略设置为 '自定义模板' 时生效, 且模板解析错误时会回退到默认文件名"
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("无效的模板, 请检查语法\n"+err.Error()), 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("已更新文件名模板"), nil)
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
}

View File

@@ -1,14 +1,18 @@
package handlers
import (
"fmt"
"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"
@@ -18,7 +22,7 @@ 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("用法: /dl <链接1> <链接2> ..."), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
return nil
}
links := args[1:]
@@ -32,7 +36,7 @@ func handleDlCmd(ctx *ext.Context, update *ext.Update) error {
}
links = slice.Compact(links)
if len(links) == 0 {
ctx.Reply(update, ext.ReplyTextString("没有有效的链接可供下载"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
return nil
}
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
@@ -42,7 +46,67 @@ func handleDlCmd(ctx *ext.Context, update *ext.Update) error {
if err != nil {
return err
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(links))), &ext.ReplyOpts{
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlInfoFilesSelectStorage, map[string]any{
"Count": len(links),
})), &ext.ReplyOpts{
Markup: markup,
})
return nil
}
var aria2ClientInitOnce sync.Once
var aria2ClientInitErr error
var aria2Client *aria2.Client
// GetAria2Client returns the shared aria2 client instance
func GetAria2Client() *aria2.Client {
return aria2Client
}
func handleAria2DlCmd(ctx *ext.Context, update *ext.Update) error {
if !config.C().Aria2.Enable {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAria2NotEnabled)), nil)
return nil
}
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
return nil
}
links := args[1:]
for i, link := range links {
links[i] = strings.TrimSpace(link)
}
links = slice.Compact(links)
if len(links) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
return nil
}
logger.Debug("Preparing aria2 download", "links", links)
// Initialize aria2 client to check connection
aria2ClientInitOnce.Do(func() {
aria2Client, aria2ClientInitErr = aria2.NewClient(config.C().Aria2.Url, config.C().Aria2.Secret)
})
if aria2ClientInitErr != nil {
logger.Error("Failed to initialize aria2 client", "error", aria2ClientInitErr)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2ErrorAria2ClientInitFailed, map[string]any{
"Error": aria2ClientInitErr.Error(),
})), nil)
return nil
}
// Build storage selection keyboard (don't add to aria2 yet)
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
TaskType: tasktype.TaskTypeAria2,
Aria2URIs: links,
})
if err != nil {
return err
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgAria2InfoSelectStorage)), &ext.ReplyOpts{
Markup: markup,
})
return nil

View File

@@ -1,14 +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"
)
@@ -24,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)
@@ -35,11 +37,15 @@ 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
}

View File

@@ -8,6 +8,8 @@ import (
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/storage"
)
@@ -34,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)

View File

@@ -1,7 +1,6 @@
package handlers
import (
"fmt"
"sync"
"time"
@@ -12,6 +11,8 @@ import (
"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"
@@ -89,7 +90,7 @@ func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
userId := update.GetUserChat().GetID()
msg, err := ctx.Reply(update, ext.ReplyTextString("正在保存文件..."), nil)
msg, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgMediaGroupInfoSavingFiles, nil)), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return
@@ -111,16 +112,20 @@ func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
AsBatch: len(items) > 1,
})
if err != nil {
logger.Errorf("构建存储选择键盘失败: %s", err)
logger.Errorf("Failed to build storage selection keyboard: %s", err)
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: "构建存储选择键盘失败: " + err.Error(),
ID: msg.ID,
Message: i18n.T(i18nk.BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed, map[string]any{
"Error": err.Error(),
}),
})
return
}
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: fmt.Sprintf("共 %d 个文件, 请选择存储位置", len(items)),
ID: msg.ID,
Message: i18n.T(i18nk.BotMsgMediaGroupInfoGroupFoundFilesSelectStorage, map[string]any{
"Count": len(items),
}),
ReplyMarkup: markup,
})
}

View File

@@ -5,6 +5,8 @@ import (
"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"
@@ -13,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
}
@@ -29,25 +27,31 @@ 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("获取默认文件夹失败: "+err.Error()), 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)

View File

@@ -14,6 +14,8 @@ import (
"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"
@@ -29,22 +31,40 @@ func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
if len(entityUrls) > 0 {
text += "\n" + strings.Join(entityUrls, "\n")
}
ok, pser := parsers.CanHandle(text)
// 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)
@@ -55,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{
@@ -87,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)
@@ -95,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{

View File

@@ -7,17 +7,15 @@ import (
"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 := `
用法:
/parser install <回复一个文件> - 安装解析器
`
help := i18n.T(i18nk.BotMsgParserHelpText, nil)
if len(args) < 2 {
ctx.Reply(u, ext.ReplyTextString(help), nil)
return nil
@@ -36,35 +34,35 @@ func handleParserCmd(ctx *ext.Context, u *ext.Update) error {
func handleParserInstallCmd(ctx *ext.Context, u *ext.Update) error {
if !config.C().Parser.PluginEnable {
ctx.Reply(u, ext.ReplyTextString("解析器插件功能未启用"), nil)
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("请回复一个包含解析器文件的消息"), 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("回复的消息不包含有效的文件"), nil)
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("回复的消息不包含有效的文件"), nil)
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("回复的消息不包含有效的文件"), nil)
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("错误的文件类型"), nil)
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("文件过大"), nil)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorFileTooLarge, nil)), nil)
return dispatcher.EndGroups
}
var fileName string
@@ -75,23 +73,29 @@ func handleParserInstallCmd(ctx *ext.Context, u *ext.Update) error {
}
}
if fileName == "" {
ctx.Reply(u, ext.ReplyTextString("无法获取文件名"), nil)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorGetFilenameFailed, nil)), nil)
return dispatcher.EndGroups
}
if !strings.HasSuffix(fileName, ".js") {
ctx.Reply(u, ext.ReplyTextString("仅支持 .js 文件作为解析器"), nil)
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("文件下载失败: "+err.Error()), 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("插件安装失败: "+err.Error()), 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("插件安装成功: "+fileName), nil)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserInfoInstallPluginSuccess, map[string]any{
"Name": fileName,
})), nil)
return dispatcher.EndGroups
}

View File

@@ -10,32 +10,39 @@ import (
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
type DescCommandHandler struct {
Cmd string
Desc string
Desc i18nk.Key
handler func(ctx *ext.Context, u *ext.Update) error
}
var CommandHandlers = []DescCommandHandler{
{"start", "开始使用", handleHelpCmd},
{"silent", "切换静默模式", handleSilentCmd},
{"storage", "设置默认存储端", handleStorageCmd},
{"dir", "管理存储文件夹", handleDirCmd},
{"rule", "管理自动存储规则", handleRuleCmd},
{"save", "保存文件", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
{"dl", "下载给定链接的文件", handleDlCmd},
{"watch", "监听聊天(UserBot)", handleWatchCmd},
{"unwatch", "取消监听聊天(UserBot)", handleUnwatchCmd},
{"lswatch", "列出监听的聊天(UserBot)", handleLswatchCmd},
{"config", "修改配置", handleConfigCmd},
{"fnametmpl", "设置文件命名模板", handleConfigFnameTmpl},
{"help", "显示帮助", handleHelpCmd},
{"parser", "管理解析器", handleParserCmd},
{"update", "检查更新", handleUpdateCmd},
{"start", i18nk.BotMsgCmdStart, handleHelpCmd},
{"silent", i18nk.BotMsgCmdSilent, handleSilentCmd},
{"storage", i18nk.BotMsgCmdStorage, handleStorageCmd},
{"dir", i18nk.BotMsgCmdDir, handleDirCmd},
{"rule", i18nk.BotMsgCmdRule, handleRuleCmd},
{"save", i18nk.BotMsgCmdSave, handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
{"dl", i18nk.BotMsgCmdDl, handleDlCmd},
{"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd},
{"ytdlp", i18nk.BotMsgCmdYtdlp, handleYtdlpCmd},
{"transfer", i18nk.BotMsgCmdTransfer, handleTransferCmd},
{"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) {

View File

@@ -10,6 +10,8 @@ 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"
@@ -21,8 +23,8 @@ func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
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 {
@@ -34,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 {
@@ -52,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
}
@@ -71,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,18 +1,19 @@
package handlers
import (
"fmt"
"regexp"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/validator"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
@@ -52,8 +53,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)
@@ -97,35 +98,40 @@ 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)
tctx := ctx
uctx := user.GetCtx()
if uctx != nil && validator.IsIntStr(chatArg) {
tctx = uctx
}
chatID, err := tgutil.ParseChatID(tctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的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
msgs, err := tgutil.GetMessagesRange(ctx, chatID, int(startID), int(endID))
msgs, err := tgutil.GetMessagesRange(tctx, 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))
@@ -142,9 +148,9 @@ func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error
if !supported {
continue
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
file, err := tfile.FromMediaMessage(media, tctx.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 {
@@ -160,7 +166,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)
@@ -171,16 +177,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(),
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)),
Message: i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{"Count": len(files)}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups

View File

@@ -1,7 +1,6 @@
package handlers
import (
"fmt"
"strings"
"github.com/celestix/gotgproto/dispatcher"
@@ -9,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"
@@ -17,20 +18,27 @@ 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
}
@@ -49,18 +57,22 @@ func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
}
if !ok {
return failedAnswer("数据已过期")
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorDataExpired, nil))
}
userID := update.CallbackQuery.GetUserID()
storageName := data.StorageName
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
if err != nil {
return failedAnswer("存储获取失败: " + err.Error())
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
}))
}
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return failedAnswer("获取用户信息失败: " + err.Error())
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
"Error": err.Error(),
}))
}
var dir *database.Dir
if data.DirID != 0 {
@@ -68,24 +80,28 @@ func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
var err error
dir, err = database.GetDirByID(ctx, data.DirID)
if err != nil {
return failedAnswer("获取文件夹信息失败: " + err.Error())
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("获取目录失败: " + err.Error())
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("构建目录选择失败: " + err.Error())
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorBuildDirSelectKeyboardFailed, map[string]any{
"Error": err.Error(),
}))
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "请选择要保存到的默认文件夹",
Message: i18n.T(i18nk.BotMsgCommonPromptSelectDefaultDir, nil),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
@@ -93,11 +109,18 @@ func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
}
user.DefaultStorage = selectedStorage.Name()
if err := database.UpdateUser(ctx, user); err != nil {
return failedAnswer("更新用户信息失败: " + err.Error())
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorUpdateUserInfoFailed, map[string]any{
"Error": err.Error(),
}))
}
msg := fmt.Sprintf("已将默认存储位置设为: %s", selectedStorage.Name())
msg := i18n.T(i18nk.BotMsgCommonInfoDefaultStorageSet, map[string]any{
"Name": selectedStorage.Name(),
})
if dir != nil {
msg += fmt.Sprintf(":/%s", strings.TrimPrefix(dir.Path, "/"))
msg = i18n.T(i18nk.BotMsgCommonInfoDefaultStorageWithDirSet, map[string]any{
"Name": selectedStorage.Name(),
"Dir": strings.TrimPrefix(dir.Path, "/"),
})
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
@@ -110,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, 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

@@ -13,6 +13,8 @@ import (
"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"
@@ -34,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

View File

@@ -0,0 +1,257 @@
package handlers
import (
"fmt"
"regexp"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/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/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/transfer"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/storagetypes"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func handleTransferCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferUsage, nil)), nil)
return dispatcher.EndGroups
}
// Parse source: storage_name:/path
sourceParts := strings.SplitN(args[1], ":", 2)
if len(sourceParts) != 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferErrorInvalidSource, nil)), nil)
return dispatcher.EndGroups
}
sourceStorageName := sourceParts[0]
sourcePath := sourceParts[1]
userID := update.GetUserChat().GetID()
// Get source storage
sourceStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, sourceStorageName)
if err != nil {
logger.Errorf("Failed to get source storage by user ID and name: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferErrorStorageNotFound, map[string]any{
"StorageName": sourceStorageName,
"Error": err,
})), nil)
return dispatcher.EndGroups
}
// Check if source storage supports listing
listable, ok := sourceStorage.(storage.StorageListable)
if !ok {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferErrorStorageNotListable, map[string]any{
"StorageName": sourceStorageName,
})), nil)
return dispatcher.EndGroups
}
// Check if source storage supports reading
_, ok = sourceStorage.(storage.StorageReadable)
if !ok {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferErrorStorageNotReadable, map[string]any{
"StorageName": sourceStorageName,
})), nil)
return dispatcher.EndGroups
}
// Fetch file list
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTransferInfoFetchingFiles, nil)), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return dispatcher.EndGroups
}
files, err := listable.ListFiles(ctx, sourcePath)
if err != nil {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferErrorListFilesFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
// Optional filter
var filter *regexp.Regexp
if len(args) >= 3 {
filter, err = regexp.Compile(args[2])
if err != nil {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferErrorInvalidRegex, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
}
// Filter files
filteredFiles := make([]storagetypes.FileInfo, 0)
for _, file := range files {
if file.IsDir {
continue
}
if filter != nil && !filter.MatchString(file.Name) {
continue
}
filteredFiles = append(filteredFiles, file)
}
if len(filteredFiles) == 0 {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferErrorNoFilesToTransfer, nil),
})
return dispatcher.EndGroups
}
// Prepare file paths for callback data
filePaths := make([]string, 0, len(filteredFiles))
var totalSize int64
for _, file := range filteredFiles {
filePaths = append(filePaths, file.Path)
totalSize += file.Size
}
// Build storage selection keyboard
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
TaskType: tasktype.TaskTypeTransfer,
TransferSourceStorName: sourceStorageName,
TransferSourcePath: sourcePath,
TransferFiles: filePaths,
})
if err != nil {
logger.Errorf("Failed to build storage selection keyboard: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferErrorBuildStorageSelectKeyboardFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgTransferInfoFilesSelectStorage, map[string]any{
"Count": len(filteredFiles),
"SizeMB": fmt.Sprintf("%.2f", float64(totalSize)/(1024*1024)),
}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func handleTransferCallback(ctx *ext.Context, userID int64, targetStorage storage.Storage, dirPath string, data tcbdata.Add, msgID int) error {
logger := log.FromContext(ctx)
// Get source storage
sourceStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.TransferSourceStorName)
if err != nil {
logger.Errorf("Failed to get source storage: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorStorageNotFound, map[string]any{"StorageName": data.TransferSourceStorName, "Error": err}),
})
return dispatcher.EndGroups
}
// Check if source storage supports listing
listable, ok := sourceStorage.(storage.StorageListable)
if !ok {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorStorageNotListable, map[string]any{"StorageName": data.TransferSourceStorName}),
})
return dispatcher.EndGroups
}
// Re-fetch files to get FileInfo (since we only stored paths)
// This is necessary to get size and other metadata
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferInfoFetchingFiles, nil),
})
allFiles, err := listable.ListFiles(ctx, data.TransferSourcePath)
if err != nil {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorListFilesFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
// Create a map for quick lookup
fileMap := make(map[string]storagetypes.FileInfo)
for _, file := range allFiles {
fileMap[file.Path] = file
}
// Build task elements for the selected files
elems := make([]transfer.TaskElement, 0, len(data.TransferFiles))
var totalSize int64
for _, filePath := range data.TransferFiles {
fileInfo, ok := fileMap[filePath]
if !ok {
logger.Warnf("File not found in source storage: %s", filePath)
continue
}
elem := transfer.NewTaskElement(sourceStorage, fileInfo, targetStorage, dirPath)
elems = append(elems, *elem)
totalSize += fileInfo.Size
}
if len(elems) == 0 {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorNoFilesToTransfer, nil),
})
return dispatcher.EndGroups
}
// Create and add task
taskID := xid.New().String()
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := transfer.NewTransferTask(
taskID,
injectCtx,
elems,
transfer.NewProgressTracker(msgID, userID),
true, // IgnoreErrors
)
if err := core.AddTask(injectCtx, task); err != nil {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferErrorAddTaskFailed, map[string]any{"Error": err}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgTransferInfoTaskAdded, map[string]any{
"Count": len(elems),
"SizeMB": fmt.Sprintf("%.2f", float64(totalSize)/(1024*1024)),
"TaskID": taskID,
}),
})
return dispatcher.EndGroups
}

View File

@@ -2,7 +2,6 @@ package handlers
import (
"errors"
"fmt"
"regexp"
"strings"
@@ -11,6 +10,8 @@ import (
"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"
)
@@ -18,26 +19,36 @@ import (
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
currentV, err := semver.Parse(config.Version)
if err != nil {
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("You are in dev or the version var failed to inject: %v", 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(fmt.Sprintf("检测最新版本失败: %v", 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("没有找到版本信息"), nil)
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(fmt.Sprintf("检测到大版本更新: %s -> %s , 请前往 GitHub 手动下载最新版本并查看迁移指南", currentV, latest.Version)), nil)
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(fmt.Sprintf("当前已经是最新版本: %s", config.Version)), nil)
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>")
@@ -53,24 +64,29 @@ func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
return `<blockquote expandable>` + md + `</blockquote>`
}()))
text := fmt.Sprintf(`发现新版本: %s
当前版本: %s
文件大小: %.2f MB
下载链接: %s
发布时间: %s
升级将重启 Bot , 是否升级?`, latest.Version, config.Version,
float64(latest.AssetByteSize)/(1024*1024), latest.AssetURL,
latest.PublishedAt.Format("2006-01-02 15:04:05"),
)
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: "升级",
Text: i18n.T(i18nk.BotMsgUpdateButtonUpgrade, nil),
Data: []byte("update"),
},
},
@@ -87,20 +103,26 @@ func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
return err
}
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
ID: u.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("正在升级中, 当前版本: %s", config.Version),
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: fmt.Sprintf("升级失败: %v", err),
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: fmt.Sprintf("已升级至版本 %s\n若 Bot 未自动重启请手动启动", latest.Version),
ID: u.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgUpdateInfoUpgradeSuccess, map[string]any{
"Version": latest.Version.String(),
}),
})
return errors.New("SAVEANTBOT-RESTART")
}

View File

@@ -30,6 +30,7 @@ type FilenameTemplateData struct {
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"`
}
@@ -39,6 +40,7 @@ func (f FilenameTemplateData) ToMap() map[string]string {
"msgid": f.MsgID,
"msgtags": f.MsgTags,
"msggen": f.MsgGen,
"msgraw": f.MsgRaw,
"msgdate": f.MsgDate,
"origname": f.OrigName,
"chatid": f.ChatID,
@@ -108,8 +110,10 @@ func BuildFilenameTemplateData(message *tg.Message) map[string]string {
t := time.Unix(int64(date), 0)
return t.Format("2006-01-02_15-04-05")
}(),
MsgRaw: message.GetMessage(),
ChatID: func() string {
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id, 无论它是否是从其他来源转发的
// 如果消息是频道的(从消息链接中fetch的) 直接使用其chat id,
// 无论它是否是从其他来源转发的
if message.GetPost() {
peer := message.GetPeerID()
switch p := peer.(type) {

View File

@@ -5,26 +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 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 {
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
fmt.Fprintf(&sb, "%d: ", dir.ID)
sb.WriteString(dir.StorageName)
sb.WriteString(" - ")
sb.WriteString(dir.Path)

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

@@ -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"
@@ -47,6 +49,14 @@ func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add)
ParsedItem: adddata.ParsedItem,
DirectLinks: adddata.DirectLinks,
Aria2URIs: adddata.Aria2URIs,
YtdlpURLs: adddata.YtdlpURLs,
YtdlpFlags: adddata.YtdlpFlags,
TransferSourceStorName: adddata.TransferSourceStorName,
TransferSourcePath: adddata.TransferSourcePath,
TransferFiles: adddata.TransferFiles,
}
dataid := xid.New().String()
err := cache.Set(dataid, data)
@@ -70,11 +80,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 {
@@ -185,7 +198,7 @@ func BuildSetDirMarkupForAdd(dirs []database.Dir, dataid string) (*tg.ReplyInlin
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

@@ -0,0 +1,65 @@
package shortcut
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/aria2dl"
"github.com/krau/SaveAny-Bot/pkg/aria2"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func CreateAndAddAria2TaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, uris []string, aria2Client *aria2.Client, msgID int, userID int64) error {
logger := log.FromContext(ctx)
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
// Now add to aria2 after user selected storage
logger.Infof("Adding download to aria2, uris type: %T, value: %+v", uris, uris)
// Ensure uris is valid
if len(uris) == 0 {
logger.Error("URIs list is empty")
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgDlErrorNoValidLinks, nil),
})
return dispatcher.EndGroups
}
gid, err := aria2Client.AddURI(ctx, uris, nil)
if err != nil {
logger.Errorf("Failed to add aria2 download: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgAria2ErrorAddingAria2Download, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
logger.Infof("Aria2 download added with GID: %s", gid)
// Create task with the GID
task := aria2dl.NewTask(xid.New().String(), injectCtx, gid, uris, aria2Client, stor, dirPath, aria2dl.NewProgress(msgID, userID))
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
})
return dispatcher.EndGroups
}

View File

@@ -5,6 +5,8 @@ import (
"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"
@@ -14,17 +16,19 @@ import (
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))
task := directlinks.NewTask(xid.New().String(), injectCtx, links, stor, 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: "任务添加失败: " + err.Error(),
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
Message: "任务已添加",
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
})
return dispatcher.EndGroups
}

View File

@@ -17,6 +17,8 @@ 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"
@@ -36,7 +38,7 @@ 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
@@ -52,7 +54,9 @@ func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, 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
@@ -68,7 +72,7 @@ func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Up
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
@@ -85,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))
@@ -110,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 {
@@ -144,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
@@ -155,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
}
@@ -180,11 +188,13 @@ 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
}
tphdir = strings.TrimSpace(tphdir)
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取 telegraph 页面..."), nil)
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
@@ -193,7 +203,9 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
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)
@@ -227,7 +239,7 @@ func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*type
}
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"
@@ -16,12 +18,14 @@ import (
func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, item *parser.Item, msgID int, userID int64) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := parsed.NewTask(xid.New().String(), injectCtx, stor, stor.JoinStoragePath(dirPath), item, parsed.NewProgress(msgID, userID))
task := parsed.NewTask(xid.New().String(), injectCtx, stor, dirPath, item, parsed.NewProgress(msgID, userID))
if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: "任务添加失败: " + err.Error(),
ID: msgID,
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"
@@ -28,8 +29,10 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
if err != nil {
logger.Errorf("Failed to get user by chat ID: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取用户失败: " + err.Error(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -46,15 +49,17 @@ func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage
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(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
}
}
startCreateTask:
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
storagePath := path.Join(dirPath, file.Name())
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
@@ -64,16 +69,20 @@ startCreateTask:
if err != nil {
logger.Errorf("create task failed: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "创建任务失败: " + err.Error(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("add task failed: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "添加任务失败: " + err.Error(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -94,8 +103,10 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
if err != nil {
logger.Errorf("Failed to get user by chat ID: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "获取用户失败: " + err.Error(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -131,20 +142,24 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
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(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
}
if !dirPath.NeedNewForAlbum() {
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
storPath := path.Join(dirPath.String(), file.Name())
elem, err := batchtfile.NewTaskElement(fileStor, storPath, file)
if err != nil {
logger.Errorf("Failed to create task element: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务创建失败: " + err.Error(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -173,13 +188,15 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
albumStor := afiles[0].storage
for _, af := range afiles {
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
afstorPath := path.Join(dirPath, albumDir, af.file.Name())
elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file)
if err != nil {
logger.Errorf("Failed to create task element for album file: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务创建失败: " + err.Error(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
@@ -193,14 +210,18 @@ func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor st
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add batch task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "批量任务添加失败: " + err.Error(),
ID: trackMsgID,
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)),
ID: trackMsgID,
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"
@@ -23,22 +25,24 @@ func CreateAndAddtelegraphWithEdit(
pics []string,
stor storage.Storage,
trackMsgID int) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := tphtask.NewTask(xid.New().String(),
injectCtx,
tphpage.Path,
pics,
stor,
stor.JoinStoragePath(dirPath),
dirPath,
tphutil.DefaultClient(),
tphtask.NewProgress(trackMsgID, userID),
)
if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务添加失败: " + err.Error(),
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,63 @@
package shortcut
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/rs/xid"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/ytdlp"
"github.com/krau/SaveAny-Bot/storage"
)
func CreateAndAddYtdlpTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, urls []string, flags []string, msgID int, userID int64) error {
logger := log.FromContext(ctx)
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
// Validate URLs
if len(urls) == 0 {
logger.Error("URLs list is empty")
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgYtdlpErrorNoValidUrls, nil),
})
return dispatcher.EndGroups
}
logger.Infof("Creating yt-dlp task for %d URL(s) with %d flag(s)", len(urls), len(flags))
// Create yt-dlp task
task := ytdlp.NewTask(
xid.New().String(),
injectCtx,
urls,
flags,
stor,
dirPath,
ytdlp.NewProgress(msgID, userID),
)
// Add task to queue
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add yt-dlp task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
})
return dispatcher.EndGroups
}

View File

@@ -5,7 +5,9 @@ import (
"path"
"regexp"
"strings"
"sync"
"text/template"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
@@ -16,10 +18,12 @@ import (
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
coretfile "github.com/krau/SaveAny-Bot/core/tasks/tfile"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
@@ -34,18 +38,18 @@ func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
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)
@@ -54,7 +58,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 := ""
@@ -63,19 +67,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
}
}
@@ -85,10 +89,10 @@ 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
}
@@ -97,22 +101,22 @@ func handleLswatchCmd(ctx *ext.Context, update *ext.Update) error {
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
}
chats := user.WatchChats
if len(chats) == 0 {
ctx.Reply(update, ext.ReplyTextString("当前没有监听任何聊天"), nil)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchListEmpty)), nil)
return dispatcher.EndGroups
}
var sb strings.Builder
sb.WriteString("当前监听的聊天:\n")
sb.WriteString(i18n.T(i18nk.BotMsgWatchInfoWatchListHeader))
for _, chat := range chats {
sb.WriteString("- ")
sb.WriteString(fmt.Sprintf("%d", chat.ChatID))
if chat.Filter != "" {
sb.WriteString(" (过滤器: ")
sb.WriteString(i18n.T(i18nk.BotMsgWatchInfoWatchListFilterPrefix))
sb.WriteString(chat.Filter)
sb.WriteString(")")
}
@@ -126,32 +130,82 @@ 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
}
type watchMediaGroupHandler struct {
groups map[int64]map[uint][]tfile.TGFileMessage // chatID -> userID -> files
timers map[int64]map[uint]*time.Timer
mu sync.Mutex
}
var watchMediaGroupMgr = &watchMediaGroupHandler{
groups: make(map[int64]map[uint][]tfile.TGFileMessage),
timers: make(map[int64]map[uint]*time.Timer),
}
func (w *watchMediaGroupHandler) addFile(chatID int64, userID uint, file tfile.TGFileMessage, timeout time.Duration, callback func([]tfile.TGFileMessage)) {
w.mu.Lock()
defer w.mu.Unlock()
if w.groups[chatID] == nil {
w.groups[chatID] = make(map[uint][]tfile.TGFileMessage)
}
if w.timers[chatID] == nil {
w.timers[chatID] = make(map[uint]*time.Timer)
}
if timer, exists := w.timers[chatID][userID]; exists {
timer.Stop()
}
w.groups[chatID][userID] = append(w.groups[chatID][userID], file)
w.timers[chatID][userID] = time.AfterFunc(timeout, func() {
w.mu.Lock()
files := w.groups[chatID][userID]
delete(w.groups[chatID], userID)
delete(w.timers[chatID], userID)
if len(w.groups[chatID]) == 0 {
delete(w.groups, chatID)
}
if len(w.timers[chatID]) == 0 {
delete(w.timers, chatID)
}
w.mu.Unlock()
if len(files) > 0 {
callback(files)
}
})
}
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
if userclient.GetCtx() == nil {
return
}
logger := log.FromContext(userclient.GetCtx())
for event := range ch {
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
@@ -198,6 +252,16 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
continue
}
// Resolve the default directory path from user.DefaultDir
var defaultDirPath string
if user.DefaultDir != 0 {
dir, err := database.GetDirByID(ctx, user.DefaultDir)
if err != nil {
logger.Warnf("Failed to get default dir for user %d: %v, using root", user.ChatID, err)
} else {
defaultDirPath = dir.Path
}
}
switch user.FilenameStrategy {
case fnamest.Message.String():
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
@@ -221,7 +285,25 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
}
file.SetName(sb.String())
}
var dirPath string
// Check if this is a media group and if rules specify NEW-FOR-ALBUM
groupID, isGroup := file.Message().GetGroupedID()
needAlbumHandling := false
if isGroup && groupID != 0 && user.ApplyRule && user.Rules != nil {
_, _, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
needAlbumHandling = matchedDirPath.NeedNewForAlbum()
}
if needAlbumHandling {
// For media groups with NEW-FOR-ALBUM rule, collect all files of the same group
watchMediaGroupMgr.addFile(event.ChatID, user.ID, file, time.Duration(max(config.C().Telegram.MediaGroupTimeout, 1))*time.Second, func(files []tfile.TGFileMessage) {
processWatchMediaGroup(ctx, user, stor, defaultDirPath, files)
})
continue
}
// Process single file or media group without album folder creation
dirPath := defaultDirPath
if user.ApplyRule && user.Rules != nil {
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
@@ -237,10 +319,10 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
}
}
startCreateTask:
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
storagePath := 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)
task, err := coretfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
if err != nil {
logger.Errorf("create task failed: %s", err)
continue
@@ -253,3 +335,101 @@ func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
}
}
}
func processWatchMediaGroup(ctx *ext.Context, user *database.User, stor storage.Storage, dirPath string, files []tfile.TGFileMessage) {
logger := log.FromContext(ctx)
if len(files) == 0 {
return
}
useRule := user.ApplyRule && user.Rules != nil
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
if !useRule {
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
storname := storName.String()
if !storName.Usable() {
storname = stor.Name()
}
return storname, dirP
}
type albumFile struct {
file tfile.TGFileMessage
storage storage.Storage
dirPath string
}
albumFiles := make(map[int64][]albumFile)
// Collect files by group ID
for _, file := range files {
storName, ruleDirPath := applyRule(file)
fileStor := stor
if storName != stor.Name() && storName != "" {
var err error
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
continue
}
}
groupId, isGroup := file.Message().GetGroupedID()
if !isGroup || groupId == 0 {
logger.Warnf("File %s is not in a group, skipping", file.Name())
continue
}
// Use the effective dirPath: if rule returns NEW-FOR-ALBUM sentinel, fall back to the
// base dirPath passed in (which is defaultDirPath from the caller).
effectiveDirPath := string(ruleDirPath)
if ruleDirPath.NeedNewForAlbum() {
effectiveDirPath = dirPath
}
if _, ok := albumFiles[groupId]; !ok {
albumFiles[groupId] = make([]albumFile, 0)
}
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
file: file,
storage: fileStor,
dirPath: effectiveDirPath,
})
}
// Process album files with folder creation
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
totalTasks := 0
for groupID, afiles := range albumFiles {
if len(afiles) <= 1 {
continue
}
// Use first file's name (without extension) as album folder name
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
albumStor := afiles[0].storage
logger.Infof("Creating album folder for group %d: %s with %d files", groupID, albumDir, len(afiles))
for _, af := range afiles {
afstorPath := path.Join(af.dirPath, albumDir, af.file.Name())
taskid := xid.New().String()
task, err := coretfile.NewTGFileTask(taskid, injectCtx, af.file, albumStor, afstorPath, nil)
if err != nil {
logger.Errorf("create task failed for album file: %s", err)
continue
}
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("add task failed: %s", err)
continue
}
totalTasks++
}
}
logger.Infof("Added %d watch media tasks for user %d", totalTasks, user.ChatID)
}

View File

@@ -0,0 +1,92 @@
package handlers
import (
"net/url"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleYtdlpCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpUsage)), nil)
return dispatcher.EndGroups
}
// Separate URLs and flags from arguments
var urls []string
var flags []string
for i := 1; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
if arg == "" {
continue
}
// Check if it's a flag (starts with - or --)
if strings.HasPrefix(arg, "-") {
flags = append(flags, arg)
// Check if the next argument might be a value for this flag
// Don't consume it if it starts with - or looks like a URL with scheme
if i+1 < len(args) {
nextArg := strings.TrimSpace(args[i+1])
if nextArg != "" && !strings.HasPrefix(nextArg, "-") {
// Check if it's clearly a URL (has ://)
// This handles common video URLs (http://, https://)
// For other yt-dlp inputs, users should ensure proper formatting
if strings.Contains(nextArg, "://") {
// It's a URL, don't consume it as a flag value
continue
}
// Otherwise, treat it as a flag value
flags = append(flags, nextArg)
i++ // Skip the next argument as it's been consumed
}
}
} else {
// Try to parse as URL
u, err := url.Parse(arg)
if err != nil || u.Scheme == "" || u.Host == "" {
logger.Warnf("Invalid URL: %s", arg)
continue
}
urls = append(urls, arg)
}
}
if len(urls) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpErrorNoValidUrls)), nil)
return dispatcher.EndGroups
}
logger.Debugf("Preparing yt-dlp download for %d URL(s) with %d flag(s)", len(urls), len(flags))
// Build storage selection keyboard
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
TaskType: tasktype.TaskTypeYtdlp,
YtdlpURLs: urls,
YtdlpFlags: flags,
})
if err != nil {
return err
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgYtdlpInfoUrlsSelectStorage, map[string]any{
"Count": len(urls),
})), &ext.ReplyOpts{
Markup: markup,
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,129 @@
package handlers
import (
"net/url"
"strings"
"testing"
)
// TestYtdlpArgumentParsing tests the URL and flag separation logic
func TestYtdlpArgumentParsing(t *testing.T) {
tests := []struct {
name string
input string
expectedURLs []string
expectedFlags []string
}{
{
name: "Single URL without flags",
input: "/ytdlp https://example.com/video",
expectedURLs: []string{"https://example.com/video"},
expectedFlags: []string{},
},
{
name: "Multiple URLs without flags",
input: "/ytdlp https://example.com/v1 https://example.com/v2",
expectedURLs: []string{"https://example.com/v1", "https://example.com/v2"},
expectedFlags: []string{},
},
{
name: "URL with format flag",
input: "/ytdlp --format best https://example.com/video",
expectedURLs: []string{"https://example.com/video"},
expectedFlags: []string{"--format", "best"},
},
{
name: "URL with extract-audio flag",
input: "/ytdlp --extract-audio --audio-format mp3 https://example.com/video",
expectedURLs: []string{"https://example.com/video"},
expectedFlags: []string{"--extract-audio", "--audio-format", "mp3"},
},
{
name: "Multiple URLs with flags",
input: "/ytdlp --format best https://example.com/v1 https://example.com/v2",
expectedURLs: []string{"https://example.com/v1", "https://example.com/v2"},
expectedFlags: []string{"--format", "best"},
},
{
name: "Flags mixed with URLs",
input: "/ytdlp https://example.com/v1 --format best https://example.com/v2",
expectedURLs: []string{"https://example.com/v1", "https://example.com/v2"},
expectedFlags: []string{"--format", "best"},
},
{
name: "Short flag",
input: "/ytdlp -f best https://example.com/video",
expectedURLs: []string{"https://example.com/video"},
expectedFlags: []string{"-f", "best"},
},
{
name: "Boolean flag",
input: "/ytdlp --extract-audio https://example.com/video",
expectedURLs: []string{"https://example.com/video"},
expectedFlags: []string{"--extract-audio"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args := strings.Split(tt.input, " ")
// Simulate the parsing logic from handleYtdlpCmd
var urls []string
var flags []string
for i := 1; i < len(args); i++ {
arg := strings.TrimSpace(args[i])
if arg == "" {
continue
}
// Check if it's a flag (starts with - or --)
if strings.HasPrefix(arg, "-") {
flags = append(flags, arg)
// Check if the next argument might be a value for this flag
if i+1 < len(args) {
nextArg := strings.TrimSpace(args[i+1])
if nextArg != "" && !strings.HasPrefix(nextArg, "-") {
// Check if it's clearly a URL (has ://)
if strings.Contains(nextArg, "://") {
// It's a URL, don't consume it as a flag value
continue
}
// Otherwise, treat it as a flag value
flags = append(flags, nextArg)
i++ // Skip the next argument as it's been consumed
}
}
} else {
// Try to parse as URL
u, err := url.Parse(arg)
if err != nil || u.Scheme == "" || u.Host == "" {
continue
}
urls = append(urls, arg)
}
}
// Verify URLs
if len(urls) != len(tt.expectedURLs) {
t.Errorf("Expected %d URLs, got %d", len(tt.expectedURLs), len(urls))
}
for i, expectedURL := range tt.expectedURLs {
if i >= len(urls) || urls[i] != expectedURL {
t.Errorf("Expected URL[%d] to be '%s', got '%s'", i, expectedURL, urls[i])
}
}
// Verify flags
if len(flags) != len(tt.expectedFlags) {
t.Errorf("Expected %d flags, got %d", len(tt.expectedFlags), len(flags))
}
for i, expectedFlag := range tt.expectedFlags {
if i >= len(flags) || flags[i] != expectedFlag {
t.Errorf("Expected flag[%d] to be '%s', got '%s'", i, expectedFlag, flags[i])
}
}
})
}
}

View File

@@ -17,30 +17,22 @@ import (
"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"
)
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 {
@@ -64,7 +56,7 @@ func Login(ctx context.Context) (*gotgproto.Client, error) {
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,8 +3,10 @@ package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"go/format"
"io/fs"
"os"
"path/filepath"
@@ -35,7 +37,7 @@ func main() {
return err
}
var content map[string]interface{}
var content map[string]any
if err := yaml.Unmarshal(data, &content); err != nil {
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
}
@@ -54,14 +56,9 @@ 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)
// 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")
@@ -72,16 +69,29 @@ func main() {
}
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]interface{}, prefix string, keys map[string]struct{}) {
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]interface{}:
case map[string]any:
collectKeys(val, fullKey, keys)
default:
keys[fullKey] = struct{}{}

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

@@ -2,19 +2,19 @@ package cmd
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"slices"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/api"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
@@ -27,13 +27,26 @@ import (
func Run(cmd *cobra.Command, _ []string) {
ctx, cancel := context.WithCancel(cmd.Context())
logger := log.NewWithOptions(os.Stdout, log.Options{
Level: log.DebugLevel,
Level: log.InfoLevel,
ReportTimestamp: true,
TimeFormat: time.TimeOnly,
ReportCaller: true,
})
log.SetDefault(logger)
ctx = log.WithContext(ctx, logger)
configFile := config.GetConfigFile(cmd)
if err := config.Init(ctx, configFile); err != nil {
logger.Fatal("Init failed", "error", err)
}
level, err := log.ParseLevel(strings.TrimSpace(config.C().Log.Level))
if err != nil {
logger.Warn("Invalid log level, fallback to debug", "level", config.C().Log.Level, "error", err)
level = log.DebugLevel
}
logger.SetLevel(level)
exitChan, err := initAll(ctx)
if err != nil {
logger.Fatal("Init failed", "error", err)
@@ -46,38 +59,36 @@ func Run(cmd *cobra.Command, _ []string) {
core.Run(ctx)
<-ctx.Done()
logger.Info(i18n.T(i18nk.LifetimeExiting))
defer logger.Info(i18n.T(i18nk.LifetimeBye))
logger.Info("Exiting...")
defer logger.Info("Exit complete")
cleanCache()
}
func initAll(ctx context.Context) (<-chan struct{}, error) {
if err := config.Init(ctx); 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.LifetimeIniting))
logger.Info("Initializing...")
database.Init(ctx)
storage.LoadStorages(ctx)
if config.C().Parser.PluginEnable {
for _, dir := range config.C().Parser.PluginDirs {
if err := parsers.LoadPlugins(ctx, dir); err != nil {
logger.Error(i18n.T(i18nk.ParserPluginLoadFailed), "dir", dir, "error", err)
logger.Error("Failed to load parser plugins", "dir", dir, "error", err)
} else {
logger.Debug(i18n.T(i18nk.ParserPluginLoadedDir), "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.Fatal(i18n.T(i18nk.LifetimeUserLoginFailed, map[string]any{
"Error": err,
}))
logger.Fatal("User login failed", "error", err)
}
}
if err := api.Start(ctx); err != nil {
logger.Error("Failed to start API server", "error", err)
}
return bot.Init(ctx), nil
}
@@ -87,33 +98,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.ConfigErrInvalidCacheDir, 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.ErrGetWorkdirFailed, 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.ErrGetCacheAbsPathFailed, map[string]any{
"Error": err,
}))
log.Error("Failed to get absolute cache path", "error", err)
return
}
log.Info(i18n.T(i18nk.LifetimeCleaningCache, map[string]any{
"Path": cachePath,
}))
log.Info("Cleaning cache directory", "path", cachePath)
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
log.Error(i18n.T(i18nk.ErrCleanCacheFailed, map[string]any{
"Error": err,
}))
log.Error("Failed to clean cache directory", "error", err)
}
}
}

130
cmd/upload/cmd.go Normal file
View File

@@ -0,0 +1,130 @@
package upload
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
stortype "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/storage"
"github.com/spf13/cobra"
)
var uploadCmd = &cobra.Command{
Use: "upload",
Short: "upload local files to storage",
RunE: Upload,
}
func Register(root *cobra.Command) {
uploadCmd.Flags().StringP("file", "f", "", "file path to upload")
uploadCmd.MarkFlagRequired("file")
uploadCmd.Flags().StringP("storage", "s", "", "storage name to upload to")
uploadCmd.MarkFlagRequired("storage")
uploadCmd.Flags().StringP("dir", "d", "", "storage dir to upload to, default is the base_path of the storage")
uploadCmd.Flags().Bool("no-progress", false, "disable progress bar")
root.AddCommand(uploadCmd)
}
func Upload(cmd *cobra.Command, args []string) error {
storname, err := cmd.Flags().GetString("storage")
if err != nil {
return err
}
fp, err := cmd.Flags().GetString("file")
if err != nil {
return err
}
dirPath, err := cmd.Flags().GetString("dir")
if err != nil {
return err
}
noProgress, err := cmd.Flags().GetBool("no-progress")
if err != nil {
return err
}
ctx := cmd.Context()
log := log.FromContext(ctx)
configFile := config.GetConfigFile(cmd)
if err := config.Init(ctx, configFile); err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
i18n.Init(config.C().Lang)
cache.Init()
database.Init(ctx)
stor, err := storage.GetStorageByName(ctx, storname)
if err != nil {
log.Fatal("Failed to get storage", "error", err)
}
switch stor.Type() {
case stortype.Telegram:
bot.Init(ctx)
default:
// placeholder for other storage types that may need special initialization
}
file, err := os.Open(filepath.Clean(fp))
if err != nil {
log.Fatal("Failed to open file", "error", err)
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
log.Fatal("Failed to get file info", "error", err)
}
fileName := fileInfo.Name()
fileSize := fileInfo.Size()
uploadPath := 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

@@ -1,3 +1,5 @@
// [TODO] complete the i18n support
package i18n
import (
@@ -42,57 +44,7 @@ func Init(lang 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")
}
templateDataMap := make(map[string]any)
for _, data := range templateData {
maps.Copy(templateDataMap, data)
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: string(key),
TemplateData: templateDataMap,
})
if err != nil {
return string(key)
}
return msg
}
func TWithLang(lang, key string, templateData ...map[string]any) string {
if bundle == nil {
panic("bundle is not initialized, call Init() first")
}
templateDataMap := make(map[string]any)
for _, data := range templateData {
maps.Copy(templateDataMap, data)
}
localizerWithLang := i18n.NewLocalizer(bundle, lang)
msg, err := localizerWithLang.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: templateDataMap,
})
if err != nil {
return key
}
return msg
}
// Only use in tests or packages that load before i18n
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
bundle := i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale")
if err != nil {
return string(key)
}
for _, file := range files {
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
return string(key)
}
}
localizer := i18n.NewLocalizer(bundle, lang)
if localizer == nil {
return string(key)
Init("zh-Hans")
}
templateDataMap := make(map[string]any)
for _, data := range templateData {

View File

@@ -4,21 +4,289 @@ package i18nk
type Key string
const (
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
ConfigLoadedStorages Key = "config.loaded_storages"
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"
BotMsgAria2ErrorAddingAria2Download Key = "bot.msg.aria2.error_adding_aria2_download"
BotMsgAria2ErrorAria2ClientInitFailed Key = "bot.msg.aria2.error_aria2_client_init_failed"
BotMsgAria2ErrorAria2NotEnabled Key = "bot.msg.aria2.error_aria2_not_enabled"
BotMsgAria2InfoAddingAria2Download Key = "bot.msg.aria2.info_adding_aria2_download"
BotMsgAria2InfoAria2DownloadAdded Key = "bot.msg.aria2.info_aria2_download_added"
BotMsgAria2InfoSelectStorage Key = "bot.msg.aria2.info_select_storage"
BotMsgCancelErrorCancelFailed Key = "bot.msg.cancel.error_cancel_failed"
BotMsgCancelInfoCancelRequested Key = "bot.msg.cancel.info_cancel_requested"
BotMsgCancelInfoCancellingTask Key = "bot.msg.cancel.info_cancelling_task"
BotMsgCancelUsage Key = "bot.msg.cancel.usage"
BotMsgCmdAria2dl Key = "bot.msg.cmd.aria2dl"
BotMsgCmdCancel Key = "bot.msg.cmd.cancel"
BotMsgCmdConfig Key = "bot.msg.cmd.config"
BotMsgCmdDir Key = "bot.msg.cmd.dir"
BotMsgCmdDl Key = "bot.msg.cmd.dl"
BotMsgCmdFnametmpl Key = "bot.msg.cmd.fnametmpl"
BotMsgCmdHelp Key = "bot.msg.cmd.help"
BotMsgCmdImport Key = "bot.msg.cmd.import"
BotMsgCmdLswatch Key = "bot.msg.cmd.lswatch"
BotMsgCmdParser Key = "bot.msg.cmd.parser"
BotMsgCmdRule Key = "bot.msg.cmd.rule"
BotMsgCmdSave Key = "bot.msg.cmd.save"
BotMsgCmdSilent Key = "bot.msg.cmd.silent"
BotMsgCmdStart Key = "bot.msg.cmd.start"
BotMsgCmdStorage Key = "bot.msg.cmd.storage"
BotMsgCmdSyncpeers Key = "bot.msg.cmd.syncpeers"
BotMsgCmdTask Key = "bot.msg.cmd.task"
BotMsgCmdTransfer Key = "bot.msg.cmd.transfer"
BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch"
BotMsgCmdUpdate Key = "bot.msg.cmd.update"
BotMsgCmdWatch Key = "bot.msg.cmd.watch"
BotMsgCmdYtdlp Key = "bot.msg.cmd.ytdlp"
BotMsgCommonCancelButtonText Key = "bot.msg.common.cancel_button_text"
BotMsgCommonErrorBuildDirSelectKeyboardFailed Key = "bot.msg.common.error_build_dir_select_keyboard_failed"
BotMsgCommonErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.common.error_build_storage_select_keyboard_failed"
BotMsgCommonErrorBuildStorageSelectMessageFailed Key = "bot.msg.common.error_build_storage_select_message_failed"
BotMsgCommonErrorDataExpired Key = "bot.msg.common.error_data_expired"
BotMsgCommonErrorDefaultStorageNotSet Key = "bot.msg.common.error_default_storage_not_set"
BotMsgCommonErrorGetDirFailed Key = "bot.msg.common.error_get_dir_failed"
BotMsgCommonErrorGetFileFailed Key = "bot.msg.common.error_get_file_failed"
BotMsgCommonErrorGetMessagesFailed Key = "bot.msg.common.error_get_messages_failed"
BotMsgCommonErrorGetStorageFailed Key = "bot.msg.common.error_get_storage_failed"
BotMsgCommonErrorGetTelegraphPageFailed Key = "bot.msg.common.error_get_telegraph_page_failed"
BotMsgCommonErrorGetUserFailed Key = "bot.msg.common.error_get_user_failed"
BotMsgCommonErrorGetUserInfoFailed Key = "bot.msg.common.error_get_user_info_failed"
BotMsgCommonErrorGetUserWithErrFailed Key = "bot.msg.common.error_get_user_with_err_failed"
BotMsgCommonErrorInvalidIdOrUsername Key = "bot.msg.common.error_invalid_id_or_username"
BotMsgCommonErrorInvalidMsgIdRange Key = "bot.msg.common.error_invalid_msg_id_range"
BotMsgCommonErrorInvalidRegex Key = "bot.msg.common.error_invalid_regex"
BotMsgCommonErrorNoAvailableStorage Key = "bot.msg.common.error_no_available_storage"
BotMsgCommonErrorNoImagesInTelegraphPage Key = "bot.msg.common.error_no_images_in_telegraph_page"
BotMsgCommonErrorNoMessagesInRange Key = "bot.msg.common.error_no_messages_in_range"
BotMsgCommonErrorNoPermission Key = "bot.msg.common.error_no_permission"
BotMsgCommonErrorNoSavableFilesFound Key = "bot.msg.common.error_no_savable_files_found"
BotMsgCommonErrorNoSavableMessagesInRange Key = "bot.msg.common.error_no_savable_messages_in_range"
BotMsgCommonErrorParseTelegraphPathFailed Key = "bot.msg.common.error_parse_telegraph_path_failed"
BotMsgCommonErrorTaskAddFailed Key = "bot.msg.common.error_task_add_failed"
BotMsgCommonErrorTaskCreateFailed Key = "bot.msg.common.error_task_create_failed"
BotMsgCommonErrorUpdateUserInfoFailed Key = "bot.msg.common.error_update_user_info_failed"
BotMsgCommonInfoBatchTasksAdded Key = "bot.msg.common.info_batch_tasks_added"
BotMsgCommonInfoDefaultStorageSet Key = "bot.msg.common.info_default_storage_set"
BotMsgCommonInfoDefaultStorageWithDirSet Key = "bot.msg.common.info_default_storage_with_dir_set"
BotMsgCommonInfoFetchingFileInfo Key = "bot.msg.common.info_fetching_file_info"
BotMsgCommonInfoFetchingMessages Key = "bot.msg.common.info_fetching_messages"
BotMsgCommonInfoFetchingTelegraphPage Key = "bot.msg.common.info_fetching_telegraph_page"
BotMsgCommonInfoFoundFilesSelectStorage Key = "bot.msg.common.info_found_files_select_storage"
BotMsgCommonInfoSilentModeOff Key = "bot.msg.common.info_silent_mode_off"
BotMsgCommonInfoSilentModeOn Key = "bot.msg.common.info_silent_mode_on"
BotMsgCommonInfoTaskAdded Key = "bot.msg.common.info_task_added"
BotMsgCommonPromptSelectDefaultDir Key = "bot.msg.common.prompt_select_default_dir"
BotMsgCommonPromptSelectDefaultStorage Key = "bot.msg.common.prompt_select_default_storage"
BotMsgCommonPromptSelectDir Key = "bot.msg.common.prompt_select_dir"
BotMsgConfigButtonFilenameStrategy Key = "bot.msg.config.button_filename_strategy"
BotMsgConfigErrorInvalidCallbackData Key = "bot.msg.config.error_invalid_callback_data"
BotMsgConfigErrorInvalidTemplate Key = "bot.msg.config.error_invalid_template"
BotMsgConfigFnametmplHelp Key = "bot.msg.config.fnametmpl_help"
BotMsgConfigInfoCurrentTemplatePrefix Key = "bot.msg.config.info_current_template_prefix"
BotMsgConfigInfoFilenameStrategySet Key = "bot.msg.config.info_filename_strategy_set"
BotMsgConfigInfoTemplateUpdated Key = "bot.msg.config.info_template_updated"
BotMsgConfigPromptSelectFilenameStrategy Key = "bot.msg.config.prompt_select_filename_strategy"
BotMsgConfigPromptSelectOption Key = "bot.msg.config.prompt_select_option"
BotMsgDirButtonDefault Key = "bot.msg.dir.button_default"
BotMsgDirErrorCreateDirFailed Key = "bot.msg.dir.error_create_dir_failed"
BotMsgDirErrorDeleteDirFailed Key = "bot.msg.dir.error_delete_dir_failed"
BotMsgDirErrorGetUserDirsFailed Key = "bot.msg.dir.error_get_user_dirs_failed"
BotMsgDirErrorGetUserFailed Key = "bot.msg.dir.error_get_user_failed"
BotMsgDirErrorInvalidDirId Key = "bot.msg.dir.error_invalid_dir_id"
BotMsgDirErrorUnknownOperation Key = "bot.msg.dir.error_unknown_operation"
BotMsgDirHelpAddExampleCmd Key = "bot.msg.dir.help_add_example_cmd"
BotMsgDirHelpAddExamplePrefix Key = "bot.msg.dir.help_add_example_prefix"
BotMsgDirHelpAddSuffix Key = "bot.msg.dir.help_add_suffix"
BotMsgDirHelpAvailableOps Key = "bot.msg.dir.help_available_ops"
BotMsgDirHelpDelExampleCmd Key = "bot.msg.dir.help_del_example_cmd"
BotMsgDirHelpDelExamplePrefix Key = "bot.msg.dir.help_del_example_prefix"
BotMsgDirHelpDelSuffix Key = "bot.msg.dir.help_del_suffix"
BotMsgDirHelpExistingDirsPrefix Key = "bot.msg.dir.help_existing_dirs_prefix"
BotMsgDirHelpUsage Key = "bot.msg.dir.help_usage"
BotMsgDirInfoCreateDirSuccess Key = "bot.msg.dir.info_create_dir_success"
BotMsgDirInfoDeleteDirSuccess Key = "bot.msg.dir.info_delete_dir_success"
BotMsgDlErrorNoValidLinks Key = "bot.msg.dl.error_no_valid_links"
BotMsgDlInfoFilesSelectStorage Key = "bot.msg.dl.info_files_select_storage"
BotMsgDlUsage Key = "bot.msg.dl.usage"
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.media_group.error_build_storage_select_keyboard_failed"
BotMsgMediaGroupInfoGroupFoundFilesSelectStorage Key = "bot.msg.media_group.info_group_found_files_select_storage"
BotMsgMediaGroupInfoSavingFiles Key = "bot.msg.media_group.info_saving_files"
BotMsgParseErrorBuildParsedTextEntityFailed Key = "bot.msg.parse.error_build_parsed_text_entity_failed"
BotMsgParseErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.parse.error_build_storage_select_keyboard_failed"
BotMsgParseErrorParseTextFailed Key = "bot.msg.parse.error_parse_text_failed"
BotMsgParseInfoAuthorPrefix Key = "bot.msg.parse.info_author_prefix"
BotMsgParseInfoDescriptionPrefix Key = "bot.msg.parse.info_description_prefix"
BotMsgParseInfoFileCountPrefix Key = "bot.msg.parse.info_file_count_prefix"
BotMsgParseInfoLinkPrefix Key = "bot.msg.parse.info_link_prefix"
BotMsgParseInfoParsing Key = "bot.msg.parse.info_parsing"
BotMsgParseInfoPromptSelectStorage Key = "bot.msg.parse.info_prompt_select_storage"
BotMsgParseInfoTotalSizePrefix Key = "bot.msg.parse.info_total_size_prefix"
BotMsgParserErrorDownloadFileFailed Key = "bot.msg.parser.error_download_file_failed"
BotMsgParserErrorFileTooLarge Key = "bot.msg.parser.error_file_too_large"
BotMsgParserErrorGetFilenameFailed Key = "bot.msg.parser.error_get_filename_failed"
BotMsgParserErrorInstallPluginFailed Key = "bot.msg.parser.error_install_plugin_failed"
BotMsgParserErrorNoValidFileInReply Key = "bot.msg.parser.error_no_valid_file_in_reply"
BotMsgParserErrorOnlyJsSupported Key = "bot.msg.parser.error_only_js_supported"
BotMsgParserErrorWrongFileType Key = "bot.msg.parser.error_wrong_file_type"
BotMsgParserHelpText Key = "bot.msg.parser.help_text"
BotMsgParserInfoInstallPluginSuccess Key = "bot.msg.parser.info_install_plugin_success"
BotMsgParserPluginNotEnabled Key = "bot.msg.parser.plugin_not_enabled"
BotMsgParserPromptReplyWithParserFile Key = "bot.msg.parser.prompt_reply_with_parser_file"
BotMsgProgressAria2Done Key = "bot.msg.progress.aria2_done"
BotMsgProgressAria2Downloading Key = "bot.msg.progress.aria2_downloading"
BotMsgProgressAria2Start Key = "bot.msg.progress.aria2_start"
BotMsgProgressAvgSpeedPrefix Key = "bot.msg.progress.avg_speed_prefix"
BotMsgProgressBatchDonePrefix Key = "bot.msg.progress.batch_done_prefix"
BotMsgProgressBatchProcessingPrefix Key = "bot.msg.progress.batch_processing_prefix"
BotMsgProgressBatchStartPrefix Key = "bot.msg.progress.batch_start_prefix"
BotMsgProgressCurrentProgressPrefix Key = "bot.msg.progress.current_progress_prefix"
BotMsgProgressCurrentSpeedPrefix Key = "bot.msg.progress.current_speed_prefix"
BotMsgProgressDirectDonePrefix Key = "bot.msg.progress.direct_done_prefix"
BotMsgProgressDirectStart Key = "bot.msg.progress.direct_start"
BotMsgProgressDownloadDonePrefix Key = "bot.msg.progress.download_done_prefix"
BotMsgProgressDownloadFailedPrefix Key = "bot.msg.progress.download_failed_prefix"
BotMsgProgressDownloadedPrefix Key = "bot.msg.progress.downloaded_prefix"
BotMsgProgressDownloadingPrefix Key = "bot.msg.progress.downloading_prefix"
BotMsgProgressErrorPrefix Key = "bot.msg.progress.error_prefix"
BotMsgProgressFileNamePrefix Key = "bot.msg.progress.file_name_prefix"
BotMsgProgressFileProcessingPrefix Key = "bot.msg.progress.file_processing_prefix"
BotMsgProgressFileSizePrefix Key = "bot.msg.progress.file_size_prefix"
BotMsgProgressFileStartPrefix Key = "bot.msg.progress.file_start_prefix"
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"
BotMsgProgressTransferAvgSpeedPrefix Key = "bot.msg.progress.transfer_avg_speed_prefix"
BotMsgProgressTransferElapsedTimePrefix Key = "bot.msg.progress.transfer_elapsed_time_prefix"
BotMsgProgressTransferFailedFilesPrefix Key = "bot.msg.progress.transfer_failed_files_prefix"
BotMsgProgressTransferFailedPrefix Key = "bot.msg.progress.transfer_failed_prefix"
BotMsgProgressTransferProcessingMore Key = "bot.msg.progress.transfer_processing_more"
BotMsgProgressTransferProcessingPrefix Key = "bot.msg.progress.transfer_processing_prefix"
BotMsgProgressTransferProgressPrefix Key = "bot.msg.progress.transfer_progress_prefix"
BotMsgProgressTransferRemainingTimePrefix Key = "bot.msg.progress.transfer_remaining_time_prefix"
BotMsgProgressTransferSpeedPrefix Key = "bot.msg.progress.transfer_speed_prefix"
BotMsgProgressTransferStartPrefix Key = "bot.msg.progress.transfer_start_prefix"
BotMsgProgressTransferSuccessPrefix Key = "bot.msg.progress.transfer_success_prefix"
BotMsgProgressTransferTotalFilesPrefix Key = "bot.msg.progress.transfer_total_files_prefix"
BotMsgProgressTransferTotalSizePrefix Key = "bot.msg.progress.transfer_total_size_prefix"
BotMsgProgressTransferUploadedPrefix Key = "bot.msg.progress.transfer_uploaded_prefix"
BotMsgProgressYtdlpDone Key = "bot.msg.progress.ytdlp_done"
BotMsgProgressYtdlpDownloading Key = "bot.msg.progress.ytdlp_downloading"
BotMsgProgressYtdlpStart Key = "bot.msg.progress.ytdlp_start"
BotMsgRuleErrorCreateRuleFailed Key = "bot.msg.rule.error_create_rule_failed"
BotMsgRuleErrorDeleteRuleFailed Key = "bot.msg.rule.error_delete_rule_failed"
BotMsgRuleErrorGetUserRulesFailed Key = "bot.msg.rule.error_get_user_rules_failed"
BotMsgRuleErrorInvalidRuleId Key = "bot.msg.rule.error_invalid_rule_id"
BotMsgRuleErrorInvalidRuleType Key = "bot.msg.rule.error_invalid_rule_type"
BotMsgRuleErrorUpdateUserFailed Key = "bot.msg.rule.error_update_user_failed"
BotMsgRuleHelpAddSuffix Key = "bot.msg.rule.help_add_suffix"
BotMsgRuleHelpAvailableOps Key = "bot.msg.rule.help_available_ops"
BotMsgRuleHelpCurrentModeDisabled Key = "bot.msg.rule.help_current_mode_disabled"
BotMsgRuleHelpCurrentModeEnabled Key = "bot.msg.rule.help_current_mode_enabled"
BotMsgRuleHelpDelSuffix Key = "bot.msg.rule.help_del_suffix"
BotMsgRuleHelpExistingRulesPrefix Key = "bot.msg.rule.help_existing_rules_prefix"
BotMsgRuleHelpSwitchSuffix Key = "bot.msg.rule.help_switch_suffix"
BotMsgRuleHelpUsage Key = "bot.msg.rule.help_usage"
BotMsgRuleInfoCreateRuleSuccess Key = "bot.msg.rule.info_create_rule_success"
BotMsgRuleInfoDeleteRuleSuccess Key = "bot.msg.rule.info_delete_rule_success"
BotMsgRuleInfoRuleModeDisabled Key = "bot.msg.rule.info_rule_mode_disabled"
BotMsgRuleInfoRuleModeEnabled Key = "bot.msg.rule.info_rule_mode_enabled"
BotMsgRulePromptProvideRuleId Key = "bot.msg.rule.prompt_provide_rule_id"
BotMsgSaveErrorInvalidIdOrUsername Key = "bot.msg.save.error_invalid_id_or_username"
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
BotMsgStorageInfoFilenamePrefix Key = "bot.msg.storage.info_filename_prefix"
BotMsgStorageInfoPromptSelectStorage Key = "bot.msg.storage.info_prompt_select_storage"
BotMsgSyncpeersDone Key = "bot.msg.syncpeers.done"
BotMsgSyncpeersFailed Key = "bot.msg.syncpeers.failed"
BotMsgSyncpeersStart Key = "bot.msg.syncpeers.start"
BotMsgSyncpeersSuccess Key = "bot.msg.syncpeers.success"
BotMsgTasksCancelFailed Key = "bot.msg.tasks.cancel_failed"
BotMsgTasksCancelRequestedPrefix Key = "bot.msg.tasks.cancel_requested_prefix"
BotMsgTasksFieldCreated Key = "bot.msg.tasks.field_created"
BotMsgTasksFieldId Key = "bot.msg.tasks.field_id"
BotMsgTasksFieldStatus Key = "bot.msg.tasks.field_status"
BotMsgTasksFieldTitle Key = "bot.msg.tasks.field_title"
BotMsgTasksInfoAddedToQueueFull Key = "bot.msg.tasks.info_added_to_queue_full"
BotMsgTasksInfoAddedToQueuePrefix Key = "bot.msg.tasks.info_added_to_queue_prefix"
BotMsgTasksInfoFilenamePrefix Key = "bot.msg.tasks.info_filename_prefix"
BotMsgTasksInfoQueueLengthPrefix Key = "bot.msg.tasks.info_queue_length_prefix"
BotMsgTasksQueuedEmpty Key = "bot.msg.tasks.queued_empty"
BotMsgTasksQueuedTitle Key = "bot.msg.tasks.queued_title"
BotMsgTasksRunningEmpty Key = "bot.msg.tasks.running_empty"
BotMsgTasksRunningTitle Key = "bot.msg.tasks.running_title"
BotMsgTasksStatusCancelRequested Key = "bot.msg.tasks.status_cancel_requested"
BotMsgTasksStatusQueued Key = "bot.msg.tasks.status_queued"
BotMsgTasksStatusRunning Key = "bot.msg.tasks.status_running"
BotMsgTasksTotalPrefix Key = "bot.msg.tasks.total_prefix"
BotMsgTasksTruncatedNote Key = "bot.msg.tasks.truncated_note"
BotMsgTasksUsage Key = "bot.msg.tasks.usage"
BotMsgTasksUsageCancel Key = "bot.msg.tasks.usage_cancel"
BotMsgTelegraphErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.telegraph.error_build_storage_select_keyboard_failed"
BotMsgTelegraphInfoPicCountPrefix Key = "bot.msg.telegraph.info_pic_count_prefix"
BotMsgTelegraphInfoPromptSelectStorage Key = "bot.msg.telegraph.info_prompt_select_storage"
BotMsgTelegraphInfoTitlePrefix Key = "bot.msg.telegraph.info_title_prefix"
BotMsgTransferErrorAddTaskFailed Key = "bot.msg.transfer.error_add_task_failed"
BotMsgTransferErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.transfer.error_build_storage_select_keyboard_failed"
BotMsgTransferErrorInvalidRegex Key = "bot.msg.transfer.error_invalid_regex"
BotMsgTransferErrorInvalidSource Key = "bot.msg.transfer.error_invalid_source"
BotMsgTransferErrorInvalidTarget Key = "bot.msg.transfer.error_invalid_target"
BotMsgTransferErrorListFilesFailed Key = "bot.msg.transfer.error_list_files_failed"
BotMsgTransferErrorNoFilesToTransfer Key = "bot.msg.transfer.error_no_files_to_transfer"
BotMsgTransferErrorStorageNotFound Key = "bot.msg.transfer.error_storage_not_found"
BotMsgTransferErrorStorageNotListable Key = "bot.msg.transfer.error_storage_not_listable"
BotMsgTransferErrorStorageNotReadable Key = "bot.msg.transfer.error_storage_not_readable"
BotMsgTransferErrorTargetNotFound Key = "bot.msg.transfer.error_target_not_found"
BotMsgTransferInfoFetchingFiles Key = "bot.msg.transfer.info_fetching_files"
BotMsgTransferInfoFilesSelectStorage Key = "bot.msg.transfer.info_files_select_storage"
BotMsgTransferInfoTaskAdded Key = "bot.msg.transfer.info_task_added"
BotMsgTransferStartStats Key = "bot.msg.transfer.start_stats"
BotMsgTransferUsage Key = "bot.msg.transfer.usage"
BotMsgUpdateButtonUpgrade Key = "bot.msg.update.button_upgrade"
BotMsgUpdateErrorCheckLatestFailed Key = "bot.msg.update.error_check_latest_failed"
BotMsgUpdateErrorNoReleaseFound Key = "bot.msg.update.error_no_release_found"
BotMsgUpdateErrorUpgradeFailed Key = "bot.msg.update.error_upgrade_failed"
BotMsgUpdateErrorVersionVarInvalid Key = "bot.msg.update.error_version_var_invalid"
BotMsgUpdateInfoAlreadyLatest Key = "bot.msg.update.info_already_latest"
BotMsgUpdateInfoMajorUpgradeRequired Key = "bot.msg.update.info_major_upgrade_required"
BotMsgUpdateInfoNewVersionInDocker Key = "bot.msg.update.info_new_version_in_docker"
BotMsgUpdateInfoNewVersionPromptUpgrade Key = "bot.msg.update.info_new_version_prompt_upgrade"
BotMsgUpdateInfoUpgradeSuccess Key = "bot.msg.update.info_upgrade_success"
BotMsgUpdateInfoUpgradingWithVersion Key = "bot.msg.update.info_upgrading_with_version"
BotMsgWatchErrorFilterFormatInvalid Key = "bot.msg.watch.error_filter_format_invalid"
BotMsgWatchErrorFilterTypeUnsupported Key = "bot.msg.watch.error_filter_type_unsupported"
BotMsgWatchErrorUnwatchChatFailed Key = "bot.msg.watch.error_unwatch_chat_failed"
BotMsgWatchErrorUnwatchNoChatProvided Key = "bot.msg.watch.error_unwatch_no_chat_provided"
BotMsgWatchErrorWatchChatFailed Key = "bot.msg.watch.error_watch_chat_failed"
BotMsgWatchInfoAlreadyWatchingChat Key = "bot.msg.watch.info_already_watching_chat"
BotMsgWatchInfoWatchChatStarted Key = "bot.msg.watch.info_watch_chat_started"
BotMsgWatchInfoWatchChatStopped Key = "bot.msg.watch.info_watch_chat_stopped"
BotMsgWatchInfoWatchListEmpty Key = "bot.msg.watch.info_watch_list_empty"
BotMsgWatchInfoWatchListFilterPrefix Key = "bot.msg.watch.info_watch_list_filter_prefix"
BotMsgWatchInfoWatchListHeader Key = "bot.msg.watch.info_watch_list_header"
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
BotMsgYtdlpErrorDownloadFailed Key = "bot.msg.ytdlp.error_download_failed"
BotMsgYtdlpErrorNoValidUrls Key = "bot.msg.ytdlp.error_no_valid_urls"
BotMsgYtdlpInfoDownloading Key = "bot.msg.ytdlp.info_downloading"
BotMsgYtdlpInfoUrlsSelectStorage Key = "bot.msg.ytdlp.info_urls_select_storage"
BotMsgYtdlpUsage Key = "bot.msg.ytdlp.usage"
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
ErrCleanCacheFailed Key = "err.clean_cache_failed"
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
LifetimeBye Key = "lifetime.bye"
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
LifetimeExiting Key = "lifetime.exiting"
LifetimeInitfailed Key = "lifetime.initfailed"
LifetimeIniting Key = "lifetime.initing"
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
)

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

@@ -0,0 +1,394 @@
lifetime:
initing: Starting up
initfailed: Initialization failed
exiting: Shutting down
user_login_failed: "User login failed: {{.Error}}"
cleaning_cache: "Cleaning cache {{.Path}}"
bye: Exited
config:
err:
invalid_cache_dir: "Invalid cache directory: {{.Path}}, please check the config file"
duplicate_storage_name: "Storage name '{{.Name}}' is duplicated, please check the config file"
err:
get_workdir_failed: "Failed to get working directory: {{.Error}}"
get_cache_abs_path_failed: "Failed to get absolute cache path: {{.Error}}"
clean_cache_failed: "Failed to clean cache: {{.Error}}"
parser:
plugin:
load_failed: Failed to load parser plugins
loaded_dir: Parser plugins loaded
bot:
msg:
help_text_fmt: |
Save Any Bot - Save your Telegram files
Version: %s , Commit: %s
Commands:
/start - Start using the bot
/help - Show help
/silent - Toggle silent mode
/storage - Set default storage
/save [custom filename] - Save file
/import <storage_name> <dir_path> [channel_id] [filter] - Import files from storage to Telegram
/dir - Manage storage directories
/rule - Manage rules
/config - Modify configuration
/fnametmpl - Set custom filename template
/parser - Manage parser plugins
/task - Manage task queue
/watch - Watch chats and auto save (UserBot)
/unwatch - Stop watching chats (UserBot)
/lswatch - List watched chats (UserBot)
/syncpeers - Sync peer chats (UserBot)
/update - Check and upgrade to latest version
Usage guide: https://sabot.unv.app/usage
cmd:
start: "Start using"
silent: "Toggle silent mode"
storage: "Set default storage"
dir: "Manage storage directories"
rule: "Manage auto-save rules"
save: "Save files"
dl: "Download files from given links"
aria2dl: "Download files using Aria2"
ytdlp: "Download video/audio using yt-dlp"
import: "Import files from storage to Telegram"
transfer: "Transfer files between storages"
task: "Manage task queue"
cancel: "Cancel task"
watch: "Watch chats (UserBot)"
unwatch: "Stop watching chats (UserBot)"
lswatch: "List watched chats (UserBot)"
config: "Modify configuration"
fnametmpl: "Set filename template"
help: "Show help"
parser: "Manage parsers"
update: "Check for updates"
syncpeers: "Sync peer chats (UserBot)"
save_help_text: |
Usage:
1. Reply to the file you want to save with this command, optional filename parameter.
Example:
/save custom_file_name.mp4
2. After setting default storage, send /save <channel_id/username> <message_id_range> to batch save files. Rules will be applied; if no rule matches, default storage will be used.
Example:
/save @acherkrau 114-514
watch_help_text: |
Use /watch to watch messages in a chat and automatically save them to the default storage, following storage rules.
Syntax:
/watch <chat_id> [filter]
Parameters:
- <chat_id>: Chat ID or username
- [filter]: Optional, format is filter_type:expression , see docs for all supported filters
Example:
/watch -1002229835658 msgre:.*plana.*
This will watch chat with ID -1002229835658 and save all media messages containing "plana".
common:
cancel_button_text: "Cancel"
error_invalid_regex: "Invalid regex: {{.Error}}"
error_invalid_msg_id_range: "Invalid message ID range: {{.Error}}"
error_invalid_id_or_username: "Invalid ID or username: {{.Error}}"
error_get_messages_failed: "Failed to get messages: {{.Error}}"
info_fetching_messages: "Fetching messages..."
error_no_messages_in_range: "No messages found in the specified range"
error_no_savable_messages_in_range: "No savable messages found in the specified range"
error_build_storage_select_message_failed: "Failed to build storage selection message: {{.Error}}"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
info_found_files_select_storage: "Found {{.Count}} files, please select storage"
error_get_user_failed: "Failed to get user"
error_get_user_with_err_failed: "Failed to get user: {{.Error}}"
error_default_storage_not_set: "Please set a default storage first with /storage"
error_no_available_storage: "No available storage"
error_get_storage_failed: "Failed to get storage: {{.Error}}"
prompt_select_default_storage: "Please select a storage to set as default"
error_data_expired: "Data has expired or is invalid"
error_task_add_failed: "Failed to add task: {{.Error}}"
info_task_added: "Task added"
info_batch_tasks_added: "Batch tasks added, total {{.Count}} files"
error_task_create_failed: "Failed to create task: {{.Error}}"
error_get_dir_failed: "Failed to get directory: {{.Error}}"
prompt_select_dir: "Please select a directory to store to"
prompt_select_default_dir: "Please select a default directory to save to"
info_default_storage_set: "Default storage set to: {{.Name}}"
info_default_storage_with_dir_set: "Default storage set to: {{.Name}}:/{{.Dir}}"
error_get_user_info_failed: "Failed to get user info: {{.Error}}"
error_update_user_info_failed: "Failed to update user info: {{.Error}}"
info_silent_mode_on: "Silent mode enabled"
info_silent_mode_off: "Silent mode disabled"
error_get_file_failed: "Failed to get file: {{.Error}}"
info_fetching_file_info: "Fetching file info..."
error_no_savable_files_found: "No savable files found"
error_parse_telegraph_path_failed: "Failed to parse telegraph path: {{.Error}}"
info_fetching_telegraph_page: "Fetching telegraph page..."
error_get_telegraph_page_failed: "Failed to get telegraph page: {{.Error}}"
error_no_images_in_telegraph_page: "No images found in telegraph page"
error_build_dir_select_keyboard_failed: "Failed to build directory selection keyboard: {{.Error}}"
error_no_permission: |
You are not in the whitelist and cannot use this bot.
You can deploy your own instance: https://github.com/krau/SaveAny-Bot
save:
error_invalid_id_or_username: "Invalid ID or username: {{.Error}}"
watch:
error_filter_format_invalid: "Invalid filter format, please use <type>:<expression>"
error_filter_type_unsupported: "Unsupported filter type, please see the docs"
error_watch_chat_failed: "Failed to watch chat: {{.Error}}"
info_watch_chat_started: "Started watching chat: {{.Chat}}"
info_already_watching_chat: "Already watching this chat"
info_watch_list_empty: "No chats are being watched currently"
info_watch_list_header: "Currently watched chats:\n"
info_watch_list_filter_prefix: " (filter: "
error_unwatch_no_chat_provided: "Please provide a chat ID or username to unwatch"
error_unwatch_chat_failed: "Failed to unwatch chat: {{.Error}}"
info_watch_chat_stopped: "Stopped watching chat: {{.Chat}}"
tasks:
usage_cancel: "Usage: /tasks cancel <task_id>"
usage: "Usage: /tasks [running|queued|cancel <task_id>]"
cancel_failed: "Failed to cancel task: {{.Error}}"
cancel_requested_prefix: "Cancel requested for task: "
running_empty: "No running tasks"
running_title: "Currently running tasks:"
total_prefix: "Total: {{.Count}}\n"
field_id: "ID: "
field_title: "Title: "
field_created: "Created at: "
field_status: "Status: "
status_running: "Running"
status_queued: "Queued"
status_cancel_requested: "Cancel requested"
queued_empty: "No queued tasks"
queued_title: "Currently queued tasks:"
truncated_note: "...\nShowing first 10 tasks, total {{.Count}} tasks"
info_added_to_queue_full: "Added to task queue\nFilename: {{.Filename}}\nCurrent queued tasks: {{.QueueLength}}"
info_added_to_queue_prefix: "Added to task queue\n"
info_filename_prefix: "Filename: "
info_queue_length_prefix: "\nCurrent queued tasks: "
rule:
error_get_user_rules_failed: "Failed to get user rules"
error_update_user_failed: "Failed to update user"
info_rule_mode_enabled: "Rule mode enabled"
info_rule_mode_disabled: "Rule mode disabled"
error_invalid_rule_type: "Invalid rule type: {{.Type}}\nAvailable: {{.Available}}"
error_create_rule_failed: "Failed to create rule"
info_create_rule_success: "Rule created successfully"
prompt_provide_rule_id: "Please provide rule ID"
error_invalid_rule_id: "Invalid rule ID"
error_delete_rule_failed: "Failed to delete rule"
info_delete_rule_success: "Rule deleted successfully"
help_usage: "Usage: /rule <op> <args...>"
help_current_mode_enabled: "\nRule mode is currently enabled"
help_current_mode_disabled: "\nRule mode is currently disabled"
help_available_ops: "\n\nAvailable operations:\n"
help_switch_suffix: " - Toggle rule mode\n"
help_add_suffix: " <type> <data> <storage_name> <path> - Add rule\n"
help_del_suffix: " <rule_id> - Delete rule\n"
help_existing_rules_prefix: "\nCurrent rules:\n"
dir:
error_get_user_dirs_failed: "Failed to get user directories"
error_get_user_failed: "Failed to get user"
error_create_dir_failed: "Failed to create directory"
info_create_dir_success: "Directory created successfully"
error_invalid_dir_id: "Invalid directory ID"
error_delete_dir_failed: "Failed to delete directory"
info_delete_dir_success: "Directory deleted successfully"
error_unknown_operation: "Unknown operation"
help_usage: "Usage: /dir <op> <args...>"
help_available_ops: "\n\nAvailable operations:\n"
help_add_suffix: " <storage_name> <path> - Add path\n"
help_del_suffix: " <path_id> - Delete path\n"
help_add_example_prefix: "\nAdd path example:\n"
help_add_example_cmd: "/dir add local1 path/to/dir"
help_del_example_prefix: "\n\nDelete path example:\n"
help_del_example_cmd: "/dir del 3"
help_existing_dirs_prefix: "\n\nCurrent paths:\n"
button_default: "Default"
parser:
help_text: |
Usage:
/parser install <reply a file> - Install parser
plugin_not_enabled: "Parser plugin feature is not enabled"
prompt_reply_with_parser_file: "Please reply with a message containing the parser file"
error_no_valid_file_in_reply: "The replied message does not contain a valid file"
error_wrong_file_type: "Wrong file type"
error_file_too_large: "File too large"
error_get_filename_failed: "Failed to get filename"
error_only_js_supported: "Only .js files are supported as parsers"
error_download_file_failed: "Failed to download file: {{.Error}}"
error_install_plugin_failed: "Failed to install plugin: {{.Error}}"
info_install_plugin_success: "Plugin installed: {{.Name}}"
parse:
info_parsing: "Parsing..."
error_parse_text_failed: "Failed to parse text: {{.Error}}"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
error_build_parsed_text_entity_failed: "Failed to build parsed text entity: {{.Error}}"
info_link_prefix: "\nLink: "
info_author_prefix: "\nAuthor: "
info_description_prefix: "\nDescription: "
info_file_count_prefix: "\nFile count: "
info_total_size_prefix: "\nEstimated total size: "
info_prompt_select_storage: "\nPlease select storage"
telegraph:
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
info_title_prefix: "Title: "
info_pic_count_prefix: "\nImage count: "
info_prompt_select_storage: "\nPlease select storage"
update:
error_version_var_invalid: "Currently in development version or version info injection failed: {{.Error}}"
error_check_latest_failed: "Failed to check latest version: {{.Error}}"
error_no_release_found: "No release found"
info_major_upgrade_required: "Major upgrade detected: {{.Current}} -> {{.Latest}} , please download the latest version from GitHub and check the migration guide"
info_already_latest: "Already on latest version: {{.Version}}"
info_new_version_in_docker: |-
New version found: {{.Latest}}
Current version: {{.Current}}
Published at: {{.PublishedAt}}
Since you are using Docker, please update it on your deployment platform
info_new_version_prompt_upgrade: |-
New version found: {{.Latest}}
Current version: {{.Current}}
File size: {{.SizeMB}} MB
Download URL: {{.URL}}
Published at: {{.PublishedAt}}
Upgrading will restart the bot. Proceed?
info_upgrading_with_version: "Upgrading, current version: {{.Current}}"
error_upgrade_failed: "Upgrade failed: {{.Error}}"
info_upgrade_success: "Upgraded to version {{.Version}}\nIf the bot did not restart automatically, please start it manually"
button_upgrade: "Upgrade"
config:
prompt_select_option: "Please select an option to configure"
button_filename_strategy: "Filename strategy"
error_invalid_callback_data: "Invalid callback data"
error_invalid_template: "Invalid template, please check syntax\n{{.Error}}"
info_filename_strategy_set: "Filename strategy set to: {{.Strategy}}"
prompt_select_filename_strategy: "Please select filename strategy, current strategy: {{.Strategy}}"
fnametmpl_help: |-
Use this command to set filename template, for example:
/fnametmpl Image_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg
Available variables:
- {{"{{.msgid}}"}}: Message ID
- {{"{{.msgtags}}"}}: Tags in the message, joined with underscore
- {{"{{.msggen}}"}}: Generated filename from the message
- {{"{{.msgdate}}"}}: Message date, format YYYY-MM-DD_HH-MM-SS
- {{"{{.msgraw}}"}}: Raw message text (unprocessed)
- {{"{{.origname}}"}}: Original media filename (if any)
- {{"{{.chatid}}"}}: Chat ID of the message
Template only takes effect when filename strategy is set to 'Custom template'.
If template parsing fails, it will fall back to default filename.
info_template_updated: "Filename template updated"
info_current_template_prefix: "Current template: {{.Template}}"
dl:
usage: "Usage: /dl <url1> <url2> ..."
error_no_valid_links: "No valid links to download"
info_files_select_storage: "Total {{.Count}} files, please select storage"
ytdlp:
usage: "Usage: /ytdlp [OPTIONS] <URL1> [URL2] ...\nExamples:\n /ytdlp https://example.com/video\n /ytdlp --format best https://example.com/video\n /ytdlp --extract-audio --audio-format mp3 https://example.com/video"
error_no_valid_urls: "No valid URLs"
info_urls_select_storage: "Found {{.Count}} links, please select storage"
info_downloading: "Downloading via yt-dlp..."
error_download_failed: "yt-dlp download failed: {{.Error}}"
transfer:
usage: |
Usage: /transfer <source_storage>:/<source_path> [filter]
Examples:
/transfer local1:/downloads
/transfer alist1:/media/photos
/transfer webdav1:/files ".*\.mp4$"
error_invalid_source: "Invalid source path format, should be: storage_name:/path"
error_invalid_target: "Invalid target path format, should be: storage_name:/path"
error_storage_not_found: "Storage '{{.StorageName}}' not found or access denied: {{.Error}}"
error_storage_not_listable: "Storage '{{.StorageName}}' does not support listing files"
error_storage_not_readable: "Storage '{{.StorageName}}' does not support reading files"
error_target_not_found: "Target storage '{{.StorageName}}' not found or access denied: {{.Error}}"
info_fetching_files: "Fetching file list..."
error_list_files_failed: "Failed to list files: {{.Error}}"
error_invalid_regex: "Invalid regular expression: {{.Error}}"
error_no_files_to_transfer: "No files to transfer in directory"
error_add_task_failed: "Failed to add task: {{.Error}}"
info_task_added: "Added {{.Count}} files to transfer queue\nTotal size: {{.SizeMB}} MB\nTask ID: {{.TaskID}}"
start_stats: "Total files: {{.Count}}\nTotal size: {{.SizeMB}} MB"
info_files_select_storage: "Total {{.Count}} files ({{.SizeMB}} MB), please select target storage"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
cancel:
usage: "Usage: /cancel <task_id>"
error_cancel_failed: "Failed to cancel task: {{.Error}}"
info_cancel_requested: "Cancel requested for task: {{.TaskID}}"
info_cancelling_task: "Cancelling task..."
media_group:
info_saving_files: "Saving files..."
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
info_group_found_files_select_storage: "Total {{.Count}} files, please select storage"
storage:
info_filename_prefix: "Filename: "
info_prompt_select_storage: "\nPlease select storage"
progress:
batch_start_prefix: "Starting batch download task\nTotal size: "
batch_processing_prefix: "Processing batch download task\nTotal size: "
downloading_prefix: "Downloading\nTotal size: "
processing_list_prefix: "\nProcessing:\n"
processing_none: " - None"
avg_speed_prefix: "\nAverage speed: "
current_progress_prefix: "\nCurrent progress: "
task_canceled: "Task canceled"
task_canceled_with_id: "Processing canceled: {{.TaskID}}"
task_failed_with_error: "Processing failed: {{.Error}}"
batch_done_prefix: "Completed\nFile count: "
direct_done_prefix: "Completed, file count: "
parsed_start_prefix: "Starting download from {{.Site}}\nTotal size: "
parsed_done_prefix: "Completed, resource count: "
telegraph_start_prefix: "Starting Telegraph download\nImage count: "
telegraph_progress_prefix: "Downloading\nCurrent progress: "
telegraph_done_prefix: "Completed\nImage count: "
file_start_prefix: "Starting download\nFilename: "
file_processing_prefix: "Processing download task\nFilename: "
download_failed_prefix: "Download failed\nFilename: "
download_done_prefix: "Download completed\nFilename: "
file_size_prefix: "\nFile size: "
save_path_prefix: "\nSave path: "
total_size_prefix: "\nTotal size: "
direct_start: "Starting download, total size: {{.SizeMB}} MB ({{.Count}} files)"
file_name_prefix: "Filename: "
error_prefix: "\nError: "
aria2_start: "Waiting for Aria2 to complete download (GID: {{.GID}})..."
aria2_downloading: "Aria2 downloading (GID: {{.GID}})\n"
aria2_done: "Aria2 download completed and transferred (GID: {{.GID}})\n"
ytdlp_start: "Starting yt-dlp download ({{.Count}} links)..."
ytdlp_downloading: "yt-dlp downloading ({{.Count}} links)\n"
ytdlp_done: "yt-dlp download completed and transferred ({{.Count}} files)\n"
downloaded_prefix: "\nDownloaded: "
current_speed_prefix: "\nCurrent speed: "
transfer_start_prefix: "Transfering: "
transfer_progress_prefix: "Transfer progress: "
transfer_uploaded_prefix: "\nUploaded: "
transfer_speed_prefix: "\nSpeed: "
transfer_remaining_time_prefix: "\nRemaining time: "
transfer_processing_prefix: "\nProcessing:\n"
transfer_processing_more: "...and {{.Count}} more files\n"
transfer_failed_prefix: "Transfer failed\n"
transfer_success_prefix: "Transfer completed\n"
transfer_total_files_prefix: "\nTotal files: "
transfer_total_size_prefix: "\nTotal size: "
transfer_elapsed_time_prefix: "\nElapsed time: "
transfer_avg_speed_prefix: "\nAverage speed: "
transfer_failed_files_prefix: "\nFailed files: "
syncpeers:
start: "Starting to sync peers..."
done: "Peer sync completed, total {{.Count}} chats synced"
failed: "Peer sync failed: {{.Error}}"
aria2:
error_aria2_not_enabled: "Aria2 feature is not enabled in the configuration"
error_aria2_client_init_failed: "Aria2 client initialization failed: {{.Error}}"
info_adding_aria2_download: "Adding Aria2 download task..."
error_adding_aria2_download: "Failed to add Aria2 download task: {{.Error}}"
info_aria2_download_added: "Aria2 download task added, GID: {{.GID}}"
info_select_storage: "Please select storage, the task will be added to Aria2 download queue after selection"

View File

@@ -6,7 +6,6 @@ lifetime:
cleaning_cache: "正在清理缓存 {{.Path}}"
bye: 已退出
config:
loaded_storages: "已加载 {{.Count}} 个存储后端"
err:
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
@@ -30,16 +29,44 @@ bot:
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
/dl <链接1> <链接2> ... - 下载给定链接的文件
/import <存储名> <目录路径> [频道ID] [过滤器] - 从存储端导入文件到 Telegram
/dir - 管理存储目录
/rule - 管理规则
/config - 修改配置
/fnametmpl - 设置文件自定义命名模板
/parser - 管理解析器插件
/task - 管理任务队列
/watch - 监听聊天并自动保存 (UserBot)
/unwatch - 取消监听聊天 (UserBot)
/lswatch - 列出正在监听的聊天 (UserBot)
/syncpeers - 同步对话列表 (UserBot)
/update - 检查更新并升级
使用帮助: https://sabot.unv.app/usage
反馈群组: https://t.me/ProjectSaveAny
cmd:
start: "开始使用"
silent: "切换静默模式"
storage: "设置默认存储端"
dir: "管理存储文件夹"
rule: "管理自动存储规则"
save: "保存文件"
dl: "下载给定链接的文件"
aria2dl: "使用 Aria2 下载给定链接的文件"
ytdlp: "使用 yt-dlp 下载视频/音频"
import: "从存储端导入文件到 Telegram"
transfer: "在存储端之间传输文件"
task: "管理任务队列"
cancel: "取消任务"
watch: "监听聊天(UserBot)"
unwatch: "取消监听聊天(UserBot)"
lswatch: "列出监听的聊天(UserBot)"
syncpeers: "同步对话列表(UserBot)"
config: "修改配置"
fnametmpl: "设置文件命名模板"
help: "显示帮助"
parser: "管理解析器"
update: "检查更新"
save_help_text: |
使用方法:
@@ -64,3 +91,305 @@ bot:
/watch -1002229835658 msgre:.*plana.*
这将监听 ID 为 -1002229835658 的聊天, 并转存所有包含 "plana" 的媒体消息
common:
cancel_button_text: "取消任务"
error_invalid_regex: "无效的正则表达式: {{.Error}}"
error_invalid_msg_id_range: "无效的消息ID范围: {{.Error}}"
error_invalid_id_or_username: "无效的ID或用户名: {{.Error}}"
error_get_messages_failed: "获取消息失败: {{.Error}}"
info_fetching_messages: "正在获取消息..."
error_no_messages_in_range: "没有找到指定范围内的消息"
error_no_savable_messages_in_range: "没有找到指定范围内的可保存消息"
error_build_storage_select_message_failed: "构建存储选择消息失败: {{.Error}}"
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
info_found_files_select_storage: "找到 {{.Count}} 个文件, 请选择存储位置"
error_get_user_failed: "获取用户失败"
error_get_user_with_err_failed: "获取用户失败: {{.Error}}"
error_default_storage_not_set: "请先设置默认存储, 使用 /storage 命令"
error_no_available_storage: "无可用的存储"
error_get_storage_failed: "获取存储失败: {{.Error}}"
prompt_select_default_storage: "请选择要设为默认的存储位置"
error_data_expired: "数据已过期或无效"
error_task_add_failed: "任务添加失败: {{.Error}}"
info_task_added: "任务已添加"
info_batch_tasks_added: "已添加批量任务, 共 {{.Count}} 个文件"
error_task_create_failed: "任务创建失败: {{.Error}}"
error_get_dir_failed: "获取目录失败: {{.Error}}"
prompt_select_dir: "请选择要存储到的目录"
prompt_select_default_dir: "请选择要保存到的默认文件夹"
info_default_storage_set: "已将默认存储位置设为: {{.Name}}"
info_default_storage_with_dir_set: "已将默认存储位置设为: {{.Name}}:/{{.Dir}}"
error_get_user_info_failed: "获取用户信息失败: {{.Error}}"
error_update_user_info_failed: "更新用户信息失败: {{.Error}}"
info_silent_mode_on: "已开启静默模式"
info_silent_mode_off: "已关闭静默模式"
error_get_file_failed: "获取文件失败: {{.Error}}"
info_fetching_file_info: "正在获取文件信息..."
error_no_savable_files_found: "没有找到可保存的文件"
error_parse_telegraph_path_failed: "解析 telegraph 路径失败: {{.Error}}"
info_fetching_telegraph_page: "正在获取 telegraph 页面..."
error_get_telegraph_page_failed: "获取 telegraph 页面失败: {{.Error}}"
error_no_images_in_telegraph_page: "在 telegraph 页面中未找到图片"
error_build_dir_select_keyboard_failed: "构建目录选择键盘失败: {{.Error}}"
error_no_permission: |
您不在白名单中, 无法使用此 Bot.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
save:
error_invalid_id_or_username: "无效的ID或用户名: {{.Error}}"
watch:
error_filter_format_invalid: "过滤器格式错误, 请使用 <过滤器类型>:<表达式>"
error_filter_type_unsupported: "不支持的过滤器类型, 请参阅文档"
error_watch_chat_failed: "监听聊天失败: {{.Error}}"
info_watch_chat_started: "已开始监听聊天: {{.Chat}}"
info_already_watching_chat: "已经在监听此聊天"
info_watch_list_empty: "当前没有监听任何聊天"
info_watch_list_header: "当前监听的聊天:\n"
info_watch_list_filter_prefix: " (过滤器: "
error_unwatch_no_chat_provided: "请提供要取消监听的聊天ID或用户名"
error_unwatch_chat_failed: "取消监听聊天失败: {{.Error}}"
info_watch_chat_stopped: "已取消监听聊天: {{.Chat}}"
tasks:
usage_cancel: "用法: /tasks cancel <task_id>"
usage: "用法: /tasks [running|queued|cancel <task_id>]"
cancel_failed: "取消任务失败: {{.Error}}"
cancel_requested_prefix: "已请求取消任务: "
running_empty: "当前没有正在运行的任务"
running_title: "当前正在运行的任务:"
total_prefix: "总数: {{.Count}}\n"
field_id: "ID: "
field_title: "名称: "
field_created: "创建时间: "
field_status: "状态: "
status_running: "运行中"
status_queued: "排队中"
status_cancel_requested: "已请求取消"
queued_empty: "当前没有排队中的任务"
queued_title: "当前排队中的任务:"
truncated_note: "...\n只显示前 10 个任务, 共 {{.Count}} 个任务"
info_added_to_queue_full: "已添加到任务队列\n文件名: {{.Filename}}\n当前排队任务数: {{.QueueLength}}"
info_added_to_queue_prefix: "已添加到任务队列\n"
info_filename_prefix: "文件名: "
info_queue_length_prefix: "\n当前排队任务数: "
rule:
error_get_user_rules_failed: "获取用户规则失败"
error_update_user_failed: "更新用户失败"
info_rule_mode_enabled: "已启用规则模式"
info_rule_mode_disabled: "已禁用规则模式"
error_invalid_rule_type: "无效的规则类型: {{.Type}}\n可用: {{.Available}}"
error_create_rule_failed: "创建规则失败"
info_create_rule_success: "创建规则成功"
prompt_provide_rule_id: "请提供规则ID"
error_invalid_rule_id: "无效的规则ID"
error_delete_rule_failed: "删除规则失败"
info_delete_rule_success: "删除规则成功"
help_usage: "使用方法: /rule <操作> <参数...>"
help_current_mode_enabled: "\n当前已启用规则模式"
help_current_mode_disabled: "\n当前已禁用规则模式"
help_available_ops: "\n\n可用操作:\n"
help_switch_suffix: " - 开关规则模式\n"
help_add_suffix: " <类型> <数据> <存储名> <路径> - 添加规则\n"
help_del_suffix: " <规则ID> - 删除规则\n"
help_existing_rules_prefix: "\n当前已添加的规则:\n"
dir:
error_get_user_dirs_failed: "获取用户文件夹失败"
error_get_user_failed: "获取用户失败"
error_create_dir_failed: "创建文件夹失败"
info_create_dir_success: "文件夹添加成功"
error_invalid_dir_id: "文件夹ID无效"
error_delete_dir_failed: "删除文件夹失败"
info_delete_dir_success: "文件夹删除成功"
error_unknown_operation: "未知操作"
help_usage: "使用方法: /dir <操作> <参数...>"
help_available_ops: "\n\n可用操作:\n"
help_add_suffix: " <存储名> <路径> - 添加路径\n"
help_del_suffix: " <路径ID> - 删除路径\n"
help_add_example_prefix: "\n添加路径示例:\n"
help_add_example_cmd: "/dir add local1 path/to/dir"
help_del_example_prefix: "\n\n删除路径示例:\n"
help_del_example_cmd: "/dir del 3"
help_existing_dirs_prefix: "\n\n当前已添加的路径:\n"
button_default: "默认"
parser:
help_text: |
用法:
/parser install <回复一个文件> - 安装解析器
plugin_not_enabled: "解析器插件功能未启用"
prompt_reply_with_parser_file: "请回复一个包含解析器文件的消息"
error_no_valid_file_in_reply: "回复的消息不包含有效的文件"
error_wrong_file_type: "错误的文件类型"
error_file_too_large: "文件过大"
error_get_filename_failed: "无法获取文件名"
error_only_js_supported: "仅支持 .js 文件作为解析器"
error_download_file_failed: "文件下载失败: {{.Error}}"
error_install_plugin_failed: "插件安装失败: {{.Error}}"
info_install_plugin_success: "插件安装成功: {{.Name}}"
parse:
info_parsing: "正在解析..."
error_parse_text_failed: "Failed to parse text: {{.Error}}"
error_build_storage_select_keyboard_failed: "Failed to build storage selection keyboard: {{.Error}}"
error_build_parsed_text_entity_failed: "Failed to build parsed text entity: {{.Error}}"
info_link_prefix: "\n链接: "
info_author_prefix: "\n作者: "
info_description_prefix: "\n描述: "
info_file_count_prefix: "\n文件数量: "
info_total_size_prefix: "\n预计总大小: "
info_prompt_select_storage: "\n请选择存储位置"
telegraph:
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
info_title_prefix: "标题: "
info_pic_count_prefix: "\n图片数量: "
info_prompt_select_storage: "\n请选择存储位置"
update:
error_version_var_invalid: "当前处于开发版本或版本信息注入失败: {{.Error}}"
error_check_latest_failed: "检测最新版本失败: {{.Error}}"
error_no_release_found: "没有找到版本信息"
info_major_upgrade_required: "检测到大版本更新: {{.Current}} -> {{.Latest}} , 请前往 GitHub 手动下载最新版本并查看迁移指南"
info_already_latest: "当前已经是最新版本: {{.Version}}"
info_new_version_in_docker: |-
发现新版本: {{.Latest}}
当前版本: {{.Current}}
发布时间: {{.PublishedAt}}
由于您正在使用 Docker 部署, 请自行在部署平台上执行更新命令
info_new_version_prompt_upgrade: |-
发现新版本: {{.Latest}}
当前版本: {{.Current}}
文件大小: {{.SizeMB}} MB
下载链接: {{.URL}}
发布时间: {{.PublishedAt}}
升级将重启 Bot , 是否升级?
info_upgrading_with_version: "正在升级中, 当前版本: {{.Current}}"
error_upgrade_failed: "升级失败: {{.Error}}"
info_upgrade_success: "已升级至版本 {{.Version}}\n若 Bot 未自动重启请手动启动"
button_upgrade: "升级"
config:
prompt_select_option: "请选择要配置的选项"
button_filename_strategy: "文件名策略"
error_invalid_callback_data: "无效的回调数据"
error_invalid_template: "无效的模板, 请检查语法\n{{.Error}}"
info_filename_strategy_set: "已将文件名策略设置为: {{.Strategy}}"
prompt_select_filename_strategy: "请选择文件名策略, 当前策略: {{.Strategy}}"
fnametmpl_help: |-
使用该命令设置文件名模板, 示例:
/fnametmpl 图片_{{"{{.msgid}}"}}_{{"{{.msgdate}}"}}.jpg
可用变量:
- {{"{{.msgid}}"}}: 消息ID
- {{"{{.msgtags}}"}}: 消息中的标签, 将以下划线分隔输出
- {{"{{.msggen}}"}}: 根据消息生成的文件名
- {{"{{.msgdate}}"}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
- {{"{{.msgraw}}"}}: 消息的原始文本内容 (不经任何处理)
- {{"{{.origname}}"}}: 媒体的原始文件名 (如果有)
- {{"{{.chatid}}"}}: 消息的聊天ID
模板仅在文件名策略设置为 '自定义模板' 时生效,
且模板解析错误时会回退到默认文件名
info_template_updated: "已更新文件名模板"
info_current_template_prefix: "当前模板: {{.Template}}"
dl:
usage: "用法: /dl <链接1> <链接2> ..."
error_no_valid_links: "没有有效的链接可供下载"
info_files_select_storage: "共 {{.Count}} 个文件, 请选择存储位置"
ytdlp:
usage: "用法: /ytdlp [选项] <URL1> [URL2] ...\n示例:\n /ytdlp https://example.com/video\n /ytdlp --format best https://example.com/video\n /ytdlp --extract-audio --audio-format mp3 https://example.com/video"
error_no_valid_urls: "没有有效的 URL"
info_urls_select_storage: "共 {{.Count}} 个链接, 请选择存储位置"
info_downloading: "正在通过 yt-dlp 下载..."
error_download_failed: "yt-dlp 下载失败: {{.Error}}"
transfer:
usage: |
用法: /transfer <source_storage>:/<source_path> [filter]
示例:
/transfer local1:/downloads
/transfer alist1:/media/photos
/transfer webdav1:/files ".*\.mp4$"
error_invalid_source: "源路径格式无效,应为: storage_name:/path"
error_invalid_target: "目标路径格式无效,应为: storage_name:/path"
error_storage_not_found: "存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
error_storage_not_listable: "存储端 '{{.StorageName}}' 不支持列举文件功能"
error_storage_not_readable: "存储端 '{{.StorageName}}' 不支持读取文件功能"
error_target_not_found: "目标存储端 '{{.StorageName}}' 不存在或您无权访问: {{.Error}}"
info_fetching_files: "正在获取文件列表..."
error_list_files_failed: "获取文件列表失败: {{.Error}}"
error_invalid_regex: "正则表达式无效: {{.Error}}"
error_no_files_to_transfer: "目录中没有可传输的文件"
error_add_task_failed: "添加任务失败: {{.Error}}"
info_task_added: "已添加 {{.Count}} 个文件到传输队列\n总大小: {{.SizeMB}} MB\n任务 ID: {{.TaskID}}"
start_stats: "总文件数: {{.Count}}\n总大小: {{.SizeMB}} MB"
info_files_select_storage: "共 {{.Count}} 个文件 (总大小: {{.SizeMB}} MB),请选择目标存储位置"
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
cancel:
usage: "用法: /cancel <task_id>"
error_cancel_failed: "取消任务失败: {{.Error}}"
info_cancel_requested: "已请求取消任务: {{.TaskID}}"
info_cancelling_task: "正在取消任务..."
media_group:
info_saving_files: "正在保存文件..."
error_build_storage_select_keyboard_failed: "构建存储选择键盘失败: {{.Error}}"
info_group_found_files_select_storage: "共 {{.Count}} 个文件, 请选择存储位置"
storage:
info_filename_prefix: "文件名: "
info_prompt_select_storage: "\n请选择存储位置"
progress:
batch_start_prefix: "开始执行批量下载任务\n总大小: "
batch_processing_prefix: "正在处理批量下载任务\n总大小: "
downloading_prefix: "正在下载\n总大小: "
processing_list_prefix: "\n正在处理:\n"
processing_none: " - 无"
avg_speed_prefix: "\n平均速度: "
current_progress_prefix: "\n当前进度: "
task_canceled: "任务已取消"
task_canceled_with_id: "处理已取消: {{.TaskID}}"
task_failed_with_error: "处理失败: {{.Error}}"
batch_done_prefix: "处理完成\n文件数: "
direct_done_prefix: "处理完成, 文件数量: "
parsed_start_prefix: "开始下载 {{.Site}} 的资源\n总大小: "
parsed_done_prefix: "处理完成, 资源数量: "
telegraph_start_prefix: "开始下载Telegraph\n图片数量: "
telegraph_progress_prefix: "正在下载\n当前进度: "
telegraph_done_prefix: "处理完成\n图片数量: "
file_start_prefix: "开始下载\n文件名: "
file_processing_prefix: "正在处理下载任务\n文件名: "
download_failed_prefix: "下载失败\n文件名: "
download_done_prefix: "下载完成\n文件名: "
file_size_prefix: "\n文件大小: "
save_path_prefix: "\n保存路径: "
total_size_prefix: "\n总大小: "
direct_start: "开始下载, 总大小: {{.SizeMB}} MB ({{.Count}} 个文件)"
file_name_prefix: "文件名: "
error_prefix: "\n错误: "
aria2_start: "等待 Aria2 下载完成 (GID: {{.GID}})..."
aria2_downloading: "Aria2 正在下载 (GID: {{.GID}})\n"
aria2_done: "Aria2 下载完成并已转存 (GID: {{.GID}})\n"
ytdlp_start: "开始使用 yt-dlp 下载 ({{.Count}} 个链接)..."
ytdlp_downloading: "yt-dlp 正在下载 ({{.Count}} 个链接)\n"
ytdlp_done: "yt-dlp 下载完成并已转存 ({{.Count}} 个文件)\n"
downloaded_prefix: "\n已下载: "
current_speed_prefix: "\n当前速度: "
transfer_start_prefix: "正在转存: "
transfer_progress_prefix: "转存进度: "
transfer_uploaded_prefix: "\n已上传: "
transfer_speed_prefix: "\n速度: "
transfer_remaining_time_prefix: "\n剩余时间: "
transfer_processing_prefix: "\n正在处理:\n"
transfer_processing_more: "...和其他 {{.Count}} 个文件\n"
transfer_failed_prefix: "转存失败\n"
transfer_success_prefix: "转存完成\n"
transfer_total_files_prefix: "\n总文件数: "
transfer_total_size_prefix: "\n总大小: "
transfer_elapsed_time_prefix: "\n耗时: "
transfer_avg_speed_prefix: "\n平均速度: "
transfer_failed_files_prefix: "\n失败文件数: "
syncpeers:
start: "正在同步对话列表..."
success: "对话列表同步完成, 共同步 {{.Count}} 个对话"
failed: "对话列表同步失败: {{.Error}}"
aria2:
error_aria2_not_enabled: "Aria2 功能未启用, 请在配置文件中启用"
error_aria2_client_init_failed: "Aria2 客户端初始化失败: {{.Error}}"
info_adding_aria2_download: "正在添加 Aria2 下载任务..."
error_adding_aria2_download: "添加 Aria2 下载任务失败: {{.Error}}"
info_aria2_download_added: "Aria2 下载任务已添加, GID: {{.GID}}"
info_select_storage: "请选择存储位置, 选择后将添加到 Aria2 下载队列"

View File

@@ -1,6 +1,9 @@
package dlutil
import "time"
import (
"fmt"
"time"
)
var threadsLevels = []struct {
threads int
@@ -31,3 +34,23 @@ func GetSpeed(downloaded int64, startTime time.Time) float64 {
}
return float64(downloaded) / elapsed
}
// FormatSize formats a byte size as a human-readable string
func FormatSize(bytes int64) string {
const (
KB = 1024
MB = KB * 1024
GB = MB * 1024
)
switch {
case bytes >= GB:
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
case bytes >= MB:
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
case bytes >= KB:
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
default:
return fmt.Sprintf("%d B", bytes)
}
}

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

@@ -48,4 +48,4 @@ func NewProgressWriter(
wr: wr,
onWrite: onWrite,
}
}
}

View File

@@ -17,6 +17,8 @@ import (
"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"
)
@@ -96,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),
}
}
@@ -111,29 +113,28 @@ func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
return result
}
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
if msg, err := getMessagesRange(ctx, chatID, minId, maxId); err == nil {
return msg, nil
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) (msg []*tg.Message, err error) {
if msg, err = getMessagesRange(ctx, chatID, minId, maxId); err == nil {
return
}
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
if msg, err = getMessagesRange(ctx, int64(channel), minId, maxId); err == nil {
return
}
var userID constant.TDLibPeerID
userID.User(plain)
if msg, err := getMessagesRange(ctx, int64(userID), minId, maxId); err == nil {
return msg, nil
if msg, err = getMessagesRange(ctx, int64(userID), minId, maxId); err == nil {
return
}
var chat constant.TDLibPeerID
chat.Chat(plain)
if msg, err := getMessagesRange(ctx, int64(chat), minId, maxId); err == nil {
return msg, nil
if msg, err = getMessagesRange(ctx, int64(chat), minId, maxId); err == nil {
return
}
return nil, fmt.Errorf("failed to get messages range for chatID %d", chatID)
return nil, fmt.Errorf("failed to get messages range for chat %d: %w", chatID, err)
}
func getMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {

View File

@@ -1,6 +1,12 @@
package tgutil
import (
"bufio"
"context"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/url"
"github.com/gotd/td/telegram/dcs"
@@ -8,24 +14,108 @@ import (
"golang.org/x/net/proxy"
)
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
url, err := url.Parse(proxyUrl)
// httpProxyDialer implements proxy.ContextDialer for HTTP CONNECT proxies
type httpProxyDialer struct {
proxyURL *url.URL
forward proxy.Dialer
}
func (d *httpProxyDialer) Dial(network, addr string) (net.Conn, error) {
return d.DialContext(context.Background(), network, addr)
}
func (d *httpProxyDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
proxyAddr := d.proxyURL.Host
if d.proxyURL.Port() == "" {
if d.proxyURL.Scheme == "https" {
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "443")
} else {
proxyAddr = net.JoinHostPort(d.proxyURL.Hostname(), "80")
}
}
var conn net.Conn
var err error
if ctxDialer, ok := d.forward.(proxy.ContextDialer); ok {
conn, err = ctxDialer.DialContext(ctx, "tcp", proxyAddr)
} else {
conn, err = d.forward.Dial("tcp", proxyAddr)
}
if err != nil {
return nil, fmt.Errorf("failed to connect to proxy: %w", err)
}
// Send CONNECT request
connectReq := &http.Request{
Method: "CONNECT",
URL: &url.URL{Opaque: addr},
Host: addr,
Header: make(http.Header),
}
// Add proxy authentication if provided
if d.proxyURL.User != nil {
username := d.proxyURL.User.Username()
password, _ := d.proxyURL.User.Password()
auth := base64.StdEncoding.EncodeToString([]byte(username + ":" + password))
connectReq.Header.Set("Proxy-Authorization", "Basic "+auth)
}
if err := connectReq.Write(conn); err != nil {
conn.Close()
return nil, fmt.Errorf("failed to write CONNECT request: %w", err)
}
// Read response
br := bufio.NewReader(conn)
resp, err := http.ReadResponse(br, connectReq)
if err != nil {
conn.Close()
return nil, fmt.Errorf("failed to read CONNECT response: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
conn.Close()
return nil, fmt.Errorf("proxy CONNECT failed with status: %s", resp.Status)
}
return conn, nil
}
func newProxyDialer(proxyUrl string) (proxy.ContextDialer, error) {
parsedURL, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
return proxy.FromURL(url, proxy.Direct)
switch parsedURL.Scheme {
case "http", "https":
return &httpProxyDialer{
proxyURL: parsedURL,
forward: proxy.Direct,
}, nil
case "socks5", "socks5h":
dialer, err := proxy.FromURL(parsedURL, proxy.Direct)
if err != nil {
return nil, err
}
return dialer.(proxy.ContextDialer), nil
default:
return nil, fmt.Errorf("unsupported proxy scheme: %s", parsedURL.Scheme)
}
}
func NewConfigProxyResolver() (dcs.Resolver, error) {
resolver := dcs.DefaultResolver()
if config.C().Proxy != "" {
// gloabl proxy, which has lower priority
// global 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,
Dial: dialer.DialContext,
})
}
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
@@ -34,7 +124,7 @@ func NewConfigProxyResolver() (dcs.Resolver, error) {
return nil, err
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
Dial: dialer.DialContext,
})
}
return resolver, nil

View File

@@ -5,6 +5,10 @@ retry = 3 # 下载失败重试次数
threads = 4 # 单个任务下载使用的最大线程数
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
[log]
# 日志级别, 可选: debug, info, warn, error, fatal
level = "debug"
[telegram]
# Bot Token
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
@@ -14,10 +18,32 @@ token = ""
# app_id = 1025907
# app_hash = "452b0359b988148995f22ff0f4229750"
[telegram.proxy]
# 启用代理连接 telegram, 只支持 socks5
# 启用代理连接 telegram
enable = false
url = "socks5://127.0.0.1:7890"
# Aria2 配置
[aria2]
# 启用 Aria2 下载支持
enable = false
# Aria2 RPC URL
url = "http://localhost:6800/jsonrpc"
# Aria2 RPC Secret (如果配置了 rpc-secret)
secret = ""
# 转存完成后删除 Aria2 下载的本地文件
remove_after_transfer = true
# HTTP API 配置
[api]
# 启用 HTTP API
enable = false
# 监听地址
host = "0.0.0.0"
# 监听端口
port = 8080
# 认证 Token (必需)
token = ""
# 存储列表
[[storages]]
# 标识名, 需要唯一
@@ -51,4 +77,4 @@ blacklist = true
[[users]]
id = 123456
storages = ["本机1"]
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储

85
config/flags.go Normal file
View File

@@ -0,0 +1,85 @@
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)")
flags.String("log-level", "", "log level (trace/debug, info, warn, error, fatal)")
// 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"))
viper.BindPFlag("log.level", flags.Lookup("log-level"))
// 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
}

5
config/log.go Normal file
View File

@@ -0,0 +1,5 @@
package config
type logConfig struct {
Level string `toml:"level" mapstructure:"level" json:"level"`
}

View File

@@ -16,6 +16,7 @@ var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageCo
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
storenum.S3: createStorageConfig(&S3StorageConfig{}),
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
storenum.Rclone: createStorageConfig(&RcloneStorageConfig{}),
}
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {

33
config/storage/rclone.go Normal file
View File

@@ -0,0 +1,33 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type RcloneStorageConfig struct {
BaseConfig
// The name of the remote as defined in rclone config
Remote string `toml:"remote" mapstructure:"remote" json:"remote"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
// The path to the rclone config file, if not using the default
ConfigPath string `toml:"config_path" mapstructure:"config_path" json:"config_path"`
// Additional flags to pass to rclone commands
Flags []string `toml:"flags" mapstructure:"flags" json:"flags"`
}
func (r *RcloneStorageConfig) Validate() error {
if r.Remote == "" {
return fmt.Errorf("remote is required for rclone storage")
}
return nil
}
func (r *RcloneStorageConfig) GetType() storenum.StorageType {
return storenum.Rclone
}
func (r *RcloneStorageConfig) GetName() string {
return r.Name
}

View File

@@ -12,6 +12,11 @@ type TelegramStorageConfig struct {
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

@@ -6,8 +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,7 +2,6 @@ package config
import (
"context"
"errors"
"fmt"
"net"
"net/http"
@@ -11,21 +10,22 @@ import (
"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"`
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
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"`
Log logConfig `toml:"log" mapstructure:"log" json:"log"`
Aria2 aria2Config `toml:"aria2" mapstructure:"aria2" json:"aria2"`
API apiConfig `toml:"api" mapstructure:"api" json:"api"`
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
@@ -37,6 +37,20 @@ 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"`
KeepFile bool `toml:"keep_file" mapstructure:"keep_file" json:"keep_file"`
}
type apiConfig struct {
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
Host string `toml:"host" mapstructure:"host" json:"host"`
Port int `toml:"port" mapstructure:"port" json:"port"`
Token string `toml:"token" mapstructure:"token" json:"token"`
}
var cfg = &Config{}
func C() Config {
@@ -52,22 +66,46 @@ 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",
"workers": 3,
"retry": 3,
"threads": 4,
"lang": "zh-Hans",
"workers": 3,
"retry": 3,
"threads": 4,
"log.level": "debug",
// 缓存配置
"cache.ttl": 86400,
@@ -87,18 +125,18 @@ func Init(ctx context.Context) error {
// 数据库
"db.path": "data/saveany.db",
"db.session": "data/session.db",
// API
"api.enable": false,
"api.host": "0.0.0.0",
"api.port": 8080,
"api.token": "",
}
for key, value := range defaultConfigs {
viper.SetDefault(key, value)
}
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
return fmt.Errorf("error saving default config: %w", err)
}
}
if err := viper.ReadInConfig(); err != nil {
fmt.Println("Error reading config file, ", err)
return err
@@ -118,20 +156,11 @@ 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.ConfigErrDuplicateStorageName, 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.ConfigLoadedStorages, 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
}

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

@@ -0,0 +1,250 @@
package aria2dl
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/aria2"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
)
// Execute implements core.Executable.
func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx)
logger.Infof("Starting aria2 download task %s (GID: %s)", t.ID, t.GID)
if t.Progress != nil {
t.Progress.OnStart(ctx, t)
}
// Wait for aria2 download to complete
if err := t.waitForDownload(ctx); err != nil {
// If context was canceled, also cancel the aria2 download
if errors.Is(err, context.Canceled) {
t.cancelAria2Download()
}
logger.Errorf("Aria2 download failed: %v", err)
if t.Progress != nil {
t.Progress.OnDone(ctx, t, err)
}
return err
}
// Transfer downloaded files to storage
if err := t.transferFiles(ctx); err != nil {
logger.Errorf("File transfer failed: %v", err)
if t.Progress != nil {
t.Progress.OnDone(ctx, t, err)
}
return err
}
logger.Infof("Aria2 task %s completed successfully", t.ID)
if t.Progress != nil {
t.Progress.OnDone(ctx, t, nil)
}
// Clean up aria2 download result
if _, err := t.Aria2Client.RemoveDownloadResult(context.Background(), t.GID); err != nil {
logger.Warnf("Failed to remove aria2 download result: %v", err)
}
return nil
}
// waitForDownload waits for aria2 to complete the download
func (t *Task) waitForDownload(ctx context.Context) error {
logger := log.FromContext(ctx)
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
status, err := t.getStatus(ctx)
if err != nil {
return err
}
if t.Progress != nil {
t.Progress.OnProgress(ctx, t, status)
}
// Check if download is complete
if status.IsDownloadComplete() {
// Handle metadata downloads (torrent/magnet) that spawn follow-up downloads
if len(status.FollowedBy) > 0 {
logger.Infof("Switching from metadata GID %s to actual download GID: %s", t.GID, status.FollowedBy[0])
t.GID = status.FollowedBy[0]
continue
}
logger.Infof("Download completed for GID %s", t.GID)
return nil
}
// Check for errors
if status.IsDownloadError() {
return fmt.Errorf("aria2 download error: %s (code: %s)", status.ErrorMessage, status.ErrorCode)
}
if status.IsDownloadRemoved() {
return errors.New("aria2 download was removed")
}
}
}
}
// getStatus retrieves the current status of the download
func (t *Task) getStatus(ctx context.Context) (*aria2.Status, error) {
logger := log.FromContext(ctx)
// Try active/waiting queue first
status, err := t.Aria2Client.TellStatus(ctx, t.GID)
if err == nil {
return status, nil
}
// Check stopped queue
logger.Debugf("Task not in active queue, checking stopped queue")
stoppedTasks, stopErr := t.Aria2Client.TellStopped(ctx, -1, 100)
if stopErr != nil {
return nil, fmt.Errorf("failed to get aria2 status: %w", err)
}
for _, task := range stoppedTasks {
if task.GID == t.GID {
logger.Debugf("Found task in stopped queue with status: %s", task.Status)
return &task, nil
}
}
return nil, fmt.Errorf("task GID %s not found: %w", t.GID, err)
}
// transferFiles transfers downloaded files from aria2 to storage
func (t *Task) transferFiles(ctx context.Context) error {
logger := log.FromContext(ctx)
status, err := t.Aria2Client.TellStatus(ctx, t.GID)
if err != nil {
return fmt.Errorf("failed to get final status: %w", err)
}
if len(status.Files) == 0 {
return errors.New("no files in aria2 download")
}
logger.Infof("Transferring %d file(s) to storage %s", len(status.Files), t.Storage.Name())
transferredCount := 0
for _, file := range status.Files {
if file.Selected != "true" {
logger.Debugf("Skipping unselected file: %s", file.Path)
continue
}
fileName := filepath.Base(file.Path)
// Skip torrent metadata files
if filepath.Ext(fileName) == ".torrent" {
logger.Debugf("Skipping torrent metadata file: %s", fileName)
t.removeFileIfNeeded(file.Path)
continue
}
if err := t.transferFile(ctx, file.Path); err != nil {
return err
}
transferredCount++
t.removeFileIfNeeded(file.Path)
}
if transferredCount == 0 {
return errors.New("no files were transferred")
}
return nil
}
// transferFile transfers a single file to storage
func (t *Task) transferFile(ctx context.Context, filePath string) error {
logger := log.FromContext(ctx)
// Check if file exists
fileInfo, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
logger.Warnf("Downloaded file not found: %s", filePath)
return nil // Not a fatal error, continue with other files
}
return fmt.Errorf("failed to stat file %s: %w", filePath, err)
}
// Open file
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("failed to open file %s: %w", filePath, err)
}
defer f.Close()
// Set content length in context for storage
ctx = context.WithValue(ctx, ctxkey.ContentLength, fileInfo.Size())
// Save to storage
fileName := filepath.Base(filePath)
destPath := filepath.Join(t.StorPath, fileName)
logger.Infof("Transferring file %s to %s:%s", fileName, t.Storage.Name(), destPath)
if err := t.Storage.Save(ctx, f, destPath); err != nil {
return fmt.Errorf("failed to save file %s to storage: %w", fileName, err)
}
logger.Infof("Successfully transferred file %s", fileName)
return nil
}
// removeFileIfNeeded removes a file if RemoveAfterTransfer is enabled
func (t *Task) removeFileIfNeeded(filePath string) {
if config.C().Aria2.KeepFile {
return
}
logger := log.FromContext(t.ctx)
if err := os.Remove(filePath); err != nil {
logger.Warnf("Failed to remove local file %s: %v", filePath, err)
} else {
logger.Debugf("Removed local file %s", filePath)
}
}
// cancelAria2Download cancels the aria2 download task
func (t *Task) cancelAria2Download() {
logger := log.FromContext(t.ctx)
logger.Infof("Canceling aria2 download GID: %s", t.GID)
// Use a background context with timeout for cleanup
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
// Try to force remove the download
if _, err := t.Aria2Client.ForceRemove(ctx, t.GID); err != nil {
logger.Warnf("Failed to cancel aria2 download %s: %v", t.GID, err)
} else {
logger.Infof("Successfully canceled aria2 download %s", t.GID)
}
// Also remove the download result to clean up
if _, err := t.Aria2Client.RemoveDownloadResult(ctx, t.GID); err != nil {
logger.Debugf("Failed to remove download result for %s: %v", t.GID, err)
}
}

View File

@@ -0,0 +1,189 @@
package aria2dl
import (
"context"
"errors"
"fmt"
"strconv"
"sync/atomic"
"time"
"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"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/pkg/aria2"
)
type ProgressTracker interface {
OnStart(ctx context.Context, task *Task)
OnProgress(ctx context.Context, task *Task, status *aria2.Status)
OnDone(ctx context.Context, task *Task, err error)
}
type Progress struct {
msgID int
chatID int64
start time.Time
lastUpdatePercent atomic.Int32
}
// OnStart implements ProgressTracker.
func (p *Progress) OnStart(ctx context.Context, task *Task) {
logger := log.FromContext(ctx)
p.start = time.Now()
p.lastUpdatePercent.Store(0)
logger.Infof("Aria2 task started: message_id=%d, chat_id=%d, gid=%s", p.msgID, p.chatID, task.GID)
ext := tgutil.ExtFromContext(ctx)
if ext == nil {
return
}
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressAria2Start, map[string]any{
"GID": task.GID,
}))); 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(task.TaskID()),
},
},
}},
)
ext.EditMessage(p.chatID, req)
}
// OnProgress implements ProgressTracker.
func (p *Progress) OnProgress(ctx context.Context, task *Task, status *aria2.Status) {
totalLength, _ := strconv.ParseInt(status.TotalLength, 10, 64)
completedLength, _ := strconv.ParseInt(status.CompletedLength, 10, 64)
downloadSpeed, _ := strconv.ParseInt(status.DownloadSpeed, 10, 64)
if totalLength == 0 {
return
}
percent := int((completedLength * 100) / totalLength)
if p.lastUpdatePercent.Load() == int32(percent) {
return
}
p.lastUpdatePercent.Store(int32(percent))
log.FromContext(ctx).Debugf("Aria2 progress update: %s, %d/%d", task.GID, completedLength, totalLength)
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressAria2Downloading, map[string]any{
"GID": task.GID,
})),
styling.Plain(i18n.T(i18nk.BotMsgProgressDownloadedPrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB / %.2f MB", float64(completedLength)/(1024*1024), float64(totalLength)/(1024*1024))),
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentSpeedPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f MB/s", float64(downloadSpeed)/(1024*1024))),
styling.Plain(i18n.T(i18nk.BotMsgProgressAvgSpeedPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f MB/s", dlutil.GetSpeed(completedLength, p.start)/(1024*1024))),
styling.Plain(i18n.T(i18nk.BotMsgProgressCurrentProgressPrefix, nil)),
styling.Bold(fmt.Sprintf("%.2f%%", float64(percent))),
); 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(task.TaskID()),
},
},
}},
)
ext := tgutil.ExtFromContext(ctx)
if ext != nil {
ext.EditMessage(p.chatID, req)
}
}
// OnDone implements ProgressTracker.
func (p *Progress) OnDone(ctx context.Context, task *Task, err error) {
logger := log.FromContext(ctx)
if err != nil {
if errors.Is(err, context.Canceled) {
logger.Infof("Aria2 task %s was canceled", task.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": task.TaskID(),
}),
})
}
} else {
logger.Errorf("Aria2 task %s failed: %s", task.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("Aria2 task %s completed successfully", task.TaskID())
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgProgressAria2Done, map[string]any{
"GID": task.GID,
})),
styling.Plain(i18n.T(i18nk.BotMsgProgressSavePathPrefix, nil)),
styling.Code(fmt.Sprintf("[%s]:%s", task.Storage.Name(), task.StorPath)),
); 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)
}
}
var _ ProgressTracker = (*Progress)(nil)
func NewProgress(msgID int, userID int64) ProgressTracker {
return &Progress{
msgID: msgID,
chatID: userID,
}
}

View File

@@ -0,0 +1,61 @@
package aria2dl
import (
"context"
"fmt"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/pkg/aria2"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/storage"
)
var _ core.Executable = (*Task)(nil)
type Task struct {
ID string
ctx context.Context
GID string
URIs []string
Aria2Client *aria2.Client
Storage storage.Storage
StorPath string
Progress ProgressTracker
}
// Title implements core.Executable.
func (t *Task) Title() string {
return fmt.Sprintf("[%s](Aria2 GID:%s->%s:%s)", t.Type(), t.GID, t.Storage.Name(), t.StorPath)
}
// Type implements core.Executable.
func (t *Task) Type() tasktype.TaskType {
return tasktype.TaskTypeAria2
}
// TaskID implements core.Executable.
func (t *Task) TaskID() string {
return t.ID
}
func NewTask(
id string,
ctx context.Context,
gid string,
uris []string,
aria2Client *aria2.Client,
stor storage.Storage,
storPath string,
progressTracker ProgressTracker,
) *Task {
return &Task{
ID: id,
ctx: ctx,
GID: gid,
URIs: uris,
Aria2Client: aria2Client,
Storage: stor,
StorPath: storPath,
Progress: progressTracker,
}
}

View File

@@ -0,0 +1,209 @@
package aria2dl
import (
"context"
"io"
"testing"
"time"
storconfig "github.com/krau/SaveAny-Bot/config/storage"
"github.com/krau/SaveAny-Bot/pkg/aria2"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
)
type mockStorage struct {
name string
savePath string
}
func (m *mockStorage) Name() string {
return m.name
}
func (m *mockStorage) Type() storenum.StorageType {
return storenum.StorageType("mock")
}
func (m *mockStorage) Init(ctx context.Context, config storconfig.StorageConfig) error {
return nil
}
func (m *mockStorage) Save(ctx context.Context, reader io.Reader, path string) error {
m.savePath = path
return nil
}
func (m *mockStorage) Exists(ctx context.Context, path string) bool {
return false
}
func (m *mockStorage) JoinStoragePath(path string) string {
return path
}
type mockProgress struct {
started bool
done bool
doneErr error
progress int
}
func (m *mockProgress) OnStart(ctx context.Context, task *Task) {
m.started = true
}
func (m *mockProgress) OnProgress(ctx context.Context, task *Task, status *aria2.Status) {
m.progress++
}
func (m *mockProgress) OnDone(ctx context.Context, task *Task, err error) {
m.done = true
m.doneErr = err
}
func TestTaskCreation(t *testing.T) {
ctx := context.Background()
mockStor := &mockStorage{name: "test-storage"}
mockProg := &mockProgress{}
task := NewTask(
"test-task-id",
ctx,
"test-gid",
[]string{"http://example.com/file.zip"},
nil,
mockStor,
"/test/path",
mockProg,
)
if task.ID != "test-task-id" {
t.Errorf("Expected task ID to be 'test-task-id', got '%s'", task.ID)
}
if task.GID != "test-gid" {
t.Errorf("Expected GID to be 'test-gid', got '%s'", task.GID)
}
if task.Type() != tasktype.TaskTypeAria2 {
t.Errorf("Expected task type to be TaskTypeAria2, got '%s'", task.Type())
}
if task.TaskID() != "test-task-id" {
t.Errorf("Expected TaskID() to return 'test-task-id', got '%s'", task.TaskID())
}
if task.Storage.Name() != "test-storage" {
t.Errorf("Expected storage name to be 'test-storage', got '%s'", task.Storage.Name())
}
}
func TestProgressTracker(t *testing.T) {
ctx := context.Background()
mockStor := &mockStorage{name: "test-storage"}
mockProg := &mockProgress{}
task := NewTask(
"test-task-id",
ctx,
"test-gid",
[]string{"http://example.com/file.zip"},
nil,
mockStor,
"/test/path",
mockProg,
)
// Test OnStart
mockProg.OnStart(ctx, task)
if !mockProg.started {
t.Error("Expected OnStart to set started to true")
}
// Test OnProgress
status := &aria2.Status{
GID: "test-gid",
Status: "active",
TotalLength: "1000000",
CompletedLength: "500000",
DownloadSpeed: "100000",
}
mockProg.OnProgress(ctx, task, status)
if mockProg.progress != 1 {
t.Errorf("Expected progress to be 1, got %d", mockProg.progress)
}
// Test OnDone
mockProg.OnDone(ctx, task, nil)
if !mockProg.done {
t.Error("Expected OnDone to set done to true")
}
if mockProg.doneErr != nil {
t.Errorf("Expected doneErr to be nil, got %v", mockProg.doneErr)
}
}
func TestTaskTitle(t *testing.T) {
ctx := context.Background()
mockStor := &mockStorage{name: "test-storage"}
task := NewTask(
"test-task-id",
ctx,
"test-gid-123",
[]string{"http://example.com/file.zip"},
nil,
mockStor,
"/test/path",
nil,
)
title := task.Title()
expectedSubstr := "test-gid-123"
if len(title) == 0 {
t.Error("Expected title to not be empty")
}
// Check if title contains the GID
found := false
for i := 0; i < len(title)-len(expectedSubstr)+1; i++ {
if title[i:i+len(expectedSubstr)] == expectedSubstr {
found = true
break
}
}
if !found {
t.Errorf("Expected title to contain GID '%s', got '%s'", expectedSubstr, title)
}
}
func TestContextCancellation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
mockStor := &mockStorage{name: "test-storage"}
mockProg := &mockProgress{}
task := NewTask(
"test-task-id",
ctx,
"test-gid",
[]string{"http://example.com/file.zip"},
nil, // nil client will cause Execute to fail/timeout
mockStor,
"/test/path",
mockProg,
)
// Just verify the task structure is valid
if task.ctx.Err() != nil {
t.Error("Context should not be cancelled yet")
}
// Wait for context to timeout
<-ctx.Done()
if ctx.Err() == nil {
t.Error("Context should be cancelled after timeout")
}
}

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

View File

@@ -45,9 +45,17 @@ func (t *Task) Execute(ctx context.Context) error {
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
if filename != "" {
file.Name = filename
}
}
// extract filename from URL if Content-Disposition is empty or invalid
if file.Name == "" {
file.Name = parseFilenameFromURL(file.URL)
}
if file.Name == "" {
return fmt.Errorf("failed to determine filename for %s: Content-Disposition header is empty and URL does not contain a valid filename", file.URL)
}
return nil

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"
)
@@ -53,8 +55,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.msgID,
Message: fmt.Sprintf("处理已取消: %s", info.TaskID()),
ID: p.msgID,
Message: i18n.T(i18nk.BotMsgProgressTaskCanceledWithId, map[string]any{
"TaskID": info.TaskID(),
}),
})
}
} else {
@@ -62,8 +66,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.msgID,
Message: fmt.Sprintf("处理失败: %s", err.Error()),
ID: p.msgID,
Message: i18n.T(i18nk.BotMsgProgressTaskFailedWithError, map[string]any{
"Error": err.Error(),
}),
})
}
}
@@ -73,9 +79,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.BotMsgProgressDirectDonePrefix, nil)),
styling.Code(fmt.Sprintf("%d", info.TotalFiles())),
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)
@@ -108,22 +114,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.TotalFiles())),
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)
@@ -164,7 +170,10 @@ 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("开始下载, 总大小: %.2f MB (%d 个文件)", float64(info.TotalBytes())/(1024*1024), info.TotalFiles()))); err != nil {
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
}

View File

@@ -2,11 +2,13 @@ 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"
)
@@ -25,6 +27,8 @@ func (f *File) FileSize() int64 {
return f.Size
}
var _ core.Executable = (*Task)(nil)
type Task struct {
ID string
ctx context.Context
@@ -44,6 +48,11 @@ type Task struct {
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()
@@ -67,6 +76,9 @@ func (t *Task) StorageName() string {
// StoragePath implements TaskInfo.
func (t *Task) StoragePath() string {
if len(t.files) == 1 {
return t.StorPath + "/" + t.files[0].Name
}
return t.StorPath
}

View File

@@ -144,6 +144,41 @@ func tryDecodeGBK(s string) string {
return ""
}
// parseFilenameFromURL extracts filename from URL path
// This is used as a fallback when Content-Disposition is not available
func parseFilenameFromURL(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil {
return ""
}
// Get the path part and extract the last segment
path := parsed.Path
if path == "" {
return ""
}
// URL decode the path first
decodedPath, err := url.PathUnescape(path)
if err != nil {
decodedPath = path
}
// Get the last segment of the path
lastSlash := strings.LastIndex(decodedPath, "/")
if lastSlash == -1 {
return decodedPath
}
filename := decodedPath[lastSlash+1:]
// Remove query string if somehow still present
if idx := strings.Index(filename, "?"); idx != -1 {
filename = filename[:idx]
}
return filename
}
// parseFilenameFallback manually parses filename= when mime.ParseMediaType fails
func parseFilenameFallback(cd string) string {
// Look for filename= (case-insensitive)

View File

@@ -0,0 +1,73 @@
package directlinks
import (
"testing"
)
func TestParseFilenameFromURL(t *testing.T) {
tests := []struct {
name string
url string
expected string
}{
{
name: "simple filename",
url: "https://example.com/files/document.pdf",
expected: "document.pdf",
},
{
name: "filename with encoded characters",
url: "https://example.com/files/%E6%B5%8B%E8%AF%95.zip",
expected: "测试.zip",
},
{
name: "filename with query string in URL",
url: "https://example.com/files/image.png?token=abc123",
expected: "image.png",
},
{
name: "nested path",
url: "https://example.com/a/b/c/file.txt",
expected: "file.txt",
},
{
name: "URL with port",
url: "https://example.com:8080/downloads/archive.tar.gz",
expected: "archive.tar.gz",
},
{
name: "empty path",
url: "https://example.com",
expected: "",
},
{
name: "root path only",
url: "https://example.com/",
expected: "",
},
{
name: "filename with spaces encoded",
url: "https://example.com/my%20file%20name.pdf",
expected: "my file name.pdf",
},
{
name: "complex encoded filename",
url: "https://example.com/downloads/%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6.docx",
expected: "中文文件.docx",
},
{
name: "invalid URL",
url: "://invalid-url",
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := parseFilenameFromURL(tt.url)
if result != tt.expected {
t.Errorf("parseFilenameFromURL(%q) = %q, want %q", tt.url, result, tt.expected)
}
})
}
}

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,17 +2,21 @@ 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
@@ -20,8 +24,8 @@ type Task struct {
StorPath string
item *parser.Item
httpClient *http.Client // [TODO] btorrent support?
progress ProgressTracker
stream bool
progress ProgressTracker
stream bool
totalResources int64
downloaded atomic.Int64 // downloaded resources count
@@ -32,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
}

View File

@@ -9,6 +9,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/tgutil"
)
@@ -29,7 +31,7 @@ func (p *Progress) OnStart(ctx context.Context, info TaskInfo) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
if err := styling.Perform(&entityBuilder,
styling.Plain("开始下载Telegraph\n图片数量: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressTelegraphStartPrefix, nil)),
styling.Code(fmt.Sprintf("%d", info.TotalPics())),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
@@ -65,7 +67,7 @@ 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.BotMsgProgressTelegraphProgressPrefix, nil)),
styling.Code(fmt.Sprintf("%d/%d", info.Downloaded(), info.TotalPics())),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entities: %s", err)
@@ -101,8 +103,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 {
@@ -110,8 +114,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(),
}),
})
}
}
@@ -121,9 +127,9 @@ func (p *Progress) OnDone(ctx context.Context, info TaskInfo, err error) {
entityBuilder := entity.Builder{}
if err := styling.Perform(&entityBuilder,
styling.Plain("处理完成\n图片数量: "),
styling.Plain(i18n.T(i18nk.BotMsgProgressTelegraphDonePrefix, nil)),
styling.Code(fmt.Sprintf("%d", info.TotalPics())),
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)

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