Compare commits

..

390 Commits

Author SHA1 Message Date
krau
0e951f641c fix(storage): add panic recovery for malformed MP4 files
- Add defer/recover in getMP4Meta to catch panics from gomedia library
- Implement fallback to ffprobe when MP4 parsing fails
- Fixes "no vosdata" panic when processing certain MP4 files

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-31 12:14:49 +08:00
krau
980455fd24 fix: remove go generate command from build process in Dockerfile and workflow 2025-08-27 11:17:03 +08:00
krau
24978470cd feat: add go generate command to build process and update go.mod dependencies 2025-08-27 11:12:42 +08:00
krau
215e082028 feat: implement internationalization support and update help commands 2025-08-27 11:09:38 +08:00
krau
a7b93e57fc refactor: js api 2025-08-24 22:49:44 +08:00
krau
a4b3b459a9 docs: change service restart policy to always 2025-08-24 14:47:13 +08:00
krau
06f326088a fix: remove redundant check for current version equality in update command 2025-08-24 14:37:01 +08:00
krau
b7d3ec6230 docs: update upgrade command 2025-08-24 14:26:47 +08:00
krau
f812990e1c feat: update help command to include usage and feedback group links 2025-08-24 14:18:43 +08:00
krau
492900bbef feat: add update command and callback for version checking and upgrading 2025-08-24 14:16:26 +08:00
krau
764be2a083 fix: improve error handling in config initialization 2025-08-24 14:16:23 +08:00
krau
46c21b77e9 feat: enhance Save method to validate chat ID and adjust ForceFile logic for image uploads 2025-08-24 12:02:27 +08:00
krau
8b389a58d5 fix: improve chat ID parsing in Save method and fallback to configured chat_id 2025-08-24 11:46:12 +08:00
krau
25ad9befa0 feat: add ForceFile option to TelegramStorageConfig and update Save method 2025-08-24 11:38:44 +08:00
krau
e824b210d1 feat: improve telegram storage chat ID parsing logic 2025-08-24 11:29:19 +08:00
krau
ae0aa7db3f fix: skip overwrite dirpath and storage when rule miss match 2025-08-24 11:28:37 +08:00
krau
226c15ef08 feat: add NormalizePathname function and update task handling for parsed items 2025-08-24 10:28:50 +08:00
krau
9b3f955e48 feat: use default HTTP client from netutil for task creation 2025-08-24 09:37:52 +08:00
krau
4997ec408f docs: update parsers 2025-08-23 20:42:51 +08:00
krau
0756cc9eb1 fix: improve parser configuration handling and default values 2025-08-23 20:40:13 +08:00
krau
37c32a23d4 feat: add Kemono parser with download info extraction and API handling 2025-08-23 20:18:02 +08:00
krau
3aa1e2eaed feat: enhance text message handling and parser configuration 2025-08-23 20:17:56 +08:00
krau
b87dd68880 feat: proxy client for parser 2025-08-23 20:17:24 +08:00
krau
68e5a51300 feat: file name staregy 2025-08-23 17:16:51 +08:00
krau
7300e54c40 refactor: rule package 2025-08-23 16:14:12 +08:00
krau
94f796d0e8 refactor: move version to config package 2025-08-23 16:10:02 +08:00
krau
c023fd869d feat: refactor jsParser to use ParserMethod constants and remove redundant locking in ParseWithContext 2025-08-23 16:04:32 +08:00
krau
e5d1e143e0 feat: configurable parser and refactor config 2025-08-23 14:29:32 +08:00
krau
03eb4f8a18 feat: update parser interface to include context in Parse method 2025-08-23 14:01:00 +08:00
krau
231eb61d25 docs: update parser plugin readme and add example danbooru parser 2025-08-23 12:35:04 +08:00
krau
fd1b586b8d feat: inject ghttp to js vm 2025-08-23 12:34:39 +08:00
krau
d035a3409e feat: support multiple parser additions and include media size in Twitter parser 2025-08-22 23:11:16 +08:00
krau
6112f6c240 feat: truncate item description to 233 characters in parsed text 2025-08-22 15:00:09 +08:00
krau
18eedf2edb docs: update parsers configuration and features 2025-08-22 10:02:44 +08:00
krau
5f9bba9ff7 docs: update contribute and parser help 2025-08-22 09:56:53 +08:00
krau
0d3d2209be docs: update readme 2025-08-22 09:28:24 +08:00
Krau
302db2fe75 feat: parse url with js plugins support (#96)
* feat: WIP. add parser functionality and text message handling

* fix: use json to marshal js result

* feat: add metadata handling and version validation for jsParser

* refactor: rename parser package to parsers and restructure parser handling

* refactor: core code struct and impl parse task handle

* feat: impl parsed download

* fix: seek cache file when processing tph picture

* feat: implement parsed task handling and progress tracking

* feat: enhance task processing with concurrency control and progress tracking

* feat: add resource ID generation and improve resource processing handling

* feat: improve message formatting in parsed text and progress completion

* feat: add example js plugin

* feat: implement Twitter parser

* fix: twitter parse video json decode error

* feat: impl stream mode for parse task
2025-08-21 23:48:17 +08:00
krau
79386bdd7d fix: improve error handling in recovery middleware 2025-08-21 14:39:41 +08:00
krau
f0607de2cc fix: update gotgproto dependency version 2025-08-15 16:49:18 +08:00
krau
b2bfc96a8f fix: get user client panic 2025-08-03 22:25:56 +08:00
krau
0c5bb2ba77 docs(zh): update watch feat 2025-08-03 17:35:47 +08:00
krau
9cc87380ff feat: update bot commands and help 2025-08-03 17:26:47 +08:00
krau
46afc14322 fix: debounce send media event and ignore edit or delete updates 2025-08-03 17:22:55 +08:00
krau
0c16650ea5 fix: watch chat check 2025-08-03 17:04:26 +08:00
krau
133453b5d4 feat: implement watch for monitoring chat messages
- Added a new command handler for /watch that allows users to listen to messages from a specified chat and save them according to storage rules.
- Introduced filtering options for messages using regular expressions.
- Implemented functionality to start and stop watching chats, including error handling for invalid inputs and user settings.
- Created a new utility package for message element handling related to the watch feature.
- Updated the user model to manage watched chats, including methods to add, remove, and check if a chat is being watched.
2025-08-03 16:55:56 +08:00
krau
8f9ef07d1c refactor: replace functions.GetMediaFileNameWithId with tgutil.GetMediaFileName for better media file name handling 2025-08-02 16:55:12 +08:00
krau
36285a0700 feat(storage/telegram): add support for uploading images excluding webp format 2025-08-02 11:41:18 +08:00
krau
ccf206d176 feat(storage/telegram): enhance file upload handling with content length support and media type differentiation 2025-08-02 11:22:14 +08:00
krau
4c851cbbaf ci: fix version inject 2025-08-02 10:58:29 +08:00
krau
b9d14f79c8 refactor: simplify dler client interface 2025-08-02 10:01:59 +08:00
krau
ee5e0b8ff0 fix: use gotgproto fork and upgrade deps 2025-07-30 17:46:36 +08:00
krau
6423fb25a7 fix: update max upload part size to use uploader's maximum value and adjust document upload options 2025-07-28 14:58:01 +08:00
krau
03907f2d32 fix: improve error handling during file download in stream mode 2025-07-28 14:37:08 +08:00
github-actions[bot]
9e5042bda1 docs(contributor): contrib-readme-action has updated readme (#92)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-07-09 20:37:16 +08:00
krau
4ebacb02c1 chore: tidy go mod 2025-07-09 20:35:55 +08:00
Abner
818ac9b240 feat: remote config 2025-07-09 20:30:29 +08:00
krau
fc4a112f08 fix: enhance filename generation by using media file name if available 2025-07-07 14:12:33 +08:00
krau
7a2274baa0 refactor: update configuration file structure and improve comments for clarity 2025-07-07 13:57:58 +08:00
krau
69a3ed6f4e fix: parse chat ID correctly in Save method for Telegram storage 2025-07-07 13:48:16 +08:00
krau
36f3dd83fc refactor: remove error wrapping in retry middleware skip logic 2025-07-07 13:44:32 +08:00
krau
501b9d844a docs: update userbot content 2025-07-04 16:26:17 +08:00
krau
03cec7ec01 docs: add IS-ALBUM rule type 2025-07-04 16:18:01 +08:00
krau
dc0debcd1c fix: add unique filename handling with error logging in Save method for Minio and Webdav 2025-07-04 16:10:42 +08:00
krau
4b136bd41e feat: add test case for nested directory file writing 2025-07-04 16:08:01 +08:00
krau
d703f11ea0 feat: implement album handling rules and refactor related logic 2025-07-04 16:01:29 +08:00
krau
3ce9926967 feat: handle media group message in updates 2025-07-04 14:36:03 +08:00
krau
80146176f0 feat: make retry middleware configurable 2025-07-04 13:38:55 +08:00
krau
14ba2afdf8 chore: update funding url 2025-07-04 13:38:28 +08:00
krau
f4d427a1cb fix(dockerignore): ensure docs directory is ignored correctly 2025-06-30 22:49:01 +08:00
krau
f84c83a7e2 fix(ci): try speed up docker build 2025-06-30 22:47:47 +08:00
krau
cb6540c017 fix(minio): trim leading slash in JoinStoragePath method 2025-06-30 22:19:33 +08:00
krau
e7bab27543 feat: add user client inject in file link handler 2025-06-29 23:08:48 +08:00
krau
f693bd6103 refactor: update file handling to use new downloader interface; remove unused tdler package 2025-06-29 23:00:40 +08:00
krau
75f52569a0 deps: upgrade 2025-06-29 22:26:52 +08:00
krau
c795f957a9 refactor: user client and proxy handling; remove unused auth code 2025-06-29 22:25:05 +08:00
krau
3b85911e3d fix: mimetype nil pointer 2025-06-20 22:42:57 +08:00
krau
336309fad0 chore: remove unuse comment 2025-06-20 22:29:37 +08:00
krau
394cdff865 feat: regex filter for batch save message 2025-06-20 22:25:46 +08:00
krau
40cb3dad9d feat: handle grouped msgs, close #72 2025-06-20 22:07:21 +08:00
krau
2979628cf7 docs(zh): add hook docs 2025-06-20 21:46:00 +08:00
krau
c82c2462bf feat: exec command hook , close #79 2025-06-20 21:30:50 +08:00
krau
88128ecac2 fix: cache init after config 2025-06-20 21:27:45 +08:00
krau
758564d436 feat(config): add cache configuration options for TTL, num counters, and max cost 2025-06-18 10:51:03 +08:00
krau
f5e33472eb feat: add IterMessages function for message iteration with error handling 2025-06-18 10:50:54 +08:00
krau
4df2c5a06d refactor: replace key package with ctxkey for context keys 2025-06-17 22:19:06 +08:00
krau
eb6f8675a4 fix(minio): pass the size to minio puitobject 2025-06-17 22:10:20 +08:00
krau
473a5b9413 chore: update help text 2025-06-16 17:31:34 +08:00
krau
6c2abe3025 chore: remove deprecated config 2025-06-16 17:11:26 +08:00
krau
e7e5b9f434 docs: add repo link 2025-06-16 17:05:31 +08:00
krau
d4d39d1c07 chore: update readme 2025-06-16 16:57:39 +08:00
krau
73b5f1b18e docs: add en translate 2025-06-16 16:30:45 +08:00
krau
837700bf63 docs: adjust font size 2025-06-16 16:08:03 +08:00
krau
53e6d7cc54 ci(docs): fix ci 2025-06-16 16:01:50 +08:00
krau
4206d1fe96 docs: refactor 2025-06-16 15:58:03 +08:00
krau
6566dbbf96 chore: add new storage configuration for Telegram channel 2025-06-16 00:24:38 +08:00
krau
44c0c784d7 fix: default dir call back data type 2025-06-16 00:09:29 +08:00
krau
8ebf96444d fix: optimize directory data handling in BuildSetDirKeyboard function 2025-06-16 00:08:52 +08:00
Krau
900823cdb9 refactor: refactor task logic for better scalability (#76)
* refactor: a big refactor. wip

* refactor: port handle file

* refactor: place all handlers

* fix: task info nil pointer

* feat: enhance task progress tracking and context management

* feat: cancel task

* feat: stream mode

* feat: silent mode

* feat: dir cmd

* refactor: remove unused old file

* feat: rule cmd

* feat: handle silent mode

* feat: batch task

* fix: batch task progress and temp file cleanup

* refactor: update file creation and cleanup methods for better resource management

* feat: add save command with silent mode handling

* feat: message link

* feat: update message prompts to include file count in storage selection

* feat: slient save links

* refactor: reduce dup code

* feat: rule type

* feat: chose dir

* feat: refactor file handling and storage rules, improve error handling and logging

* feat: rule mode

* feat: telegraph pics

* fix: tphpics nil pointer and inaccurate dirpath

* feat: silent save telegraph

* feat: add suffix to avoid file overwrite

* feat: new storage telegram

* chore: tidy go mod
2025-06-15 23:57:49 +08:00
krau
280745cae3 feat: update database driver to use gormlite and add new dependencies 2025-06-11 10:01:24 +08:00
krau
e85d3c9441 feat: rename file only when storagePath exists 2025-06-11 09:54:08 +08:00
krau
9d3a3a8dcd feat: update fetch message to support user client 2025-06-09 16:54:51 +08:00
krau
19535d0438 feat: parse media group, wip 2025-06-09 16:17:27 +08:00
krau
693e20b066 Deprecated public copy media feat 2025-06-09 15:50:53 +08:00
krau
56ea1d6f36 feat: parse message link via userbot, close #70 2025-06-09 14:33:40 +08:00
krau
95522d03f9 chore: update bug report template to ensure required fields are validated 2025-06-09 09:03:50 +08:00
krau
2bc290b57d fix: git commit display 2025-06-08 16:07:23 +08:00
krau
c7c458f147 feat: add user client 2025-06-08 15:36:14 +08:00
krau
481427683e chore: translate config package 2025-06-08 13:37:51 +08:00
krau
c798c7ae99 feat: i18n with default lang zh-Hans (translating) 2025-06-08 11:01:33 +08:00
krau
0422c1ac3e refactor: improve log format 2025-06-08 11:00:29 +08:00
krau
f0445fe26f chore: upgrade deps 2025-06-08 09:25:56 +08:00
krau
a3628be024 chore: remove unuse dep 2025-06-08 09:23:29 +08:00
krau
a9c56892c3 refactor: move log for pub copy media req and provide a tip 2025-05-29 22:00:12 +08:00
krau
015539c009 fix: set filedbid for silent mode task, close #67 2025-05-29 21:55:04 +08:00
krau
71844deab1 fix: public media copy message check 2025-05-28 17:02:51 +08:00
krau
55fed6389e chore: add experimental to nav 2025-05-28 16:56:38 +08:00
krau
8ce5c2e007 feat: 添加实验性功能文档,包含存储规则和媒体消息发送说明 2025-05-28 16:55:18 +08:00
krau
6ecfbd8385 feat: add public media copy 2025-05-28 16:43:11 +08:00
krau
6c2bfd72cd chore: commented-out code for user storages check, for we have send_here feature now 2025-05-28 16:10:31 +08:00
Krau
8ea5be5b90 Merge pull request #65 from krau/upload-telegram
feat: send media to telegram, close #47
2025-05-28 15:57:43 +08:00
krau
7f483056e0 feat: send media to telegram, close #47 2025-05-28 15:57:10 +08:00
krau
a6f88d7f75 chore: fix typo in Dockerfile ARG variable name for GitCommit 2025-05-19 22:14:50 +08:00
krau
b757df0b5e chore: reorganize Docker build workflow and enhance build arguments for versioning 2025-05-19 22:02:17 +08:00
krau
b017046c8b chore: simplify Dockerfile by removing unnecessary user and permission setup 2025-05-19 21:43:47 +08:00
krau
a474fdf6ae chore: update .dockerignore and Dockerfile for improved build context and permissions 2025-05-19 09:23:24 +08:00
krau
729e688748 fix: cleaning up the cache folder caused permission issues 2025-05-19 09:23:10 +08:00
krau
9ea4857cd9 chore: update issue template labels for consistency 2025-05-18 18:14:33 +08:00
krau
8bf7bc0e85 chore: add .dockerignore to exclude unnecessary files from Docker context 2025-05-18 18:14:30 +08:00
krau
26e344a6f6 refactor: remove unused conversation handling code and simplify delDir function parameters 2025-05-18 14:29:59 +08:00
krau
8f0744077e fix: update success message for batch task addition in handle_save function 2025-05-18 14:28:20 +08:00
krau
ed99a37831 fix: add unique id to task struct to avoid duplicate file name overwrite, close #59 2025-05-09 08:58:30 +08:00
krau
488d709d85 chore: update contributors section in README to remove specific name 2025-05-08 21:04:56 +08:00
krau
66454b082a fix: improve logger initialization and reduce cache TTL 2025-05-08 21:03:48 +08:00
Krau
70e83e62d9 Merge pull request #58 from AHCorn/main
fix: docker cache permission issue (#57)
2025-05-08 20:44:46 +08:00
安和
d2ddb9193a fix: docker cache permission issue (#57) 2025-05-08 18:38:28 +08:00
krau
5f78db90c7 fix: webdav url escape 2025-05-07 12:05:10 +08:00
krau
c3a4702e79 fix: allow custom file name for cached files in FileFromMessage function 2025-05-07 11:38:56 +08:00
krau
e731cfee9e chore: upgrade deps 2025-05-07 09:24:36 +08:00
krau
75de86fe97 chore: add funding configuration file 2025-05-07 09:21:30 +08:00
krau
6d4e97b4bb chore: add issue templates 2025-05-07 09:18:46 +08:00
krau
239d5ad562 feat: update database session configuration and retry settings 2025-05-07 08:59:15 +08:00
krau
e76f191922 chore: remove executable compression from build-release workflow 2025-04-29 09:37:25 +08:00
krau
a619ed2f22 typo: client_test filename 2025-04-27 08:32:47 +08:00
krau
838dfc35a1 fix: webdav client implement, close #49 2025-04-27 08:31:50 +08:00
krau
6ecee6d561 Merge branch 'main' of https://github.com/krau/SaveAny-Bot 2025-04-20 14:57:08 +08:00
krau
c1827f93a9 chore: rename workflow to 'Update Contributors' and trigger on workflow_dispatch 2025-04-20 14:57:06 +08:00
Krau
aaf3f7c35f Merge pull request #51 from krau/contributors-readme-action-yX7LIfZQ3S
docs(contributor): contributors readme action update
2025-04-20 14:56:49 +08:00
github-actions[bot]
02fbea4eb0 docs(contributor): contrib-readme-action has updated readme 2025-04-20 06:56:16 +00:00
krau
bf9aef6eb7 chore: update contributor workflow to trigger on workflow_dispatch instead of tags 2025-04-20 14:55:48 +08:00
krau
230c07fd55 feat: add rate limiting middleware to enhance bot performance 2025-04-20 14:50:06 +08:00
krau
18de349dc3 chore: update contributor workflow to trigger on tags and add contributor to README 2025-04-18 21:24:28 +08:00
krau
cef1a5c194 chore: update golang.org/x/net to latest 2025-04-18 21:19:41 +08:00
krau
99f8f0cb27 feat: add automated contributors section to README 2025-04-18 21:17:31 +08:00
krau
789c14134c chore: update .gitignore to include .vscode/ and remove launch.json 2025-04-18 21:12:59 +08:00
krau
5bb3b595aa docs: update contributing guides 2025-04-18 21:12:44 +08:00
krau
609289c16a perf: optimize user storage retrieval and remove unused rate limiting middleware 2025-04-15 21:04:48 +08:00
krau
c8c348a182 feat: batch save files 2025-04-12 16:27:23 +08:00
krau
725acd0199 feat: refactor caching logic to use gocache for better compatibility 2025-04-12 15:07:43 +08:00
krau
166c27c70f feat: automatic file organization based on rules, close #28 2025-04-12 14:27:13 +08:00
krau
3bdef20e85 feat: add expiration handling for database entries and enhance user model with rules 2025-04-12 11:14:13 +08:00
krau
50fba3f910 feat: add configurable timeout for Telegram client initialization 2025-04-07 10:23:50 +08:00
krau
87d3f14392 docs: update README to include sponsorship information and improve formatting 2025-04-05 00:01:48 +08:00
krau
30452c8d46 docs: consolidate message link information in help.md 2025-04-04 08:46:48 +08:00
krau
300f7723af fix: enhance webdav client impl 2025-03-31 17:34:24 +08:00
krau
491ba55f1e feat: add support for handling unsupported stream storage in download process 2025-03-26 10:35:40 +08:00
krau
32519b8c08 docs: add note about unsupported storage backends in Stream mode 2025-03-26 10:25:36 +08:00
krau
7ffd9891a0 fix: not pass content length when uploading in non stream mode 2025-03-26 10:22:38 +08:00
krau
347a60f1f7 fix: implement image extraction from Telegraph nodes 2025-03-24 22:04:55 +08:00
krau
da69fe1354 feat: enhance file name generation to include media extensions 2025-03-24 21:36:13 +08:00
krau
746ca026ba docs: remove outdated information about stream mode support in help documentation 2025-03-22 15:48:36 +08:00
krau
a8c64675e5 docs: update help documentation to include supported message links 2025-03-22 15:48:05 +08:00
krau
3918f6eee2 feat: add version and commit information to help text in start command 2025-03-22 15:45:34 +08:00
krau
8d44b43c82 fix: remove caching logic for Telegram messages in GetTGMessage function, close #40 2025-03-22 15:41:20 +08:00
krau
f14c4367f8 feat: cancel download telegraph task 2025-03-22 12:08:19 +08:00
krau
3e3a320672 feat: download telegraph images , close #5 2025-03-22 11:52:43 +08:00
krau
19efab0665 feat: implement GenFileNameFromMessage function for improved file naming 2025-03-22 09:33:50 +08:00
krau
635f00ac71 fix: reorganize cache destination path handling in processPendingTask function 2025-03-21 23:28:14 +08:00
krau
2d2becccf6 refactor: update storage interface to use io.Reader for Save method and remove stream implementations 2025-03-21 23:05:09 +08:00
krau
ed0837a89b refactor: replace logger usage with common.Log for consistent logging 2025-03-21 21:07:53 +08:00
krau
65fee89e14 feat: refactor storage configuration to use dedicated storage package and add new storage types
BREAKING CHANGE: remove deprecated config
2025-03-21 20:52:41 +08:00
krau
8e180006f0 chore: update dependencies to latest versions 2025-03-16 21:55:52 +08:00
krau
721c9666eb refactor: streamline storage configuration loading and remove redundant code 2025-03-11 22:24:52 +08:00
krau
6f35401181 docs: update links in README_EN.md for consistency 2025-03-11 21:46:04 +08:00
Krau
72ae2ce079 Merge pull request #35 from ysicing/main
feat: add Minio storage support
2025-03-11 21:41:43 +08:00
ysicing
495ad3ea5c feat: add Minio storage support
Signed-off-by: ysicing <i@ysicing.me>
2025-03-11 21:29:35 +08:00
krau
3def9df4b4 docs: update alist faq 2025-03-03 10:59:08 +08:00
krau
790a32d297 fix(alist): do not upload file as task to prevent alist cache full file 2025-03-03 10:58:03 +08:00
krau
f7779224ef docs: update example link 2025-03-01 15:54:21 +08:00
krau
7d899ae088 ci: Is anyone really using Windows ARM? 2025-03-01 14:01:10 +08:00
krau
7e67bdb7e2 fix: update executable compression condition for Windows ARM64 in build-release workflow 2025-03-01 13:55:42 +08:00
krau
0071780ff4 typo: deploy 2025-03-01 13:44:04 +08:00
krau
0a95431468 feat: add name to build release workflow 2025-03-01 13:39:08 +08:00
krau
34525c5b11 feat: add docs 2025-03-01 13:37:09 +08:00
krau
6ac6d79fb6 feat: update docker-compose.yml to use host network mode for accessing host services 2025-03-01 12:31:20 +08:00
krau
f21a82ad43 chore: clean up README.md by removing unnecessary demo video section 2025-03-01 12:29:43 +08:00
Krau
73f6647f8d Merge pull request #33 from krau/dev-stream
impl webdav stream mode & progress callback for stream mode
2025-03-01 12:24:46 +08:00
krau
6fbb4609f9 feat: show progress for stream mode 2025-03-01 12:22:50 +08:00
krau
802c908384 feat: refactor webdav client and implement custom upload stream handling 2025-03-01 12:06:55 +08:00
Krau
5d403056d0 Merge pull request #32 from krau/dev-stream
feat: add stream upload support and related configurations
2025-02-28 12:17:10 +08:00
krau
8e2dd37155 feat: add stream upload support and related configurations 2025-02-28 11:09:24 +08:00
krau
9c7ed833fd ci: add upx support 2025-02-28 09:45:34 +08:00
Krau
f9d601bd8a Merge pull request #30 from krau/dev
feat: cancel task
2025-02-27 22:34:58 +08:00
krau
152f473131 fix: delete done task 2025-02-27 22:25:10 +08:00
krau
7015081a84 feat: add context cancellation handling in saveFileWithRetry function 2025-02-27 22:07:41 +08:00
krau
be6444cf96 feat: implement task cancellation feature and update task handling 2025-02-27 22:02:16 +08:00
krau
98ba7c50e7 refactor: remove unused StoragePath initialization in AddToQueue function 2025-02-27 21:32:14 +08:00
krau
0c31d908cc feat: add dir command at init and show dirs in dir command help 2025-02-25 16:23:24 +08:00
krau
9e776b22fb feat: set dir for storages 2025-02-25 16:17:20 +08:00
krau
d6f8603656 docs: update change bot token comment 2025-02-25 15:09:44 +08:00
krau
9c42bee662 refactor: spilt handlers file 2025-02-24 17:50:35 +08:00
krau
b96340dd46 refactor: add err var ErrEmptyMessage 2025-02-24 17:41:36 +08:00
krau
a5ba01e219 typo: config example 2025-02-24 17:34:44 +08:00
krau
d00e907735 typo: config example 2025-02-24 17:34:11 +08:00
Twilight
418f9bd2bc 更详细的 config 配置以及更完善的 README (#25)
* Add files via upload

* Add files via upload

* Add OpenWrt auto-start and shortcut script instructions, Optimize file link reference method.

* add more detailed instructions.

* Add files via upload
2025-02-24 17:32:53 +08:00
krau
28b4585dba chore: update configuration for user storage filtering and add base path for file saving 2025-02-23 18:12:23 +08:00
krau
d2669f0c99 feat: add logging for file save operations in storage modules 2025-02-21 14:04:32 +08:00
krau
c9921926e3 chore: add configurable thread count 2025-02-21 13:53:46 +08:00
krau
d7cd2ede01 feat: add configurable thread count for file processing 2025-02-21 13:51:30 +08:00
krau
ed21b65c98 perf: refactor file download to support multithreading 2025-02-21 13:49:15 +08:00
krau
8975589c43 refactor: file download process and enhance progress tracking 2025-02-21 11:16:45 +08:00
krau
27dca2e343 perf: add UserStorages map and implement GetUserStorages function for user-specific storage retrieval 2025-02-20 22:57:45 +08:00
krau
5c8261c34a refactor: improve error handling in getSelectStorageMarkup for user retrieval 2025-02-20 22:53:08 +08:00
krau
cbc2dc82d8 fix: update EffectiveUser cannot obtain the accurate user, use GetUserChat instead 2025-02-20 22:52:16 +08:00
krau
09a7c5597d fix: add UserID to link message and enforce default storage setting in silent handler 2025-02-19 14:33:03 +08:00
krau
f73f18e90d fix: update user and file deletion to use unscoped delete; add user synchronization logic 2025-02-19 14:19:39 +08:00
krau
ab822c2fe6 fix: update create user to new config 2025-02-19 14:06:33 +08:00
krau
2579044841 fix: update permission check 2025-02-19 13:56:30 +08:00
krau
88a02aae8d chore: update config example and docker compose file 2025-02-19 13:42:12 +08:00
krau
ab374a870b chore: update readme and add english version
Co-authored-by: AHCorn <42889600+AHCorn@users.noreply.github.com>
2025-02-19 13:41:57 +08:00
krau
3a1b8f34ea chore: translate some import log to cn 2025-02-19 12:36:48 +08:00
krau
c4eb824457 feat: set default storage by inline keyboard 2025-02-19 12:23:12 +08:00
krau
692e970772 feat!: (WIP) switched back to using config files config storages because the conversation handling is shit 2025-02-19 11:05:30 +08:00
krau
80696c9661 feat: (WIP) add storage
Co-authored-by: AHCorn <42889600+AHCorn@users.noreply.github.com>
2025-02-18 22:53:07 +08:00
krau
18cd480264 fix: add json tag for config 2025-02-18 19:53:01 +08:00
krau
dfde65c28e feat: (WIP) migrate storage configuration to user-specific models and remove deprecated storage loading 2025-02-18 19:45:06 +08:00
krau
968547b005 feat!: (WIP) decouple storage, users, and configuration files to support multiple users 2025-02-18 17:17:02 +08:00
krau
9367419156 chore: update config comment 2025-02-17 16:46:57 +08:00
krau
f80c4d7d55 feat: login in alist via token 2025-02-17 16:39:27 +08:00
krau
ccfde34666 fix: failed to run when config file exist 2025-02-17 16:37:46 +08:00
krau
2b23446123 fix: write default config to file if not exist, close #16 2025-02-17 16:23:10 +08:00
krau
7882185ee1 feat: update help message to include default storage location and silent mode details 2025-02-16 15:44:57 +08:00
krau
2d17a731c4 feat: improve file handling by generating default file names and adding mime type detection 2025-02-16 15:43:02 +08:00
krau
db69688722 feat: support save content protect channel message by handle link 2025-02-16 11:38:26 +08:00
krau
ec09289d5f feat: enhance message formatting with entity support and improve command registration 2025-02-15 17:20:16 +08:00
krau
13c87debcc fix: add error handling for message retrieval in saveCmd function 2025-02-15 16:26:12 +08:00
krau
5f3b38c788 feat: add /path command to change file save path and improve configuration handling 2025-02-15 16:25:16 +08:00
krau
8ba0c623c9 chore: update dependencies for github.com/gotd/td to v0.120.0 and modernc.org/sqlite to v1.35.0 2025-02-15 15:11:58 +08:00
krau
6fa8e89191 feat: update download task message to include detailed progress information 2025-02-15 15:10:52 +08:00
krau
3a4effab33 feat: refactor file processing and storage handling with improved path management 2025-02-15 15:06:06 +08:00
krau
7692286d78 docs: update docker upgrade cmd 2025-02-12 13:49:36 +08:00
krau
93ffc940ce docs: add docker deploy 2025-02-12 13:47:25 +08:00
krau
4aadfc1273 feat: add docker-compose 2025-02-12 13:41:02 +08:00
krau
d26a8df15f feat: add default telegram api id and hash 2025-02-12 13:40:49 +08:00
krau
a746cc0fc7 feat: add cache cleaning functionality and configuration option 2025-02-12 12:24:15 +08:00
krau
0fb5634874 feat: support custom storage path in filename 2025-02-12 12:24:11 +08:00
krau
930e838b2e feat: add custom file name support for saved files and improve error messages 2025-02-12 12:02:28 +08:00
krau
1701d1ab86 refactor: improve error handling for empty file, document, and photo cases 2025-02-12 11:13:55 +08:00
krau
a17492d4ae feat(task): enhance download progress reporting and add speed calculation 2025-02-12 11:06:25 +08:00
krau
a32bf43cdc fix(webdav): replace filepath with path for directory creation 2025-02-12 10:29:12 +08:00
krau
a25a58f8a2 refactor(alist): replace req.Client with http.Client and improve error handling 2025-02-01 17:01:46 +08:00
krau
804a86cbdd fix: close file 2025-02-01 16:00:56 +08:00
krau
cf1e9299c0 chore: upgrade deps 2025-02-01 16:00:50 +08:00
krau
e3f7380341 fix(alist): use filebytes to upload file 2025-02-01 14:46:10 +08:00
krau
6c6ee77067 fix: alist error resp 2025-02-01 14:36:08 +08:00
krau
f00aa189e3 docs: 添加 systemd 服务配置说明到 README 2025-01-22 22:01:16 +08:00
krau
48ceb87e29 ci: add docker build 2025-01-20 12:07:13 +08:00
krau
cd663d05ca feat: add Dockerfile for building and running saveany-bot 2025-01-20 12:07:02 +08:00
krau
a778b0fa8c feat: support env vars configuration 2025-01-20 12:06:47 +08:00
krau
2a9c615819 docs: add upgrade instructions to README 2025-01-20 11:29:07 +08:00
krau
2f60af858d refactor: remove message editing after photo file download in task processing 2025-01-20 11:27:26 +08:00
krau
741ceb87fb chore: add example usage for storage command in response text 2025-01-20 11:25:43 +08:00
krau
9795a85dd6 refactor: file download process 2025-01-20 11:09:38 +08:00
krau
45974917f2 feat: support media photo download 2025-01-20 11:03:01 +08:00
krau
a146871a9d ci: set CGO_ENABLED=0 in pre_command for Go binary release 2025-01-19 14:38:10 +08:00
krau
b76c1d5ccc feat: enhance task processing messages with download status and error details 2025-01-19 14:35:08 +08:00
krau
79fba918cf feat: add retry configuration for file upload and validate config values 2025-01-19 14:25:56 +08:00
krau
e1bc80ab7d upgrade deps 2025-01-19 14:01:57 +08:00
krau
8d0851f37a feat: add retry configuration for failed save task 2025-01-07 10:30:33 +08:00
krau
4b5cabc76e feat: add proxy config in telegram dialer 2025-01-07 10:26:09 +08:00
krau
3b42ba86b3 fix: generate file name from hash if empty in FileFromMedia function 2025-01-04 20:56:24 +08:00
krau
2b0bfac171 chore: upgrade deps 2025-01-04 20:56:03 +08:00
Krau
a76b289d17 Update README.md 2024-12-16 21:55:51 +08:00
Krau
4ac65622ee Merge pull request #2 from krau/dependabot/go_modules/golang.org/x/crypto-0.31.0
chore(deps): bump golang.org/x/crypto from 0.29.0 to 0.31.0
2024-12-16 21:24:30 +08:00
dependabot[bot]
7bac97d533 chore(deps): bump golang.org/x/crypto from 0.29.0 to 0.31.0
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.29.0 to 0.31.0.
- [Commits](https://github.com/golang/crypto/compare/v0.29.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-12 13:41:45 +00:00
krau
169197c8fe chore: update readme 2024-12-05 09:36:06 +08:00
krau
a166ff1e64 fix: improve error messages and update reply handling in bot commands
chore: upgrade deps
2024-12-02 13:41:10 +08:00
krau
f360052784 fix: typo 2024-12-02 13:40:42 +08:00
krau
15f263cb50 chore: update readme 2024-11-10 15:57:08 +08:00
krau
420e0b1a4f chore: update readme 2024-11-10 15:41:14 +08:00
krau
71bd6a9480 chore: update readme 2024-11-10 15:36:47 +08:00
krau
5b9e177af4 chore: add demo 2024-11-10 15:35:14 +08:00
krau
9e226ae592 refactor(webdav): use write stream to upload file 2024-11-09 13:18:22 +08:00
krau
a91cce9680 fix: error type in any update 2024-11-09 12:56:03 +08:00
258 changed files with 18491 additions and 1872 deletions

12
.dockerignore Normal file
View File

@@ -0,0 +1,12 @@
*.md
.git
.github/
.gitignore
.vscode/
downloads/
data/
cache/
docs/
config.example.toml
docker-compose.*
playwright/

5
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,5 @@
# These are supported funding model platforms
custom: [
"https://afdian.com/a/unvapp"
]

39
.github/ISSUE_TEMPLATE/bug.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: "👾 Bug Report"
description: "Report a bug or unexpected behavior"
labels:
- "bug"
assignees:
- krau
body:
- type: textarea
attributes:
label: "👾 Description"
description: "What happened?"
placeholder: "When called ... happens ..."
validations:
required: true
- type: textarea
attributes:
label: "⚡️ Expected Behavior"
description: "What was expected?"
placeholder: "It should be ..."
- type: textarea
attributes:
label: "📄 Configuration File"
description: "Please provide your config file"
placeholder: "Please remove sensitive information"
render: toml
validations:
required: true
- type: textarea
attributes:
label: "🔍 Logs"
description: "Please provide logs"
placeholder: "Please remove sensitive information"
render: shell
validations:
required: true
- type: markdown
attributes:
value: |
## Thank you for contributing to the project :slightly_smiling_face:

8
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 💬 Don't know how to use it correctly?
url: https://github.com/krau/SaveAny-Bot/discussions
about: "Go to the discussion area to ask questions"
- name: 📄 Documentation
url: https://sabot.unv.app
about: "View the documentation"

33
.github/ISSUE_TEMPLATE/feature.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
name: "⭐️ Feature Request"
description: "Feature request"
labels:
- "enhancement"
assignees:
- krau
body:
- type: markdown
attributes:
value: |
# Please describe the feature you want in detail
- type: textarea
attributes:
label: "⭐️ Feature description"
description: "What new feature you want to see?"
placeholder: "Add ... in order to ..."
validations:
required: true
- type: textarea
attributes:
label: "🌈 Your view"
description: "How do you see this feature will be used and/or implemented?"
placeholder: "It should be like ..."
- type: textarea
attributes:
label: "🧐 Code example"
description: "You can provide code (or pseudocode) example"
placeholder: "Cool code that will work ..."
render: Go
- type: markdown
attributes:
value: |
## Thank you for contributing to the project :slightly_smiling_face:

86
.github/copilot-instructions.md vendored Normal file
View File

@@ -0,0 +1,86 @@
# SaveAny-Bot AI 协作说明
本项目是一个将 Telegram 文件/消息转存到多种存储端的 Bot主要用 Go 实现CLI 入口在 `cmd/`,核心逻辑分布在 `client/``core/``config/``database/``storage/` 等目录。下面是针对本仓库的专用约定,供 AI 编码助手参考。
## 总体架构与入口
- **CLI 入口**
- 二进制入口:`main.go` 调用 `cmd.Execute(ctx)`
- 根命令:`cmd/root.go` 使用 `cobra` 定义 `saveany-bot``Run` 实现在 `cmd/run.go`
- **应用启动流程(非常重要)**
- `cmd/run.go::Run` 中按顺序完成:读取配置 `config.Init` → 初始化缓存 `common/cache` → 初始化 i18n `common/i18n` → 初始化数据库 `database.Init` → 加载存储 `storage.LoadStorages` → 加载解析器插件 `parsers.LoadPlugins`可选Userbot 登录 → 启动 Telegram Bot `client/bot.Init` → 启动核心任务队列消费 `core.Run`
- 添加新的初始化步骤时,请遵循该顺序并放在 `initAll` 中,而不是分散在各处。
## 配置与约定
- **配置系统**
- 使用 `viper` 读取 `config.toml`,核心结构体定义在 `config/viper.go::Config`
- 默认值在 `config.Init` 中通过 `viper.SetDefault` 定义,如 `workers``retry``telegram.*``db.*` 等。
- `Config.C()` 返回的是全局配置副本(值类型),不要在返回值上修改字段;如果需要修改配置流程,应在 `config.Init` 内或通过 `viper` 进行。
- 存储配置通过 `config/storage/factory.go::LoadStorageConfigs` 加载并校验,新增存储类型需:
-`pkg/enums/storage` 中增加枚举。
-`config/storage/` 下新增具体 `StorageConfig` 实现并实现 `Validate`
-`storageFactories` 映射中注册工厂方法。
- **环境变量**
- 所有配置键会被 `SAVEANY_` 前缀的环境变量覆盖,`.` 会被 `_` 替换(`SAVEANY_TELEGRAM_APP_ID` 等)。
## Telegram 客户端与中间件
- **Bot 客户端**
- 入口在 `client/bot/bot.go::Init`,使用 `gotgproto.NewClient` 创建Session 使用 `database.GetDialect(config.C().DB.Session)`,错误处理通过 `ErrorHandler` 回调完成。
- Handlers 注册集中在 `client/bot/handlers` 目录,`handlers.Register` 负责统一挂载;新增命令/消息处理逻辑时优先在该目录按功能拆分文件,实现后在 `handlers.Register` 中注册。
- Bot 命令列表依赖 `handlers.CommandHandlers` 来自动注册到 Telegram新增命令时务必更新该切片以保持 `/help` 与 Bot 命令列表一致。
- **中间件**
- 通用中间件位于 `client/middleware/`,包含 floodwait、防崩溃、重试等`middleware.NewDefaultMiddlewares``client/bot/bot.go` 中统一挂载。
- 新增跨所有更新生效的行为(如日志、统计)时,应优先实现为中间件。
## 核心任务与队列
- **任务接口与队列**
- 核心接口:`core/core.go::Executable`,包含 `Type() TaskType``Title()``TaskID()``Execute(ctx)`
- 任务队列:`pkg/queue.TaskQueue[Executable]`,由 `core.Run` 使用;`Workers` 数量来自配置 `config.C().Workers`
- 任务类型与实现示例位于 `core/tasks/**`例如文件任务、Telegraph 任务等;新增任务类型应放在对应子目录并实现 `Executable` 接口,然后通过 `core.AddTask` 入队。
- **生命周期 Hook**
- `core.worker` 在执行任务前后会根据 `config.C().Hook.Exec` 调用外部命令(`TaskBeforeStart` / `TaskSuccess` / `TaskFail` / `TaskCancel`)。
- 修改任务执行流程时需保留这些 Hook 调用,以免破坏用户已有集成。
## 数据库与持久化
- **数据库初始化**
- `database.Init` 使用配置 `config.C().DB.Path` 创建并连接 SQLite使用 `GetDialect` 抽象驱动(见 `database/driver_*.go`)。
- Migration 通过 `db.AutoMigrate(&User{}, &Dir{}, &Rule{}, &WatchChat{})` 完成,模型定义在 `database/*.go` 中。
- **用户同步约定**
- `database.syncUsers` 会根据 `config.C().Users` 同步数据库用户表:在配置中新增/删除用户会自动在 DB 中创建/删除对应记录。
- 开发涉及用户表逻辑时,请考虑该同步行为,避免在其他地方直接创建/删除用户记录而与配置冲突。
## 存储后端
- **存储抽象**
- 抽象接口在 `config/storage/types.go``storage/` 顶层(以及子目录)中;`config/storage/*.go` 处理配置解析,`storage/*` 处理真正的上传/下载实现。
- 现有实现包括 `local``alist``s3/minio``webdav``telegram` 等,每个后端都有对应子目录和配置结构体。
- **新增存储实现的推荐路径**
-`config/storage/` 下添加配置结构体 + `Validate`
-`storage/` 下添加具体实现(例如 `storage/foo/`)。
-`pkg/enums/storage``storageFactories` 中注册,并确保 `storages` 配置示例被更新(`config.example.toml` / 文档)。
## 解析器插件JS
- **插件运行时**
- 解析器接口和插件文档在 `plugins/README.md`Go 端入口为 `parsers/` 目录,使用 `goja``playwright-go`
- 插件通过 `registerParser({ metadata, canHandle, parse })` 注册,使用 `ghttp`/`playwright` 进行 HTTP/浏览器请求。
- **与核心交互约定**
- 插件 `parse` 返回的 `Item`/`Resource` 会被转化为内部任务(通常是下载/转存任务)并进入 `core` 队列。
- 修改 `Item`/`Resource` 结构或解析逻辑时,要确保保持向后兼容,或在 `plugins/README.md` 中同步更新字段说明和示例。
## i18n 与日志
- **国际化**
- 所有用户可见字符串(尤其是错误与提示)应使用 `common/i18n``i18n.T(i18nk.SomeKey, map[string]any{"Name": name})`
- 语言文件位于 `common/i18n/locale/``go:generate` 指令在 `main.go` 中生成 `i18nk/keys.go`;新增文案时需:添加到 YAML、运行 `go generate ./...`、再在代码中引用新 key。
- **日志**
- 使用 `github.com/charmbracelet/log`,在 `cmd/run.go::Run` 中通过 `log.WithContext` 将 logger 注入 `context.Context`;后续代码优先通过 `log.FromContext(ctx)` 获取 logger。
- 编写新代码时,如已有 `ctx`,请使用 `log.FromContext(ctx)` 而不是全局 logger。
## 开发与运行
- **本地运行**
- 直接运行:`go run ./cmd``cmd/root.go` + `cmd/run.go`)。
- 或通过 Docker参见根目录 `README.md` 中的 `docker run ...` 示例及 `docker-compose.yml`
- **代码生成与文档**
- i18n key 生成:`go generate ./...` 会执行 `main.go` 顶部的 `//go:generate`,使用 `cmd/geni18n/main.go` 生成 `common/i18n/i18nk/keys.go`
- 文档站点在 `docs/`Hugo通常不需要在核心代码改动时同步修改除非涉及文档内容。
---
如果你在实现某个功能时发现以上规则不够具体(例如某类任务在 `core/tasks` 中到底如何落地,或某个存储/解析器的边界不清晰),请在对应章节下扩展更精确的说明,并在 PR 描述中标注。也欢迎你告诉我有哪些部分需要补充或澄清,我可以进一步细化。

163
.github/workflows/build-docker.yml vendored Normal file
View File

@@ -0,0 +1,163 @@
name: Build and Publish Docker Image
on:
push:
tags:
- "v*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: krau/saveany-bot
concurrency:
group: docker-build-${{ github.repository }}
cancel-in-progress: true
jobs:
prepare:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.vars.outputs.version }}
major_minor: ${{ steps.vars.outputs.major_minor }}
short_sha: ${{ steps.vars.outputs.short_sha }}
build_time: ${{ steps.vars.outputs.build_time }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Extract Version Components
id: vars
run: |
VERSION=${GITHUB_REF#refs/tags/v}
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "major_minor=$MAJOR_MINOR" >> "$GITHUB_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
build:
needs: prepare
permissions:
contents: read
packages: write
strategy:
matrix:
arch: [amd64, arm64]
type: [default, micro, pico]
fail-fast: false
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.type == 'default' && './Dockerfile' || format('./Dockerfile.{0}', matrix.type) }}
platforms: ${{ matrix.arch == 'amd64' && 'linux/amd64' || 'linux/arm64' }}
# 关键修改:不再使用 tags而是通过 image output 按摘要推送
outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ needs.prepare.outputs.version }}
GitCommit=${{ needs.prepare.outputs.short_sha }}
BuildTime=${{ needs.prepare.outputs.build_time }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name:
Export digest
# 将 digest 写入文件,供后续步骤读取
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
echo "$digest" > /tmp/digests/digest
- name: Upload digest
uses: actions/upload-artifact@v6
with:
name: digest-${{ matrix.type }}-${{ matrix.arch }}
path: /tmp/digests/digest
if-no-files-found: error
retention-days: 1
create-manifest:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
matrix:
type: [default, micro, pico]
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download digests
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digest-${{ matrix.type }}-*
merge-multiple: false
- name: Create and push manifest lists
run: |
REPO="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
VERSION="${{ needs.prepare.outputs.version }}"
MAJOR_MINOR="${{ needs.prepare.outputs.major_minor }}"
SHA="${{ needs.prepare.outputs.short_sha }}"
TYPE="${{ matrix.type }}"
DIGEST_AMD64=$(cat /tmp/digests/digest-${TYPE}-amd64/digest)
DIGEST_ARM64=$(cat /tmp/digests/digest-${TYPE}-arm64/digest)
echo "Found digests for $TYPE:"
echo "AMD64: $DIGEST_AMD64"
echo "ARM64: $DIGEST_ARM64"
TAGS=()
if [ "$TYPE" == "default" ]; then
TAGS+=("$REPO:latest")
TAGS+=("$REPO:$VERSION")
TAGS+=("$REPO:$MAJOR_MINOR")
TAGS+=("$REPO:sha-$SHA")
else
TAGS+=("$REPO:$TYPE")
TAGS+=("$REPO:$TYPE-latest")
TAGS+=("$REPO:$TYPE-$VERSION")
fi
SRC_AMD64="${REPO}@${DIGEST_AMD64}"
SRC_ARM64="${REPO}@${DIGEST_ARM64}"
echo "Creating manifest list with sources:"
echo " $SRC_AMD64"
echo " $SRC_ARM64"
for TAG in "${TAGS[@]}"; do
echo "Pushing tag: $TAG"
docker buildx imagetools create -t "$TAG" "$SRC_AMD64" "$SRC_ARM64"
done

View File

@@ -1,3 +1,5 @@
name: Build Release
on:
push:
tags:
@@ -13,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -21,9 +23,9 @@ jobs:
uses: softprops/action-gh-release@v2
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 20
node-version: 22
- run: npx changelogithub
env:
@@ -36,6 +38,9 @@ jobs:
matrix:
goos: [linux, darwin, windows]
goarch: [amd64, arm64]
exclude:
- goos: windows
goarch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -49,7 +54,7 @@ jobs:
- name: Release Go Binary
uses: wangyoucao577/go-release-action@v1
with:
pre_command: export
pre_command: export CGO_ENABLED=0
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -58,9 +63,9 @@ jobs:
README.md
ldflags: >-
-s -w
-X "github.com/krau/SaveAny-Bot/common.Version=${{ env.VERSION }}"
-X "github.com/krau/SaveAny-Bot/common.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
-X "github.com/krau/SaveAny-Bot/common.GitCommit=${{ github.sha }}"
-X "github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
-X "github.com/krau/SaveAny-Bot/config.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}"
-X "github.com/krau/SaveAny-Bot/config.GitCommit=${{ github.sha }}"
binary_name: saveany-bot
env:
VERSION: ${{ env.VERSION }}

36
.github/workflows/docs.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: Deploy Docs
on:
push:
branches:
- main
paths:
- "docs/**"
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- uses: actions/checkout@v6
with:
submodules: true # Fetch Hugo themes (true OR recursive)
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
hugo-version: '0.147.8'
extended: true
- name: Build
run: hugo --minify --destination public --source docs
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/public
publish_branch: gh-pages

8
.gitignore vendored
View File

@@ -3,6 +3,10 @@ logs/
tmp/
data/
downloads/
cache/
session.*
cache.db
cache.db
.vscode/
temp/
.hugo_build.lock
playwright/
testplugins/

15
.vscode/launch.json vendored
View File

@@ -1,15 +0,0 @@
{
// 使用 IntelliSense 了解相关属性。
// 悬停以查看现有属性的描述。
// 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Launch Package",
"type": "go",
"request": "launch",
"mode": "auto",
"program": "main.go",
}
]
}

172
CLAUDE.md Normal file
View File

@@ -0,0 +1,172 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
SaveAny-Bot is a Telegram bot written in Go that saves files from Telegram and external websites to various storage backends (Local, Alist, S3, WebDAV, Telegram). It supports bypassing "restrict saving content" media, batch downloads, auto-organization, and JS parser plugins for extracting content from websites.
## Commands
### Development
```bash
# Run the bot directly
go run ./cmd
# Run with specific config file
go run ./cmd --config /path/to/config.toml
# Generate i18n keys after modifying locale files
go generate ./...
# Run tests
go test ./...
# Run specific package tests
go test ./storage/s3
go test ./pkg/queue
```
### Docker
```bash
# Build and run locally
docker-compose -f docker-compose.local.yml up
# Production deployment
docker-compose up -d
```
## Architecture
### Application Initialization Flow (CRITICAL)
The application follows a strict initialization sequence in `cmd/run.go::initAll`:
1. **Config** (`config.Init`) - Load config.toml using viper
2. **Cache** (`common/cache`) - Initialize in-memory cache
3. **i18n** (`common/i18n`) - Load localization files from `common/i18n/locale/`
4. **Database** (`database.Init`) - Connect to SQLite, auto-migrate models
5. **Storage** (`storage.LoadStorages`) - Initialize all configured storage backends
6. **Parsers** (`parsers.LoadPlugins`) - Load JS parser plugins if enabled
7. **Userbot** (optional) - Login to Telegram userbot if configured
8. **Bot** (`client/bot.Init`) - Initialize Telegram bot client
9. **Core** (`core.Run`) - Start task queue workers
**When adding new initialization steps, add them to `initAll` in this order.**
### Core Components
#### Task Queue System (`core/`)
- Central abstraction: `core.Executable` interface with `Type()`, `Title()`, `TaskID()`, `Execute(ctx)`
- Task queue: `pkg/queue.TaskQueue[Executable]` managed by `core.Run`
- Worker pool size controlled by `config.Workers`
- Task implementations in `core/tasks/`:
- `tfile/` - Telegram file downloads
- `batchtfile/` - Batch downloads
- `telegraph/` - Telegraph page saving
- `parsed/` - Content from parser plugins
- `directlinks/` - Direct URL downloads
- Tasks support lifecycle hooks: `TaskBeforeStart`, `TaskSuccess`, `TaskFail`, `TaskCancel` (configured via `config.Hook.Exec`)
#### Telegram Client (`client/`)
- **Bot**: `client/bot/bot.go` uses `gotgproto.NewClient`, handlers in `client/bot/handlers/`
- **Userbot**: `client/user/` for bypassing restricted content (optional)
- **Middleware**: `client/middleware/` handles flood wait, recovery, retry
- Handler registration: All handlers registered in `handlers.Register()`, command list in `handlers.CommandHandlers`
#### Storage Backends (`storage/`)
- Abstract interface: `Storage` with `Init()`, `Save()`, `Exists()`, `JoinStoragePath()`
- Implementations: `local/`, `alist/`, `s3/`, `minio/`, `webdav/`, `telegram/`
- Storage registry: `storage.Storages` map, constructed via `storageConstructors`
- Add new storage: Define enum in `pkg/enums/storage`, config in `config/storage/`, implementation in `storage/`, register in `storageConstructors`
#### Parser Plugins (`parsers/`)
- Native parsers: `parsers/native/twitter/`, `parsers/native/kemono/`
- JS plugin runtime: `parsers/js/` using `goja` VM and `playwright-go`
- Plugin interface: `registerParser({ metadata, canHandle, parse })` in JS
- Plugins return `parser.Item` with `Resources[]`, converted to `core/tasks/parsed` tasks
- Plugin docs: `plugins/README.md`
#### Configuration (`config/`)
- Main config: `config/viper.go::Config` loaded via `viper`
- Storage configs: `config/storage/factory.go::LoadStorageConfigs` with type-specific validation
- Environment override: Prefix `SAVEANY_`, dots become underscores (e.g., `SAVEANY_TELEGRAM_TOKEN`)
- **Important**: `config.C()` returns a copy, don't modify its fields; modify via `viper` in `config.Init`
#### Database (`database/`)
- SQLite with GORM
- Models: `User`, `Dir`, `Rule`, `WatchChat`
- User sync: `database.syncUsers` syncs `config.Users` to DB (creates/deletes users based on config)
- Don't create/delete users manually; use config file
#### Internationalization (`common/i18n/`)
- Locale files: `common/i18n/locale/*.yaml`
- Key generation: `go generate ./...` runs `cmd/geni18n/main.go` to generate `common/i18n/i18nk/keys.go`
- Usage: `i18n.T(i18nk.KeyName, map[string]any{"Param": value})`
- Add new strings: Edit YAML → run `go generate` → use new key in code
#### Logging
- Uses `github.com/charmbracelet/log`
- Logger injected into context in `cmd/run.go::Run` via `log.WithContext`
- Prefer `log.FromContext(ctx)` over global logger when context is available
### Data Flow
1. User sends message/file to Telegram bot
2. Handler in `client/bot/handlers/` processes update
3. Handler creates task (implements `core.Executable`) and calls `core.AddTask()`
4. Task enters `pkg/queue.TaskQueue`
5. Worker goroutine picks up task, calls `Execute(ctx)`
6. Task downloads content (from Telegram or parser) and saves to storage backend via `Storage.Save()`
7. Task completion triggers hooks, sends result message back to user
For parsed content (URLs):
1. Handler extracts URL, calls `parsers.ParseWithContext()`
2. Parser (native or JS plugin) returns `parser.Item` with `Resources[]`
3. `core/tasks/parsed.Task` created with resources
4. Task downloads each resource and saves to storage
## Project-Specific Guidelines
### Adding New Storage Backend
1. Define enum in `pkg/enums/storage/storage.go`
2. Create config struct in `config/storage/yourtype.go` implementing `StorageConfig` with `Validate()`
3. Implement storage in `storage/yourtype/yourtype.go` with `Storage` interface
4. Register in `config/storage/factory.go::storageFactories` and `storage/storage.go::storageConstructors`
5. Update `config.example.toml` with example configuration
### Adding New Task Type
1. Create package in `core/tasks/yourtype/`
2. Define struct implementing `core.Executable` interface
3. Implement `Type()`, `Title()`, `TaskID()`, `Execute(ctx)`
4. Add task type enum in `pkg/enums/tasktype/`
5. Create task from handler and call `core.AddTask(ctx, task)`
### Adding New Bot Command
1. Create handler file in `client/bot/handlers/yourcommand.go`
2. Implement handler function with signature matching gotgproto patterns
3. Register in `handlers.Register()` function
4. Add to `handlers.CommandHandlers` slice for command list
5. Add i18n strings for command description and responses
### Adding i18n Strings
1. Edit locale files in `common/i18n/locale/` (e.g., `zh-Hans.yaml`, `en.yaml`)
2. Run `go generate ./...` to generate `common/i18n/i18nk/keys.go`
3. Use in code: `i18n.T(i18nk.YourNewKey, map[string]any{"Variable": value})`
### Working with Middleware
New cross-cutting concerns (logging, metrics, rate limiting) should be implemented as middleware in `client/middleware/`:
1. Create middleware file with function signature matching gotgproto middleware
2. Add to `middleware.NewDefaultMiddlewares()` in `client/bot/bot.go`
### Parser Plugin Development
- JS plugins go in directories specified by `config.parser.plugin_dirs` (default: `./plugins/`)
- See `plugins/README.md` for plugin API documentation
- Plugins have access to `ghttp` for HTTP requests and `playwright` for browser automation
- Test plugins in `./testplugins/` directory

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
FROM golang:alpine AS builder
ARG VERSION="dev"
ARG GitCommit="Unknown"
ARG BuildTime="Unknown"
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
CGO_ENABLED=0 \
go build -trimpath \
-ldflags=" \
-s -w \
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
" \
-o saveany-bot .
FROM alpine:latest
RUN apk add --no-cache curl ffmpeg
WORKDIR /app
COPY --from=builder /app/saveany-bot .
COPY entrypoint.sh .
RUN chmod +x /app/saveany-bot && \
chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]

41
Dockerfile.micro Normal file
View File

@@ -0,0 +1,41 @@
FROM golang:alpine AS builder
ARG VERSION="dev"
ARG GitCommit="Unknown"
ARG BuildTime="Unknown"
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
CGO_ENABLED=0 \
go build -trimpath \
-tags=no_jsparser,no_minio,no_bubbletea \
-ldflags=" \
-s -w \
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
" \
-o saveany-bot .
FROM alpine:latest
RUN apk add --no-cache curl
WORKDIR /app
COPY --from=builder /app/saveany-bot .
COPY entrypoint.sh .
RUN chmod +x /app/saveany-bot && \
chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]

35
Dockerfile.pico Normal file
View File

@@ -0,0 +1,35 @@
# pico is the minimum build of SaveAnyBot, which disables all the optional features like JS parsing and MinIO support.
FROM golang:alpine AS builder
ARG VERSION="dev"
ARG GitCommit="Unknown"
ARG BuildTime="Unknown"
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
CGO_ENABLED=0 \
go build -trimpath \
-tags=no_jsparser,no_minio,sqlite_glebarez,no_bubbletea \
-ldflags=" \
-s -w \
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
-X 'github.com/krau/SaveAny-Bot/config.Docker=true' \
" \
-o saveany-bot . && chmod +x saveany-bot
FROM scratch
WORKDIR /app
COPY --from=builder /app/saveany-bot .
ENTRYPOINT ["/app/saveany-bot"]

View File

@@ -1,44 +1,92 @@
# Save Any Bot
<div align="center">
把 Telegram 的文件保存到各类存储端.
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
> _就像 PikPak Bot 一样_
**English** | [简体中文](./README_zh.md)
## 部署
> **Save Any Telegram File to Anywhere 📂. Support restrict saving content and beyond telegram.**
[Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
[![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)
在解压后目录新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
</div>
运行:
## 🎯 Features
```bash
chmod +x saveany-bot
./saveany-bot
```
- Support documents / videos / photos / stickers… and even [Telegraph](https://telegra.ph/)
- Bypass "restrict saving content" media
- Batch download
- Streaming transfer
- Multi-user support
- Auto organize files based on storage rules
- Watch specified chats and auto-save messages, with filters
- Write JS parser plugins to save files from almost any website
- Storage backends:
- Alist
- S3
- WebDAV
- Local filesystem
- Telegram (re-upload to specified chats)
## 使用
## 📦 Quick Start
向 Bot 发送(转发)文件, 按照提示操作.
## Bot API 版本 (v0.3.0 前)
> Bot API 版本自身不需要 API_ID 和 API_HASH, 但是部署 Telegram Bot API 服务器仍然需要.
由于 Telegram 官方 Bot API 的限制, Bot 无法下载大于 20MB 的文件. 你需要部署一个本地的 Telegram Bot API 来解决这个问题, 然后在配置文件改为你自己的 api 地址
Create a `config.toml` file with the following content:
```toml
[telegram]
api = "http://localhost:8081"
token = "" # Your bot token, obtained from @BotFather
[telegram.proxy]
# Enable proxy for Telegram, currently only SOCKS5 is supported
enable = false
url = "socks5://127.0.0.1:7890"
[[storages]]
name = "Local Disk"
type = "local"
enable = true
base_path = "./downloads"
[[users]]
id = 114514 # Your Telegram account id
storages = []
blacklist = true
```
参考: [telegram-bot-api-compose](https://github.com/krau/telegram-bot-api-compose)
Run Save Any Bot with Docker:
---
```bash
docker run -d --name saveany-bot \
-v ./config.toml:/app/config.toml \
-v ./downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
## Thanks
Please [**read the docs**](https://sabot.unv.app/en/) for more configuration options and usage.
## Sponsors
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
If this project is helpful to you, consider sponsoring me via:
- [Afdian](https://afdian.com/a/unvapp)
## Thanks To
- [gotd](https://github.com/gotd/td)
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
- [gotgproto](https://github.com/celestix/gotgproto)
- All the dependencies
- [tdl](https://github.com/iyear/tdl)
- All the dependencies, contributors, sponsors and users.
## Contact
- [![Group](https://img.shields.io/badge/ProjectSaveAny-Group-blue)](https://t.me/ProjectSaveAny)
- [![Discussion](https://img.shields.io/badge/Github-Discussion-white)](https://github.com/krau/saveany-bot/discussions)
- [![PersonalChannel](https://img.shields.io/badge/Krau-PersonalChannel-cyan)](https://t.me/acherkrau)

90
README_zh.md Normal file
View File

@@ -0,0 +1,90 @@
<div align="center">
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
> **把 Telegram 上的文件转存到多种存储端**
[![Release Date](https://img.shields.io/github/release-date/krau/saveany-bot?label=release)](https://github.com/krau/saveany-bot/releases)
[![tag](https://img.shields.io/github/v/tag/krau/saveany-bot.svg)](https://github.com/krau/saveany-bot/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/krau/saveany-bot/build-release.yml)](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
[![Stars](https://img.shields.io/github/stars/krau/saveany-bot?style=flat)](https://github.com/krau/saveany-bot/stargazers)
[![Downloads](https://img.shields.io/github/downloads/krau/saveany-bot/total)](https://github.com/krau/saveany-bot/releases)
[![Issues](https://img.shields.io/github/issues/krau/saveany-bot)](https://github.com/krau/saveany-bot/issues)
[![Pull Requests](https://img.shields.io/github/issues-pr/krau/saveany-bot?label=pr)](https://github.com/krau/saveany-bot/pulls)
[![License](https://img.shields.io/github/license/krau/saveany-bot)](./LICENSE)
</div>
## 🎯 特性
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
- 破解禁止保存的文件
- 批量下载
- 流式传输
- 多用户使用
- 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist
- S3
- WebDAV
- 本地磁盘
- Telegram (重传回指定聊天)
## 快速开始
创建文件 `config.toml` 并填入以下内容:
```toml
[telegram]
token = "" # 你的 Bot Token, 在 @BotFather 获取
[telegram.proxy]
# 启用代理连接 telegram, 当前只支持 socks5
enable = false
url = "socks5://127.0.0.1:7890"
[[storages]]
name = "本地磁盘"
type = "local"
enable = true
base_path = "./downloads"
[[users]]
id = 114514 # 你的 Telegram 账号 id
storages = []
blacklist = true
```
使用 Docker 运行 Save Any Bot:
```bash
docker run -d --name saveany-bot \
-v ./config.toml:/app/config.toml \
-v ./downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
请 [**查看文档**](https://sabot.unv.app/) 以获取更多配置选项和使用方法.
## 赞助
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
- [爱发电](https://afdian.com/a/unvapp)
## 鸣谢
- [gotd](https://github.com/gotd/td)
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
- [gotgproto](https://github.com/celestix/gotgproto)
- [tdl](https://github.com/iyear/tdl)
- All the dependencies, contributors, sponsors and users.
## 社区和关于作者
- [![通知群组](https://img.shields.io/badge/ProjectSaveAny-Group-blue)](https://t.me/ProjectSaveAny)
- [![讨论区](https://img.shields.io/badge/Github-Discussion-white)](https://github.com/krau/saveany-bot/discussions)
- [![个人频道](https://img.shields.io/badge/Krau-PersonalChannel-cyan)](https://t.me/acherkrau)

View File

@@ -1,21 +0,0 @@
package bootstrap
import (
"github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage"
)
func InitAll() {
config.Init()
logger.InitLogger()
logger.L.Info("Running...")
common.Init()
storage.Init()
dao.Init()
bot.Init()
}

View File

@@ -1,54 +0,0 @@
package bot
import (
"context"
"os"
"time"
"github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/sessionMaker"
"github.com/glebarez/sqlite"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger"
)
var Client *gotgproto.Client
func Init() {
logger.L.Info("Initializing client...")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
resultChan := make(chan struct {
client *gotgproto.Client
err error
})
go func() {
client, err := gotgproto.NewClient(int(config.Cfg.Telegram.AppID), config.Cfg.Telegram.AppHash, gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
&gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(sqlite.Open("data/session.db")),
DisableCopyright: true,
Middlewares: FloodWaitMiddleware(),
},
)
resultChan <- struct {
client *gotgproto.Client
err error
}{client, err}
}()
select {
case <-ctx.Done():
logger.L.Fatal("Failed to initialize client")
os.Exit(1)
case result := <-resultChan:
if result.err != nil {
logger.L.Fatalf("Failed to initialize client: %s", result.err)
os.Exit(1)
}
Client = result.client
RegisterHandlers(Client.Dispatcher)
logger.L.Info("Client initialized")
}
}

View File

@@ -1,375 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/duke-git/lancet/v2/slice"
"github.com/gookit/goutil/maputil"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/dispatcher/handlers"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
func RegisterHandlers(dispatcher dispatcher.Dispatcher) {
dispatcher.AddHandler(handlers.NewAnyUpdate(checkPermission))
dispatcher.AddHandler(handlers.NewCommand("start", start))
dispatcher.AddHandler(handlers.NewCommand("help", help))
dispatcher.AddHandler(handlers.NewCommand("silent", silent))
dispatcher.AddHandler(handlers.NewCommand("storage", setDefaultStorage))
dispatcher.AddHandler(handlers.NewCommand("save", saveCmd))
dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
}
const noPermissionText string = `
本 Bot 仅限个人使用.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
`
func checkPermission(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
if !slice.Contain(config.Cfg.Telegram.Admins, userID) {
ctx.Reply(update, noPermissionText, nil)
return dispatcher.EndGroups
}
return dispatcher.ContinueGroups
}
func start(ctx *ext.Context, update *ext.Update) error {
if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil {
logger.L.Errorf("Failed to create user: %s", err)
return dispatcher.EndGroups
}
return help(ctx, update)
}
const helpText string = `
SaveAny Bot - 转存你的 Telegram 文件
命令:
/start - 开始使用
/help - 显示帮助
/silent - 静默模式
/storage - 设置默认存储位置
/save - 保存文件
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
`
func help(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, helpText, nil)
return dispatcher.EndGroups
}
func silent(ctx *ext.Context, update *ext.Update) error {
user, err := dao.GetUserByUserID(update.GetUserChat().GetID())
if err != nil {
logger.L.Errorf("Failed to get user: %s", err)
return dispatcher.EndGroups
}
user.Silent = !user.Silent
if err := dao.UpdateUser(user); err != nil {
logger.L.Errorf("Failed to update user: %s", err)
return dispatcher.EndGroups
}
ctx.Reply(update, fmt.Sprintf("已%s静默模式", func() string {
if user.Silent {
return "开启"
}
return "关闭"
}()), nil)
return dispatcher.EndGroups
}
func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
if len(storage.Storages) == 0 {
ctx.Reply(update, "未配置存储", nil)
return dispatcher.EndGroups
}
args := strings.Split(update.EffectiveMessage.Text, " ")
avaliableStorages := maputil.Keys(storage.Storages)
if len(args) < 2 {
text := []styling.StyledTextOption{
styling.Plain("请提供存储位置名称, 可用项:"),
}
for _, name := range avaliableStorages {
text = append(text, styling.Plain("\n"))
text = append(text, styling.Code(name))
}
ctx.Reply(update, text, nil)
return dispatcher.EndGroups
}
storageName := args[1]
if !slice.Contain(avaliableStorages, storageName) {
ctx.Reply(update, "存储位置不存在", nil)
return dispatcher.EndGroups
}
user, err := dao.GetUserByUserID(update.GetUserChat().GetID())
if err != nil {
logger.L.Errorf("Failed to get user: %s", err)
return dispatcher.EndGroups
}
user.DefaultStorage = storageName
if err := dao.UpdateUser(user); err != nil {
logger.L.Errorf("Failed to update user: %s", err)
return dispatcher.EndGroups
}
ctx.Reply(update, fmt.Sprintf("已设置默认存储位置为 %s", storageName), nil)
return dispatcher.EndGroups
}
func saveCmd(ctx *ext.Context, update *ext.Update) error {
res, ok := update.EffectiveMessage.GetReplyTo()
if !ok || res == nil {
ctx.Reply(update, "请回复要保存的文件", nil)
return dispatcher.EndGroups
}
replyHeader, ok := res.(*tg.MessageReplyHeader)
if !ok {
ctx.Reply(update, "请回复要保存的文件", nil)
return dispatcher.EndGroups
}
replyToMsgID, ok := replyHeader.GetReplyToMsgID()
if !ok {
ctx.Reply(update, "请回复要保存的文件", nil)
return dispatcher.EndGroups
}
msg, err := GetTGMessage(ctx, Client, replyToMsgID)
supported, _ := supportedMediaFilter(msg)
if !supported {
ctx.Reply(update, "不支持的消息类型或消息中没有文件", nil)
return dispatcher.EndGroups
}
user, err := dao.GetUserByUserID(update.GetUserChat().GetID())
if err != nil {
logger.L.Errorf("Failed to get user: %s", err)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, "正在获取文件信息...", nil)
if err != nil {
logger.L.Errorf("Failed to reply: %s", err)
return dispatcher.EndGroups
}
file, err := FileFromMessage(ctx, Client, update.EffectiveChat().GetID(), msg.ID)
if err != nil {
logger.L.Errorf("Failed to get file from message: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法获取文件",
ID: replied.ID,
})
return dispatcher.EndGroups
}
if file.FileName == "" {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法获取文件名",
ID: replied.ID,
})
return dispatcher.EndGroups
}
if err := dao.AddReceivedFile(&types.ReceivedFile{
Processing: false,
FileName: file.FileName,
ChatID: update.EffectiveChat().GetID(),
MessageID: replyToMsgID,
ReplyMessageID: replied.ID,
}); err != nil {
logger.L.Errorf("Failed to add received file: %s", err)
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法保存文件",
ID: replied.ID,
}); err != nil {
logger.L.Errorf("Failed to edit message: %s", err)
}
return dispatcher.EndGroups
}
if !user.Silent {
text := "请选择存储位置"
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: text,
ReplyMarkup: getAddTaskMarkup(msg.ID),
ID: replied.ID,
})
if err != nil {
logger.L.Errorf("Failed to reply: %s", err)
}
return dispatcher.EndGroups
}
if user.DefaultStorage == "" {
ctx.Reply(update, "请先使用 /storage 设置默认存储位置", nil)
return dispatcher.EndGroups
}
queue.AddTask(types.Task{
Ctx: ctx,
Status: types.Pending,
File: file,
Storage: types.StorageType(user.DefaultStorage),
ChatID: update.EffectiveChat().GetID(),
ReplyMessageID: replied.ID,
MessageID: msg.ID,
})
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", file.FileName, queue.Len()),
ID: replied.ID,
})
if err != nil {
logger.L.Errorf("Failed to edit message: %s", err)
}
return dispatcher.EndGroups
}
func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
logger.L.Trace("Got media: ", update.EffectiveMessage.Media.TypeName())
supported, err := supportedMediaFilter(update.EffectiveMessage.Message)
if err != nil {
return err
}
if !supported {
return dispatcher.EndGroups
}
user, err := dao.GetUserByUserID(update.GetUserChat().GetID())
if err != nil {
logger.L.Errorf("Failed to get user: %s", err)
return dispatcher.EndGroups
}
msg, err := ctx.Reply(update, "正在获取文件信息...", nil)
if err != nil {
logger.L.Errorf("Failed to reply: %s", err)
return dispatcher.EndGroups
}
media := update.EffectiveMessage.Media
file, err := FileFromMedia(media)
if err != nil {
logger.L.Errorf("Failed to get file from media: %s", err)
ctx.Reply(update, "无法获取文件", nil)
return dispatcher.EndGroups
}
if file.FileName == "" {
ctx.Reply(update, "无法获取文件名", nil)
return dispatcher.EndGroups
}
if err := dao.AddReceivedFile(&types.ReceivedFile{
Processing: false,
FileName: file.FileName,
ChatID: update.EffectiveChat().GetID(),
MessageID: update.EffectiveMessage.ID,
ReplyMessageID: msg.ID,
}); err != nil {
logger.L.Errorf("Failed to add received file: %s", err)
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法保存文件",
ID: msg.ID,
}); err != nil {
logger.L.Errorf("Failed to edit message: %s", err)
}
return dispatcher.EndGroups
}
if !user.Silent {
text := "请选择存储位置"
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: text,
ReplyMarkup: getAddTaskMarkup(update.EffectiveMessage.ID),
ID: msg.ID,
})
if err != nil {
logger.L.Errorf("Failed to edit message: %s", err)
}
return dispatcher.EndGroups
}
if user.DefaultStorage == "" {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "请先使用 /storage 设置默认存储位置",
ID: msg.ID,
})
return dispatcher.EndGroups
}
queue.AddTask(types.Task{
Ctx: ctx,
Status: types.Pending,
File: file,
Storage: types.StorageType(user.DefaultStorage),
ChatID: update.EffectiveChat().GetID(),
ReplyMessageID: msg.ID,
MessageID: update.EffectiveMessage.ID,
})
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", file.FileName, queue.Len()),
ID: msg.ID,
})
return dispatcher.EndGroups
}
func AddToQueue(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.CallbackQuery.Data), " ")
messageID, _ := strconv.Atoi(args[1])
logger.L.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", update.EffectiveChat().GetID(), messageID, args[2])
record, err := dao.GetReceivedFileByChatAndMessageID(update.EffectiveChat().GetID(), messageID)
if err != nil {
logger.L.Errorf("Failed to get received file: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "查询记录失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if update.CallbackQuery.MsgID != record.ReplyMessageID {
record.ReplyMessageID = update.CallbackQuery.MsgID
if err := dao.UpdateReceivedFile(record); err != nil {
logger.L.Errorf("Failed to update received file: %s", err)
}
}
file, err := FileFromMessage(ctx, Client, record.ChatID, record.MessageID)
if err != nil {
logger.L.Errorf("Failed to get file from message: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取消息文件失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
queue.AddTask(types.Task{
Ctx: ctx,
Status: types.Pending,
File: file,
Storage: types.StorageType(args[2]),
ChatID: record.ChatID,
ReplyMessageID: record.ReplyMessageID,
MessageID: record.MessageID,
})
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", record.FileName, queue.Len()),
ID: record.ReplyMessageID,
})
return dispatcher.EndGroups
}

View File

@@ -1,141 +0,0 @@
package bot
import (
"context"
"fmt"
"github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/dispatcher"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
func supportedMediaFilter(m *tg.Message) (bool, error) {
if not := m.Media == nil; not {
return false, dispatcher.EndGroups
}
switch m.Media.(type) {
case *tg.MessageMediaDocument:
return true, nil
case *tg.MessageMediaWebPage:
return false, dispatcher.EndGroups
case tg.MessageMediaClass:
return false, dispatcher.EndGroups
default:
return false, nil
}
}
var StorageDisplayNames = map[string]string{
"all": "全部",
"local": "服务器磁盘",
"alist": "Alist",
"webdav": "WebDAV",
}
func getAddTaskMarkup(messageID int) *tg.ReplyInlineMarkup {
storageButtons := make([]tg.KeyboardButtonClass, 0)
for _, name := range storage.StorageKeys {
storageButtons = append(storageButtons, &tg.KeyboardButtonCallback{
Text: StorageDisplayNames[string(name)],
Data: []byte(fmt.Sprintf("add %d %s", messageID, name)),
})
}
if len(storageButtons) < 1 {
return nil
}
if len(storageButtons) == 1 {
return &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: storageButtons,
},
},
}
}
return &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: storageButtons,
},
{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: "全部",
Data: []byte(fmt.Sprintf("add %d all", messageID)),
},
},
},
},
}
}
func FileFromMedia(media tg.MessageMediaClass) (*types.File, error) {
switch media := media.(type) {
case *tg.MessageMediaDocument:
document, ok := media.Document.AsNotEmpty()
if !ok {
return nil, fmt.Errorf("unexpected type %T", media)
}
var fileName string
for _, attribute := range document.Attributes {
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
fileName = name.FileName
break
}
}
return &types.File{
Location: document.AsInputDocumentFileLocation(),
FileSize: document.Size,
FileName: fileName,
MimeType: document.MimeType,
ID: document.ID,
}, nil
}
return nil, fmt.Errorf("unexpected type %T", media)
}
func FileFromMessage(ctx context.Context, client *gotgproto.Client, chatID int64, messageID int) (*types.File, error) {
key := fmt.Sprintf("file:%d:%d", chatID, messageID)
logger.L.Debugf("Getting file: %s", key)
var cachedFile types.File
err := common.Cache.Get(key, &cachedFile)
if err == nil {
return &cachedFile, nil
}
message, err := GetTGMessage(ctx, client, messageID)
if err != nil {
return nil, err
}
file, err := FileFromMedia(message.Media)
if err != nil {
return nil, err
}
if err := common.Cache.Set(key, file, 3600); err != nil {
logger.L.Errorf("Failed to cache file: %s", err)
}
return file, nil
}
func GetTGMessage(ctx context.Context, client *gotgproto.Client, messageID int) (*tg.Message, error) {
logger.L.Debugf("Fetching message: %d", messageID)
res, err := client.API().MessagesGetMessages(ctx, []tg.InputMessageClass{
&tg.InputMessageID{
ID: messageID,
},
})
if err != nil {
return nil, err
}
messages := res.(*tg.MessagesMessages)
msg := messages.Messages[0]
if _, ok := msg.(*tg.Message); !ok {
return nil, fmt.Errorf("unexpected type %T, this file may be deleted", msg)
}
return msg.(*tg.Message), nil
}

102
client/bot/bot.go Normal file
View File

@@ -0,0 +1,102 @@
package bot
import (
"context"
"time"
"github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/sessionMaker"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
)
var ectx *ext.Context
func ExtContext() *ext.Context {
return ectx
}
func Init(ctx context.Context) <-chan struct{} {
log.FromContext(ctx).Info("Initializing Bot...")
resultChan := make(chan struct {
client *gotgproto.Client
err error
})
shouldRestart := make(chan struct{})
go func() {
resolver, err := tgutil.NewConfigProxyResolver()
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
client, err := gotgproto.NewClient(
config.C().Telegram.AppID,
config.C().Telegram.AppHash,
gotgproto.ClientTypeBot(config.C().Telegram.Token),
&gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(database.GetDialect(config.C().DB.Session)),
DisableCopyright: true,
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
Resolver: resolver,
Context: ctx,
MaxRetries: config.C().Telegram.RpcRetry,
AutoFetchReply: true,
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
if s == "SAVEANTBOT-RESTART" {
shouldRestart <- struct{}{}
return dispatcher.EndGroups
}
log.FromContext(ctx).Errorf("unhandled error: %s", s)
return dispatcher.EndGroups
},
},
)
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
Scope: &tg.BotCommandScopeDefault{},
})
commands := make([]tg.BotCommand, 0, len(handlers.CommandHandlers))
for _, info := range handlers.CommandHandlers {
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: i18n.T(info.Desc)})
}
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
Scope: &tg.BotCommandScopeDefault{},
Commands: commands,
})
resultChan <- struct {
client *gotgproto.Client
err error
}{client, err}
}()
select {
case <-ctx.Done():
log.FromContext(ctx).Errorf("Bot initialization cancelled: %s", ctx.Err())
case result := <-resultChan:
if result.err != nil {
log.FromContext(ctx).Fatalf("Failed to initialize Bot: %s", result.err)
}
handlers.Register(result.client.Dispatcher)
ectx = result.client.CreateContext()
log.FromContext(ctx).Info("Bot initialization completed.")
}
return shouldRestart
}

View File

@@ -0,0 +1,97 @@
package handlers
import (
"errors"
"fmt"
"path"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
"gorm.io/gorm"
)
func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
data, err := shortcut.GetCallbackDataWithAnswer[tcbdata.Add](ctx, update, dataid)
if err != nil {
return err
}
queryID := update.CallbackQuery.GetQueryID()
msgID := update.CallbackQuery.GetMsgID()
userID := update.CallbackQuery.GetUserID()
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.SelectedStorName)
if err != nil {
log.FromContext(ctx).Errorf("Failed to get storage: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
})))
return dispatcher.EndGroups
}
dirs, err := database.GetDirsByUserChatIDAndStorageName(ctx, userID, data.SelectedStorName)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("failed to get user directories: %w", err)
}
if !data.SettedDir && len(dirs) != 0 {
// ask for directory selection
markup, err := msgelem.BuildSetDirMarkupForAdd(dirs, dataid)
if err != nil {
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{
"Error": err.Error(),
})))
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgCommonPromptSelectDir, nil),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
dirPath := ""
if data.DirID != 0 {
dir, err := database.GetDirByID(ctx, data.DirID)
if err != nil {
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
"Error": err.Error(),
})))
return dispatcher.EndGroups
}
dirPath = dir.Path
}
switch data.TaskType {
case tasktype.TaskTypeTgfiles:
if data.AsBatch {
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID)
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
case tasktype.TaskTypeTphpics:
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
case tasktype.TaskTypeParseditem:
if len(data.ParsedItem.Resources) > 1 {
dirPath = path.Join(dirPath, fsutil.NormalizePathname(data.ParsedItem.Title))
}
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
case tasktype.TaskTypeDirectlinks:
shortcut.CreateAndAddDirectTaskWithEdit(ctx, selectedStorage, dirPath, data.DirectLinks, msgID, userID)
default:
return fmt.Errorf("unexcept task type: %s", data.TaskType)
}
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,53 @@
package handlers
import (
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/core"
)
func handleCancelCallback(ctx *ext.Context, update *ext.Update) error {
taskid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
if err := core.CancelTask(ctx, taskid); err != nil {
log.FromContext(ctx).Errorf("Failed to cancel task %s: %v", taskid, err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), i18n.T(i18nk.BotMsgCancelErrorCancelFailed, map[string]any{
"Error": err.Error(),
})))
return dispatcher.EndGroups
}
ctx.EditMessage(update.CallbackQuery.GetUserID(), &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgCancelInfoCancellingTask, nil),
})
return dispatcher.EndGroups
}
func handleCancelCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Fields(update.EffectiveMessage.Text)
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelUsage, nil)), nil)
return dispatcher.EndGroups
}
taskID := args[1]
if err := core.CancelTask(ctx, taskID); err != nil {
logger.Errorf("failed to cancel task %s: %v", taskID, err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelErrorCancelFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCancelInfoCancelRequested, map[string]any{
"TaskID": taskID,
})), nil)
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,143 @@
package handlers
import (
"fmt"
"strings"
"text/template"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigPromptSelectOption)), &ext.ReplyOpts{
Markup: &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: i18n.T(i18nk.BotMsgConfigButtonFilenameStrategy),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
},
},
},
},
},
})
return dispatcher.EndGroups
}
func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
args := strings.Fields(string(update.CallbackQuery.Data))
invaildDataAnswer := func() error {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: i18n.T(i18nk.BotMsgConfigErrorInvalidCallbackData),
CacheTime: 5,
})
return dispatcher.EndGroups
}
if len(args) < 2 {
return invaildDataAnswer()
}
switch args[1] {
case "fnamest":
return handleConfigFnameSTCallback(ctx, update)
default:
return invaildDataAnswer()
}
}
func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
userID := update.CallbackQuery.GetUserID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
args := strings.Fields(string(update.CallbackQuery.Data))
if len(args) == 3 {
selected := args[2]
st, err := fnamest.ParseFnameST(selected)
if err != nil {
return err
}
user.FilenameStrategy = st.String()
if err := database.UpdateUser(ctx, user); err != nil {
return err
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgConfigInfoFilenameStrategySet, map[string]any{
"Strategy": fnamest.FnameSTDisplay[st],
}),
})
return dispatcher.EndGroups
}
opts := fnamest.FnameSTValues()
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
for _, opt := range opts {
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: fnamest.FnameSTDisplay[opt],
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
})
}
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
{Buttons: buttons},
}}
currentStStr := user.FilenameStrategy
if currentStStr == "" {
currentStStr = fnamest.Default.String()
}
currentSt, err := fnamest.ParseFnameST(currentStStr)
if err != nil {
currentSt = fnamest.Default
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgConfigPromptSelectFilenameStrategy, map[string]any{
"Strategy": fnamest.FnameSTDisplay[currentSt],
}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
args := strings.Fields(string(update.EffectiveMessage.Text))
if len(args) <= 1 {
text := i18n.T(i18nk.BotMsgConfigFnametmplHelp, nil)
if user.FilenameTemplate != "" {
text += "\n\n" + i18n.T(i18nk.BotMsgConfigInfoCurrentTemplatePrefix, map[string]any{
"Template": user.FilenameTemplate,
})
}
ctx.Reply(update, ext.ReplyTextString(text), nil)
return dispatcher.EndGroups
}
newTmpl := strings.Join(args[1:], " ")
_, err = template.New("filename").Parse(newTmpl)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigErrorInvalidTemplate, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
user.FilenameTemplate = newTmpl
if err := database.UpdateUser(ctx, user); err != nil {
return err
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgConfigInfoTemplateUpdated, nil)), nil)
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,76 @@
package handlers
import (
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/storage"
)
func handleDirCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
userChatID := update.GetUserChat().GetID()
dirs, err := database.GetUserDirsByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("Failed to get user directories: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorGetUserDirsFailed)), nil)
return dispatcher.EndGroups
}
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
return dispatcher.EndGroups
}
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
logger.Errorf("Failed to get user: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorGetUserFailed)), nil)
return dispatcher.EndGroups
}
switch args[1] {
case "add":
// /dir add local1 path/to/dir
if len(args) < 4 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
return dispatcher.EndGroups
}
if _, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, args[2]); err != nil {
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
return dispatcher.EndGroups
}
if err := database.CreateDirForUser(ctx, user.ID, args[2], args[3]); err != nil {
logger.Errorf("Failed to create directory: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorCreateDirFailed)), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirInfoCreateDirSuccess)), nil)
case "del":
// /dir del 3
if len(args) < 3 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
return dispatcher.EndGroups
}
dirID, err := strconv.Atoi(args[2])
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorInvalidDirId)), nil)
return dispatcher.EndGroups
}
if err := database.DeleteDirByID(ctx, uint(dirID)); err != nil {
logger.Errorf("Failed to delete directory: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorDeleteDirFailed)), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirInfoDeleteDirSuccess)), nil)
default:
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDirErrorUnknownOperation)), nil)
}
return dispatcher.EndGroups
}

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

@@ -0,0 +1,52 @@
package handlers
import (
"net/url"
"strings"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleDlCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlUsage)), nil)
return nil
}
links := args[1:]
for i, link := range links {
links[i] = strings.TrimSpace(link)
u, err := url.Parse(link)
if err != nil || u.Scheme == "" || u.Host == "" {
logger.Warn("invaild link", link)
links[i] = ""
}
}
links = slice.Compact(links)
if len(links) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlErrorNoValidLinks)), nil)
return nil
}
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, update.GetUserChat().GetID()), tcbdata.Add{
TaskType: tasktype.TaskTypeDirectlinks,
DirectLinks: links,
})
if err != nil {
return err
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgDlInfoFilesSelectStorage, map[string]any{
"Count": len(links),
})), &ext.ReplyOpts{
Markup: markup,
})
return nil
}

View File

@@ -0,0 +1,20 @@
package handlers
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
)
func handleHelpCmd(ctx *ext.Context, update *ext.Update) error {
shortHash := config.GitCommit
if len(shortHash) > 7 {
shortHash = shortHash[:7]
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(i18n.T(i18nk.BotMsgHelpTextFmt), config.Version, shortHash)), nil)
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,63 @@
package handlers
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleMessageLink(ctx *ext.Context, update *ext.Update) error {
replied, files, editReplied, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
if err != nil {
return err
}
logger := log.FromContext(ctx)
userId := update.GetUserChat().GetID()
stors := storage.GetUserStorages(ctx, userId)
if len(files) == 1 {
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, files[0], replied.ID)
if err != nil {
logger.Errorf("Failed to build storage selection message: %s", err)
editReplied(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{
"Error": err.Error(),
}), nil)
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), req)
return dispatcher.EndGroups
}
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
Files: files,
})
if err != nil {
logger.Errorf("Failed to build storage selection keyboard: %s", err)
editReplied(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{
"Error": err.Error(),
}), nil)
return dispatcher.EndGroups
}
editReplied(i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{
"Count": len(files),
}), markup)
return dispatcher.EndGroups
}
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
stor := storage.FromContext(ctx)
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
if err != nil {
return err
}
userId := update.GetUserChat().GetID()
if len(files) == 1 {
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files[0], replied.ID)
}
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, dirutil.PathFromContext(ctx), files, replied.ID)
}

View File

@@ -0,0 +1,69 @@
package handlers
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/storage"
)
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
message := update.EffectiveMessage.Message
groupID, isGroup := message.GetGroupedID()
if isGroup && groupID != 0 {
return handleGroupMediaMessage(ctx, update, message, groupID)
}
logger.Debugf("Got media: %s", message.Media.TypeName())
userId := update.GetUserChat().GetID()
userDB, err := database.GetUserByChatID(ctx, userId)
if err != nil {
return err
}
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
if err != nil {
return err
}
stors := storage.GetUserStorages(ctx, userId)
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
if err != nil {
logger.Errorf("Failed to build storage selection message: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), req)
return dispatcher.EndGroups
}
func handleSilentSaveMedia(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
message := update.EffectiveMessage.Message
groupID, isGroup := message.GetGroupedID()
if isGroup && groupID != 0 {
return handleGroupMediaMessage(ctx, update, message, groupID)
}
logger.Debugf("Got media: %s", message.Media.TypeName())
userID := update.GetUserChat().GetID()
userDB, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, message, tfOpts...)
if err != nil {
return err
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, stor, dirutil.PathFromContext(ctx), file, msg.ID)
}

View File

@@ -0,0 +1,131 @@
package handlers
import (
"sync"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
type MediaGroupHandler struct {
groups map[int64][]tfile.TGFileMessage
timers map[int64]*time.Timer
mu sync.Mutex
timeout time.Duration
setupOnce sync.Once
}
func (m *MediaGroupHandler) SetupTimeout(timeoutSec int) {
m.setupOnce.Do(func() {
if timeoutSec < 1 {
timeoutSec = 1
}
m.timeout = time.Duration(timeoutSec) * time.Second
})
}
var (
mediaGroupHandler = &MediaGroupHandler{
groups: make(map[int64][]tfile.TGFileMessage),
timers: make(map[int64]*time.Timer),
mu: sync.Mutex{},
}
)
func handleGroupMediaMessage(ctx *ext.Context, update *ext.Update, message *tg.Message, groupID int64) error {
mediaGroupHandler.SetupTimeout(max(config.C().Telegram.MediaGroupTimeout, 1))
logger := log.FromContext(ctx)
media := message.Media
supported := mediautil.IsSupported(media)
if !supported {
return dispatcher.EndGroups
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfile.WithNameIfEmpty(
tgutil.GenFileNameFromMessage(*message),
))
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
return dispatcher.EndGroups
}
mediaGroupHandler.mu.Lock()
defer mediaGroupHandler.mu.Unlock()
if mediaGroupHandler.groups[groupID] == nil {
mediaGroupHandler.groups[groupID] = make([]tfile.TGFileMessage, 0)
}
mediaGroupHandler.groups[groupID] = append(mediaGroupHandler.groups[groupID], file)
if timer, exists := mediaGroupHandler.timers[groupID]; exists {
timer.Stop()
}
mediaGroupHandler.timers[groupID] = time.AfterFunc(mediaGroupHandler.timeout, func() {
processMediaGroup(ctx, update, groupID)
})
return dispatcher.EndGroups
}
func processMediaGroup(ctx *ext.Context, update *ext.Update, groupID int64) {
logger := log.FromContext(ctx)
mediaGroupHandler.mu.Lock()
items := mediaGroupHandler.groups[groupID]
delete(mediaGroupHandler.groups, groupID)
delete(mediaGroupHandler.timers, groupID)
mediaGroupHandler.mu.Unlock()
if len(items) == 0 {
logger.Warn("No media items to process for group", "groupID", groupID)
return
}
logger.Debugf("Processing media group %d with %d items", groupID, len(items))
userId := update.GetUserChat().GetID()
msg, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgMediaGroupInfoSavingFiles, nil)), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return
}
stor := storage.FromContext(ctx)
if stor != nil {
// In silent mode
if len(items) == 1 {
shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", items[0], msg.ID)
return
}
shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", items, msg.ID)
return
}
stors := storage.GetUserStorages(ctx, userId)
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
Files: items,
AsBatch: len(items) > 1,
})
if err != nil {
logger.Errorf("Failed to build storage selection keyboard: %s", err)
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: i18n.T(i18nk.BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed, map[string]any{
"Error": err.Error(),
}),
})
return
}
ctx.EditMessage(userId, &tg.MessagesEditMessageRequest{
ID: msg.ID,
Message: i18n.T(i18nk.BotMsgMediaGroupInfoGroupFoundFilesSelectStorage, map[string]any{
"Count": len(items),
}),
ReplyMarkup: markup,
})
}

View File

@@ -0,0 +1,62 @@
package handlers
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/storage"
)
func checkPermission(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
if !slice.Contain(config.C().GetUsersID(), userID) {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoPermission, nil)), nil)
return dispatcher.EndGroups
}
return dispatcher.ContinueGroups
}
func handleSilentMode(next func(*ext.Context, *ext.Update) error, handler func(*ext.Context, *ext.Update) error) func(*ext.Context, *ext.Update) error {
return func(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
if !user.Silent {
return next(ctx, update)
}
if user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet, nil)), nil)
return next(ctx, update)
}
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, user.DefaultStorage)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
if user.DefaultDir != 0 {
dir, err := database.GetDirByID(ctx, user.DefaultDir)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
"Error": err.Error(),
})), nil)
return next(ctx, update)
}
ctx.Context = dirutil.WithContext(ctx.Context, dir)
}
ctx.Context = storage.WithContext(ctx.Context, stor)
return handler(ctx, update)
}
}

View File

@@ -0,0 +1,149 @@
// 处理任意文本消息, 用于通用地从外部源下载文件
package handlers
import (
"errors"
"path"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/parsers"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleTextMessage(ctx *ext.Context, u *ext.Update) error {
logger := log.FromContext(ctx)
text := u.EffectiveMessage.Text
entityUrls := tgutil.ExtractMessageEntityUrls(u.EffectiveMessage.Message)
if len(entityUrls) > 0 {
text += "\n" + strings.Join(entityUrls, "\n")
}
// read lines and remove empty lines & duplicates
lines := strings.Split(text, "\n")
seen := make(map[string]struct{})
var processedLines []string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if _, ok := seen[line]; ok {
continue
}
seen[line] = struct{}{}
processedLines = append(processedLines, line)
}
source := strings.TrimSpace(strings.Join(processedLines, "\n"))
ok, pser := parsers.CanHandle(source)
if !ok {
return dispatcher.EndGroups
}
msg, err := ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseInfoParsing, nil)), nil)
if err != nil {
return err
}
item, err := pser.Parse(ctx, source)
if errors.Is(err, parsers.ErrNoParserFound) {
return dispatcher.EndGroups
}
if err != nil {
logger.Error("Failed to parse text", "error", err)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorParseTextFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
userID := u.GetUserChat().GetID()
markup, err := msgelem.BuildAddSelectStorageKeyboard(storage.GetUserStorages(ctx, userID), tcbdata.Add{
TaskType: tasktype.TaskTypeParseditem,
ParsedItem: item,
})
if err != nil {
logger.Errorf("Failed to build storage selection keyboard: %s", err)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildStorageSelectKeyboardFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
text, entities, err := msgelem.BuildParsedTextEntity(*item)
if err != nil {
logger.Errorf("Failed to build parsed text entity: %s", err)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildParsedTextEntityFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
Message: text,
ReplyMarkup: markup,
Entities: entities,
ID: msg.ID,
})
return dispatcher.EndGroups
}
func handleSilentSaveText(ctx *ext.Context, u *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
text := u.EffectiveMessage.Text
if text == "" {
return dispatcher.EndGroups
}
item, err := parsers.ParseWithContext(ctx, text)
if errors.Is(err, parsers.ErrNoParserFound) {
return dispatcher.EndGroups
}
if err != nil {
logger.Error("Failed to parse text", "error", err)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorParseTextFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
logger.Debug("Parsed item from text message", "title", item.Title, "url", item.URL)
userID := u.GetUserChat().GetID()
text, entities, err := msgelem.BuildParsedTextEntity(*item)
if err != nil {
logger.Errorf("Failed to build parsed text entity: %s", err)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParseErrorBuildParsedTextEntityFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
msg, err := ctx.SendMessage(userID, &tg.MessagesSendMessageRequest{
Message: text,
Entities: entities,
ReplyTo: &tg.InputReplyToMessage{
ReplyToMsgID: u.EffectiveMessage.ID,
ReplyToPeerID: u.GetUserChat().AsInputPeer(),
},
})
if err != nil {
logger.Errorf("Failed to send message: %s", err)
return dispatcher.EndGroups
}
dirPath := ""
if len(item.Resources) > 1 {
dirPath = fsutil.NormalizePathname(item.Title)
}
if p := dirutil.PathFromContext(ctx); p != "" {
dirPath = path.Join(p, dirPath)
}
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, dirPath, item, msg.ID, userID)
}

View File

@@ -0,0 +1,101 @@
package handlers
import (
"bytes"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/parsers"
)
func handleParserCmd(ctx *ext.Context, u *ext.Update) error {
args := strings.Split(u.EffectiveMessage.Text, " ")
help := i18n.T(i18nk.BotMsgParserHelpText, nil)
if len(args) < 2 {
ctx.Reply(u, ext.ReplyTextString(help), nil)
return nil
}
switch args[1] {
// case "list":
// return handleParserListCmd(ctx, u)
case "install":
return handleParserInstallCmd(ctx, u)
// case "uninstall":
// return handleParserUninstallCmd(ctx, u)
default:
}
return dispatcher.EndGroups
}
func handleParserInstallCmd(ctx *ext.Context, u *ext.Update) error {
if !config.C().Parser.PluginEnable {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserPluginNotEnabled, nil)), nil)
return dispatcher.EndGroups
}
if u.EffectiveMessage.ReplyToMessage == nil || u.EffectiveMessage.ReplyToMessage.Media == nil {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserPromptReplyWithParserFile, nil)), nil)
return dispatcher.EndGroups
}
media := u.EffectiveMessage.ReplyToMessage.Media
document, ok := media.(*tg.MessageMediaDocument)
if !ok {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
return dispatcher.EndGroups
}
value, ok := document.GetDocument()
if !ok {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
return dispatcher.EndGroups
}
doc, ok := value.AsNotEmpty()
if !ok {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorNoValidFileInReply, nil)), nil)
return dispatcher.EndGroups
}
if !strings.HasPrefix(doc.MimeType, "text/") {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorWrongFileType, nil)), nil)
return dispatcher.EndGroups
}
if doc.Size > 1024*1024*10 {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorFileTooLarge, nil)), nil)
return dispatcher.EndGroups
}
var fileName string
for _, attr := range doc.Attributes {
if fileNameAttr, ok := attr.(*tg.DocumentAttributeFilename); ok {
fileName = fileNameAttr.FileName
break
}
}
if fileName == "" {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorGetFilenameFailed, nil)), nil)
return dispatcher.EndGroups
}
if !strings.HasSuffix(fileName, ".js") {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorOnlyJsSupported, nil)), nil)
return dispatcher.EndGroups
}
data := bytes.NewBuffer(nil)
_, err := ctx.DownloadMedia(media, ext.DownloadOutputStream{Writer: data}, nil)
if err != nil {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorDownloadFileFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
if err := parsers.AddPlugin(ctx, data.String(), fileName); err != nil {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserErrorInstallPluginFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgParserInfoInstallPluginSuccess, map[string]any{
"Name": fileName,
})), nil)
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,68 @@
package handlers
import (
"regexp"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/dispatcher/handlers"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/ext"
sabotfilters "github.com/krau/SaveAny-Bot/client/bot/handlers/utils/filters"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
type DescCommandHandler struct {
Cmd string
Desc i18nk.Key
handler func(ctx *ext.Context, u *ext.Update) error
}
var CommandHandlers = []DescCommandHandler{
{"start", i18nk.BotMsgCmdStart, handleHelpCmd},
{"silent", i18nk.BotMsgCmdSilent, handleSilentCmd},
{"storage", i18nk.BotMsgCmdStorage, handleStorageCmd},
{"dir", i18nk.BotMsgCmdDir, handleDirCmd},
{"rule", i18nk.BotMsgCmdRule, handleRuleCmd},
{"save", i18nk.BotMsgCmdSave, handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
{"dl", i18nk.BotMsgCmdDl, handleDlCmd},
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
{"watch", i18nk.BotMsgCmdWatch, handleWatchCmd},
{"unwatch", i18nk.BotMsgCmdUnwatch, handleUnwatchCmd},
{"lswatch", i18nk.BotMsgCmdLswatch, handleLswatchCmd},
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},
{"fnametmpl", i18nk.BotMsgCmdFnametmpl, handleConfigFnameTmpl},
{"help", i18nk.BotMsgCmdHelp, handleHelpCmd},
{"parser", i18nk.BotMsgCmdParser, handleParserCmd},
{"update", i18nk.BotMsgCmdUpdate, handleUpdateCmd},
}
func Register(disp dispatcher.Dispatcher) {
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChannel), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChat), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
for _, info := range CommandHandlers {
disp.AddHandler(handlers.NewCommand(info.Cmd, info.handler))
}
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("update"), handleUpdateCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TgMessageLinkRegexString)), handleSilentMode(handleMessageLink, handleSilentSaveLink)))
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TelegraphUrlRegexString)), handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
disp.AddHandler(handlers.NewMessage(filters.Message.Text, handleSilentMode(handleTextMessage, handleSilentSaveText)))
if config.C().Telegram.Userbot.Enable {
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
}
}

110
client/bot/handlers/rule.go Normal file
View File

@@ -0,0 +1,110 @@
package handlers
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/rule"
)
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strutil.ParseArgsRespectQuotes(update.EffectiveMessage.Text)
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("Failed to get user rules: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorGetUserRulesFailed, nil)), nil)
return dispatcher.EndGroups
}
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
switch args[1] {
case "switch":
// /rule switch
applyRule := !user.ApplyRule
if err := database.UpdateUserApplyRule(ctx, user.ChatID, applyRule); err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorUpdateUserFailed, nil)), nil)
return dispatcher.EndGroups
}
if applyRule {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoRuleModeEnabled, nil)), nil)
} else {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoRuleModeDisabled, nil)), nil)
}
case "add":
// /rule add <type> <data> <storage> <dirpath>
if len(args) < 6 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
ruleTypeArg := args[2]
ruleType, err := func() (rule.RuleType, error) {
for _, t := range rule.Values() {
if strings.EqualFold(t.String(), ruleTypeArg) {
return t, nil
}
}
return rule.RuleType(""), fmt.Errorf("invalid rule type: %s\navailable: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
}()
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorInvalidRuleType, map[string]any{
"Type": ruleTypeArg,
"Available": slice.Join(rule.Values(), ", "),
})), nil)
return dispatcher.EndGroups
}
ruleData := args[3]
storageName := args[4]
dirPath := args[5]
rd := &database.Rule{
Type: ruleType.String(),
Data: ruleData,
StorageName: storageName,
DirPath: dirPath,
UserID: user.ID,
}
if err := database.CreateRule(ctx, rd); err != nil {
logger.Errorf("failed to create rule: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorCreateRuleFailed, nil)), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoCreateRuleSuccess, nil)), nil)
case "del":
// /rule del <id>
if len(args) < 3 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRulePromptProvideRuleId, nil)), nil)
return dispatcher.EndGroups
}
ruleID := args[2]
id, err := strconv.Atoi(ruleID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorInvalidRuleId, nil)), nil)
return dispatcher.EndGroups
}
if err := database.DeleteRule(ctx, uint(id)); err != nil {
logger.Errorf("failed to delete rule %d: %s", id, err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorDeleteRuleFailed, nil)), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoDeleteRuleSuccess, nil)), nil)
default:
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
return dispatcher.EndGroups
}

188
client/bot/handlers/save.go Normal file
View File

@@ -0,0 +1,188 @@
package handlers
import (
"regexp"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1:])
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups
}
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
return err
}
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
if len(args) > 1 {
// custom filename via command arg
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
}
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
if err != nil {
return err
}
userId := update.GetUserChat().GetID()
stors := storage.GetUserStorages(ctx, userId)
req, err := msgelem.BuildAddOneSelectStorageMessage(ctx, stors, file, msg.ID)
if err != nil {
logger.Errorf("Failed to build storage selection message: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectMessageFailed, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), req)
return dispatcher.EndGroups
}
func handleSilentSaveReplied(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.EffectiveMessage.Text), " ")
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1:])
}
stor := storage.FromContext(ctx)
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups
}
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
return err
}
opts := mediautil.TfileOptions(ctx, userDB, replyTo.Message)
if len(args) > 1 {
// custom filename via command arg
opts = append(opts, tfile.WithName(strings.Join(args[1:], " ")))
}
msg, file, err := shortcut.GetFileFromMessageWithReply(ctx, update, replyTo.Message, opts...)
if err != nil {
return err
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, dirutil.PathFromContext(ctx), file, msg.GetID())
}
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
chatArg := args[0]
msgIdRangeArg := args[1]
var filterStr string
var filter *regexp.Regexp
if len(args) > 2 {
filterStr = args[2]
var err error
filter, err = regexp.Compile(filterStr)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidRegex, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
}
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidMsgIdRange, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
chatID, err := tgutil.ParseChatID(ctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingMessages)), nil)
if err != nil {
log.FromContext(ctx).Errorf("Failed to reply: %s", err)
return dispatcher.EndGroups
}
// [TODO]: generator istead of get all messages
msgs, err := tgutil.GetMessagesRange(ctx, chatID, int(startID), int(endID))
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetMessagesFailed, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
if len(msgs) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoMessagesInRange)), nil)
return dispatcher.EndGroups
}
files := make([]tfile.TGFileMessage, 0, len(msgs))
sb := strings.Builder{}
for _, msg := range msgs {
if msg == nil {
continue
}
media, ok := msg.GetMedia()
if !ok {
continue
}
supported := mediautil.IsSupported(media)
if !supported {
continue
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
log.FromContext(ctx).Errorf("Failed to get file from message: %s", err)
continue
}
if filter != nil {
sb.Reset()
sb.WriteString(msg.GetMessage())
sb.WriteString(" ")
fn, _ := tgutil.GetMediaFileName(media)
sb.WriteString(fn)
if !filter.MatchString(sb.String()) {
continue
}
}
files = append(files, file)
}
if len(files) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoSavableMessagesInRange)), nil)
return dispatcher.EndGroups
}
stor := storage.FromContext(ctx)
if stor == nil {
// not in silent mode
stors := storage.GetUserStorages(ctx, update.GetUserChat().GetID())
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
Files: files,
})
if err != nil {
log.FromContext(ctx).Errorf("Failed to build storage selection keyboard: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgCommonErrorBuildStorageSelectKeyboardFailed, map[string]any{"Error": err.Error()}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: i18n.T(i18nk.BotMsgCommonInfoFoundFilesSelectStorage, map[string]any{"Count": len(files)}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
}

View File

@@ -0,0 +1,150 @@
package handlers
import (
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleSilentCmd(ctx *ext.Context, update *ext.Update) error {
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil
}
if !user.Silent && user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet, nil)), nil)
return nil
}
user.Silent = !user.Silent
if err := database.UpdateUser(ctx, user); err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorUpdateUserInfoFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil
}
if user.Silent {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoSilentModeOn, nil)), nil)
} else {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoSilentModeOff, nil)), nil)
}
return dispatcher.EndGroups
}
func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
data, ok := cache.Get[tcbdata.SetDefaultStorage](dataid)
failedAnswer := func(message string) error {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: message,
CacheTime: 5,
})
return dispatcher.EndGroups
}
if !ok {
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorDataExpired, nil))
}
userID := update.CallbackQuery.GetUserID()
storageName := data.StorageName
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
if err != nil {
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
}))
}
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
"Error": err.Error(),
}))
}
var dir *database.Dir
if data.DirID != 0 {
// 已经选择了文件夹
var err error
dir, err = database.GetDirByID(ctx, data.DirID)
if err != nil {
return failedAnswer(i18n.T(i18nk.BotMsgDirErrorGetUserDirsFailed, nil))
}
user.DefaultDir = dir.ID
} else {
// 检查是否有可用的文件夹
dirs, err := database.GetDirsByUserIDAndStorageName(ctx, user.ID, storageName)
if err != nil {
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorGetDirFailed, map[string]any{
"Error": err.Error(),
}))
}
if len(dirs) > 0 {
// 要求选择文件夹
markup, err := msgelem.BuildSetDefaultDirMarkup(ctx, storageName, dirs)
if err != nil {
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorBuildDirSelectKeyboardFailed, map[string]any{
"Error": err.Error(),
}))
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgCommonPromptSelectDefaultDir, nil),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
}
user.DefaultStorage = selectedStorage.Name()
if err := database.UpdateUser(ctx, user); err != nil {
return failedAnswer(i18n.T(i18nk.BotMsgCommonErrorUpdateUserInfoFailed, map[string]any{
"Error": err.Error(),
}))
}
msg := i18n.T(i18nk.BotMsgCommonInfoDefaultStorageSet, map[string]any{
"Name": selectedStorage.Name(),
})
if dir != nil {
msg = i18n.T(i18nk.BotMsgCommonInfoDefaultStorageWithDirSet, map[string]any{
"Name": selectedStorage.Name(),
"Dir": strings.TrimPrefix(dir.Path, "/"),
})
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: msg,
})
return dispatcher.EndGroups
}
func handleStorageCmd(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
storages := storage.GetUserStorages(ctx, userID)
if len(storages) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoAvailableStorage, nil)), nil)
return nil
}
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, storages)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonPromptSelectDefaultStorage, nil)), &ext.ReplyOpts{
Markup: markup,
})
return dispatcher.EndGroups
}

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

@@ -0,0 +1,80 @@
package handlers
import (
"fmt"
"path"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
)
func handleTelegraphUrlMessage(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
if err != nil {
return err
}
userID := update.GetUserChat().GetID()
stors := storage.GetUserStorages(ctx, userID)
markup, err := msgelem.BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
TaskType: tasktype.TaskTypeTphpics,
TphPageNode: result.Page,
TphDirPath: result.TphDir,
TphPics: result.Pics,
})
if err != nil {
logger.Errorf("Failed to build storage selection keyboard: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgTelegraphErrorBuildStorageSelectKeyboardFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
eb := entity.Builder{}
if err := styling.Perform(&eb,
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoTitlePrefix, nil)),
styling.Code(result.Page.Title),
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoPicCountPrefix, nil)),
styling.Code(fmt.Sprintf("%d", len(result.Pics))),
styling.Plain(i18n.T(i18nk.BotMsgTelegraphInfoPromptSelectStorage, nil)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
return dispatcher.EndGroups
}
text, entities := eb.Complete()
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
Message: text,
ID: msg.ID,
ReplyMarkup: markup,
Entities: entities,
})
return dispatcher.EndGroups
}
func handleSilentSaveTelegraph(ctx *ext.Context, update *ext.Update) error {
stor := storage.FromContext(ctx)
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
if err != nil {
return err
}
userID := update.GetUserChat().GetID()
dirpath := result.TphDir
if p := dirutil.PathFromContext(ctx); p != "" {
dirpath = path.Join(p, dirpath)
}
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, result.Page, dirpath, result.Pics, stor, msg.ID)
}

View File

@@ -0,0 +1,128 @@
package handlers
import (
"errors"
"regexp"
"strings"
"github.com/blang/semver"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/telegram/message/html"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/unvgo/ghselfupdate"
)
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
currentV, err := semver.Parse(config.Version)
if err != nil {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorVersionVarInvalid, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
latest, ok, err := ghselfupdate.DetectLatest(config.GitRepo)
if err != nil {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorCheckLatestFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
if !ok {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateErrorNoReleaseFound, nil)), nil)
return dispatcher.EndGroups
}
if latest.Version.Major != currentV.Major {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateInfoMajorUpgradeRequired, map[string]any{
"Current": currentV.String(),
"Latest": latest.Version.String(),
})), nil)
return dispatcher.EndGroups
}
if latest.Version.LT(currentV) || latest.Version.Equals(currentV) {
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgUpdateInfoAlreadyLatest, map[string]any{
"Version": config.Version,
})), nil)
return dispatcher.EndGroups
}
indocker := config.Docker == "true"
ctx.Sender.To(u.GetUserChat().AsInputPeer()).StyledText(ctx, html.String(nil, func() string {
md := latest.ReleaseNotes
md = regexp.MustCompile(`(?m)^###\s+&nbsp;&nbsp;&nbsp;(.+)$`).ReplaceAllString(md, "<b>$1</b>")
md = regexp.MustCompile(`(?m)^#####\s+&nbsp;&nbsp;&nbsp;&nbsp;(.+)$`).ReplaceAllString(md, "<i>$1</i>")
md = regexp.MustCompile(`(?m)^- `).ReplaceAllString(md, "• ")
md = regexp.MustCompile(`\[\((\w{6,})\)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `(<a href="$2">$1</a>)`)
md = regexp.MustCompile(`\[(.+?)\]\((https?://[^\s)]+)\)`).ReplaceAllString(md, `<a href="$2">$1</a>`)
md = strings.ReplaceAll(md, "&nbsp;", " ")
return `<blockquote expandable>` + md + `</blockquote>`
}()))
if indocker {
text := i18n.T(i18nk.BotMsgUpdateInfoNewVersionInDocker, map[string]any{
"Latest": latest.Version.String(),
"Current": config.Version,
"PublishedAt": latest.PublishedAt.Format("2006-01-02 15:04:05"),
})
ctx.Reply(u, ext.ReplyTextString(text), nil)
return dispatcher.EndGroups
}
text := i18n.T(i18nk.BotMsgUpdateInfoNewVersionPromptUpgrade, map[string]any{
"Latest": latest.Version.String(),
"Current": config.Version,
"SizeMB": float64(latest.AssetByteSize) / (1024 * 1024),
"URL": latest.AssetURL,
"PublishedAt": latest.PublishedAt.Format("2006-01-02 15:04:05"),
})
ctx.Reply(u, ext.ReplyTextString(text), &ext.ReplyOpts{
Markup: &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: i18n.T(i18nk.BotMsgUpdateButtonUpgrade, nil),
Data: []byte("update"),
},
},
},
},
},
})
return dispatcher.EndGroups
}
func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
currentV, err := semver.Parse(config.Version)
if err != nil {
return err
}
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
ID: u.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgUpdateInfoUpgradingWithVersion, map[string]any{
"Current": config.Version,
}),
})
latest, err := ghselfupdate.UpdateSelf(currentV, config.GitRepo)
if err != nil {
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
ID: u.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgUpdateErrorUpgradeFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
ID: u.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgUpdateInfoUpgradeSuccess, map[string]any{
"Version": latest.Version.String(),
}),
})
return errors.New("SAVEANTBOT-RESTART")
}

View File

@@ -0,0 +1,37 @@
package dirutil
import (
"context"
"github.com/krau/SaveAny-Bot/database"
)
type contextKey struct{}
var dirContextKey = contextKey{}
func WithContext(ctx context.Context, dir *database.Dir) context.Context {
if dir == nil {
return ctx
}
return context.WithValue(ctx, dirContextKey, dir)
}
func FromContext(ctx context.Context) *database.Dir {
dir, ok := ctx.Value(dirContextKey).(*database.Dir)
if !ok {
return nil
}
return dir
}
// PathFromContext returns the directory path stored in the context.
//
// If no directory is found, an empty string is returned.
func PathFromContext(ctx context.Context) string {
dir := FromContext(ctx)
if dir == nil {
return ""
}
return dir.Path
}

View File

@@ -0,0 +1,26 @@
package filters
import (
"regexp"
"slices"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/types"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
)
func RegexUrl(r *regexp.Regexp) filters.MessageFilter {
return func(m *types.Message) bool {
if m.Text == "" {
return false
}
if r.MatchString(m.Text) {
return true
}
urls := tgutil.ExtractMessageEntityUrls(m.Message)
if len(urls) == 0 {
return false
}
return slices.ContainsFunc(urls, r.MatchString)
}
}

View File

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

View File

@@ -0,0 +1,12 @@
package msgelem
import "github.com/gotd/td/tg"
func AlertCallbackAnswer(queryID int64, text string) *tg.MessagesSetBotCallbackAnswerRequest {
return &tg.MessagesSetBotCallbackAnswerRequest{
QueryID: queryID,
Alert: true,
Message: text,
CacheTime: 5,
}
}

View File

@@ -0,0 +1,38 @@
package msgelem
import (
"fmt"
"strings"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
)
func BuildDirHelpStyling(dirs []database.Dir) []styling.StyledTextOption {
return []styling.StyledTextOption{
styling.Bold(i18n.T(i18nk.BotMsgDirHelpUsage, nil)),
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAvailableOps, nil)),
styling.Code("add"),
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAddSuffix, nil)),
styling.Code("del"),
styling.Plain(i18n.T(i18nk.BotMsgDirHelpDelSuffix, nil)),
styling.Plain(i18n.T(i18nk.BotMsgDirHelpAddExamplePrefix, nil)),
styling.Code(i18n.T(i18nk.BotMsgDirHelpAddExampleCmd, nil)),
styling.Plain(i18n.T(i18nk.BotMsgDirHelpDelExamplePrefix, nil)),
styling.Code(i18n.T(i18nk.BotMsgDirHelpDelExampleCmd, nil)),
styling.Plain(i18n.T(i18nk.BotMsgDirHelpExistingDirsPrefix, nil)),
styling.Blockquote(func() string {
var sb strings.Builder
for _, dir := range dirs {
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
sb.WriteString(dir.StorageName)
sb.WriteString(" - ")
sb.WriteString(dir.Path)
sb.WriteString("\n")
}
return sb.String()
}(), true),
}
}

View File

@@ -0,0 +1,41 @@
package msgelem
import (
"fmt"
"github.com/duke-git/lancet/v2/strutil"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/pkg/parser"
)
func BuildParsedTextEntity(item parser.Item) (string, []tg.MessageEntityClass, error) {
eb := entity.Builder{}
if err := styling.Perform(&eb,
styling.Bold(fmt.Sprintf("[%s]%s", item.Site, item.Title)),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoLinkPrefix, nil)),
styling.Code(item.URL),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoAuthorPrefix, nil)),
styling.Code(item.Author),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoDescriptionPrefix, nil)),
styling.Blockquote(strutil.Ellipsis(item.Description, 233), true),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoFileCountPrefix, nil)),
styling.Code(fmt.Sprintf("%d", len(item.Resources))),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoTotalSizePrefix, nil)),
styling.Code(fmt.Sprintf("%.2f MB", func() float64 {
var totalSize int64
for _, res := range item.Resources {
totalSize += res.Size
}
return float64(totalSize) / 1024 / 1024
}())),
styling.Plain(i18n.T(i18nk.BotMsgParseInfoPromptSelectStorage, nil)),
); err != nil {
return "", nil, fmt.Errorf("failed to build parsed text entity: %w", err)
}
text, entities := eb.Complete()
return text, entities, nil
}

View File

@@ -0,0 +1,39 @@
package msgelem
import (
"fmt"
"strings"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
)
func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledTextOption {
return []styling.StyledTextOption{
styling.Bold(i18n.T(i18nk.BotMsgRuleHelpUsage, nil)),
styling.Bold(func() string {
if enabled {
return i18n.T(i18nk.BotMsgRuleHelpCurrentModeEnabled, nil)
}
return i18n.T(i18nk.BotMsgRuleHelpCurrentModeDisabled, nil)
}()),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAvailableOps, nil)),
styling.Code("switch"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpSwitchSuffix, nil)),
styling.Code("add"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpAddSuffix, nil)),
styling.Code("del"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpDelSuffix, nil)),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpExistingRulesPrefix, nil)),
styling.Blockquote(func() string {
var sb strings.Builder
for _, rule := range rules {
ruleText := fmt.Sprintf("%s %s %s %s", rule.Type, rule.Data, rule.StorageName, rule.DirPath)
sb.WriteString(fmt.Sprintf("%d: %s\n", rule.ID, ruleText))
}
return sb.String()
}(), true),
}
}

View File

@@ -0,0 +1,203 @@
package msgelem
import (
"context"
"fmt"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) {
taskType := adddata.TaskType
if taskType == "" {
if len(adddata.Files) > 0 {
taskType = tasktype.TaskTypeTgfiles
} else if adddata.TphPageNode != nil {
taskType = tasktype.TaskTypeTphpics
} else if adddata.ParsedItem != nil {
taskType = tasktype.TaskTypeParseditem
} else {
return nil, fmt.Errorf("unknown task type: %s", taskType)
}
}
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range stors {
data := tcbdata.Add{
TaskType: taskType,
SelectedStorName: storage.Name(),
Files: adddata.Files,
AsBatch: len(adddata.Files) > 1,
TphPageNode: adddata.TphPageNode,
TphPics: adddata.TphPics,
TphDirPath: adddata.TphDirPath,
ParsedItem: adddata.ParsedItem,
DirectLinks: adddata.DirectLinks,
}
dataid := xid.New().String()
err := cache.Set(dataid, data)
if err != nil {
return nil, err
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: storage.Name(),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dataid),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func BuildAddOneSelectStorageMessage(ctx context.Context, stors []storage.Storage, file tfile.TGFileMessage, msgId int) (*tg.MessagesEditMessageRequest, error) {
eb := entity.Builder{}
var entities []tg.MessageEntityClass
text := i18n.T(i18nk.BotMsgTasksInfoAddedToQueueFull, map[string]any{
"Filename": file.Name(),
"QueueLength": 0,
})
if err := styling.Perform(&eb,
styling.Plain(i18n.T(i18nk.BotMsgStorageInfoFilenamePrefix, nil)),
styling.Code(file.Name()),
styling.Plain(i18n.T(i18nk.BotMsgStorageInfoPromptSelectStorage, nil)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
} else {
text, entities = eb.Complete()
}
markup, err := BuildAddSelectStorageKeyboard(stors, tcbdata.Add{
TaskType: tasktype.TaskTypeTgfiles,
Files: []tfile.TGFileMessage{file},
AsBatch: false,
})
if err != nil {
return nil, fmt.Errorf("failed to build storage keyboard: %w", err)
}
return &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ReplyMarkup: markup,
ID: msgId,
}, nil
}
// Builds the inline keyboard for setting default storage
func BuildSetDefaultStorageMarkup(
ctx context.Context,
stors []storage.Storage) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range stors {
data := tcbdata.SetDefaultStorage{
StorageName: storage.Name(),
}
dataid := xid.New().String()
err := cache.Set(dataid, data)
if err != nil {
return nil, err
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: storage.Name(),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeSetDefault, dataid),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func BuildSetDefaultDirMarkup(ctx context.Context,
seletedStorage string,
dirs []database.Dir) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, dir := range dirs {
dataid := xid.New().String()
data := tcbdata.SetDefaultStorage{
StorageName: seletedStorage,
DirID: dir.ID,
}
err := cache.Set(dataid, data)
if err != nil {
return nil, err
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: dir.Path,
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeSetDefault, dataid),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func BuildSetDirMarkupForAdd(dirs []database.Dir, dataid string) (*tg.ReplyInlineMarkup, error) {
data, ok := cache.Get[tcbdata.Add](dataid)
if !ok {
return nil, fmt.Errorf("failed to get data from cache: %s", dataid)
}
if data.DirID != 0 || data.SettedDir {
log.Warnf("Data already has a directory set: %d, %t", data.DirID, data.SettedDir)
return nil, fmt.Errorf("data already has a directory set")
}
buttons := make([]tg.KeyboardButtonClass, 0)
for _, dir := range dirs {
dirDataId := xid.New().String()
dirData := data
dirData.DirID = dir.ID
dirData.SettedDir = true
err := cache.Set(dirDataId, dirData)
if err != nil {
return nil, fmt.Errorf("failed to set directory data in cache: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: dir.Path,
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDataId),
})
}
dirDefaultDataId := xid.New().String()
dirDefaultData := data
dirDefaultData.DirID = 0
dirDefaultData.SettedDir = true
err := cache.Set(dirDefaultDataId, dirDefaultData)
if err != nil {
return nil, fmt.Errorf("failed to set default directory data in cache: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: i18n.T(i18nk.BotMsgDirButtonDefault, nil),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dirDefaultDataId),
})
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}

View File

@@ -0,0 +1,38 @@
package msgelem
import (
"context"
"strconv"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
)
func BuildTaskAddedEntities(
ctx context.Context,
filename string,
queueLength int,
) (string, []tg.MessageEntityClass) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
text := i18n.T(i18nk.BotMsgTasksInfoAddedToQueueFull, map[string]any{
"Filename": filename,
"QueueLength": queueLength,
})
if err := styling.Perform(&entityBuilder,
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoAddedToQueuePrefix, nil)),
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoFilenamePrefix, nil)),
styling.Code(filename),
styling.Plain(i18n.T(i18nk.BotMsgTasksInfoQueueLengthPrefix, nil)),
styling.Bold(strconv.Itoa(queueLength)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
} else {
text, entities = entityBuilder.Complete()
}
return text, entities
}

View File

@@ -0,0 +1,10 @@
package re
import "regexp"
var (
TgMessageLinkRegexString = `https?://t\.me/(?:c/\d+|[A-Za-z0-9_]+)/\d+(?:/\d+)?(?:\?[^\s#]*[A-Za-z0-9_])?\b`
TgMessageLinkRegexp = regexp.MustCompile(TgMessageLinkRegexString)
TelegraphUrlRegexString = `https://telegra.ph/.*`
TelegraphUrlRegexp = regexp.MustCompile(TelegraphUrlRegexString)
)

View File

@@ -0,0 +1,113 @@
package ruleutil
import (
"context"
"github.com/duke-git/lancet/v2/convertor"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/rule"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
type ruleInput struct {
File tfile.TGFileMessage
}
type ruleInputOption func(*ruleInput)
func NewInput(file tfile.TGFileMessage, opts ...ruleInputOption) *ruleInput {
input := &ruleInput{
File: file,
}
for _, opt := range opts {
opt(input)
}
return input
}
type matchedStorName string
func (m matchedStorName) String() string {
return string(m)
}
// can we use this storage name directly?
func (m matchedStorName) Usable() bool {
return m != "" && m != rule.RuleStorNameChosen
}
type MatchedDirPath string
func (m MatchedDirPath) String() string {
return string(m)
}
func (m MatchedDirPath) NeedNewForAlbum() bool {
return m != "" && m == rule.RuleDirPathNewForAlbum
}
func ApplyRule(ctx context.Context, rules []database.Rule, inputs *ruleInput) (matched bool, matchedStorageName matchedStorName, dirPath MatchedDirPath) {
if inputs == nil || len(rules) == 0 {
return false, "", ""
}
logger := log.FromContext(ctx)
for _, ur := range rules {
switch ur.Type {
case rule.FileNameRegex.String():
ru, err := rule.NewRuleFileNameRegex(ur.StorageName, ur.DirPath, ur.Data)
if err != nil {
logger.Errorf("Failed to create rule: %s", err)
continue
}
ok, err := ru.Match(inputs.File)
if err != nil {
logger.Errorf("Failed to match rule: %s", err)
continue
}
if ok {
dirPath = MatchedDirPath(ru.StoragePath())
matchedStorageName = matchedStorName(ru.StorageName())
}
case rule.MessageRegex.String():
ru, err := rule.NewRuleMessageRegex(ur.StorageName, ur.DirPath, ur.Data)
if err != nil {
logger.Errorf("Failed to create rule: %s", err)
continue
}
ok, err := ru.Match(inputs.File.Message().GetMessage())
if err != nil {
logger.Errorf("Failed to match rule: %s", err)
continue
}
if ok {
dirPath = MatchedDirPath(ru.StoragePath())
matchedStorageName = matchedStorName(ru.StorageName())
}
case rule.IsAlbum.String():
matchAlbum, err := convertor.ToBool(ur.Data)
if err != nil {
matchAlbum = false
}
ru, err := rule.NewRuleMediaType(ur.StorageName, ur.DirPath, matchAlbum)
if err != nil {
logger.Errorf("Failed to create rule: %s", err)
continue
}
ok, err := ru.Match(inputs.File.Message().GroupedID != 0)
if err != nil {
logger.Errorf("Failed to match rule: %s", err)
continue
}
if ok {
dirPath = MatchedDirPath(ru.StoragePath())
matchedStorageName = matchedStorName(ru.StorageName())
}
}
}
if matchedStorageName != "" || dirPath != "" {
return true, matchedStorageName, dirPath
}
return false, "", ""
}

View File

@@ -0,0 +1,34 @@
package shortcut
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/directlinks"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func CreateAndAddDirectTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, links []string, msgID int, userID int64) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := directlinks.NewTask(xid.New().String(), injectCtx, links, stor, stor.JoinStoragePath(dirPath), directlinks.NewProgress(msgID, userID))
if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,250 @@
// Some shortcuts for duplicate code in handlers, they should return dispatcher errors
package shortcut
import (
"encoding/json"
"net/url"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/types"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/downloader"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
uc "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
// 获取消息中的文件并回复等待消息, 返回等待消息, 获取到的文件
func GetFileFromMessageWithReply(ctx *ext.Context, update *ext.Update, message *tg.Message, tfileopts ...tfile.TGFileOption) (replied *types.Message,
file tfile.TGFileMessage, err error,
) {
logger := log.FromContext(ctx)
media := message.Media
supported := mediautil.IsSupported(media)
if !supported {
return nil, nil, dispatcher.ContinueGroups
}
replied, err = ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingFileInfo, nil)), nil)
if err != nil {
logger.Errorf("Failed to reply: %s", err)
return nil, nil, dispatcher.EndGroups
}
// options := []tfile.TGFileOption{
// tfile.WithMessage(message),
// }
// if len(tfileopts) > 0 {
// options = append(options, tfileopts...)
// } else {
// options = append(options, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*message)))
// }
file, err = tfile.FromMediaMessage(media, ctx.Raw, message, tfileopts...)
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetFileFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil, nil, dispatcher.EndGroups
}
return replied, file, nil
}
type EditMessageFunc func(text string, markup tg.ReplyMarkupClass)
// 获取链接中的文件并回复等待消息
func GetFilesFromUpdateLinkMessageWithReplyEdit(ctx *ext.Context, update *ext.Update) (replied *types.Message, files []tfile.TGFileMessage, editReplied EditMessageFunc, err error) {
logger := log.FromContext(ctx)
msgLinks := re.TgMessageLinkRegexp.FindAllString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message), -1)
if len(msgLinks) == 0 {
logger.Warn("no matched message links but called handleMessageLink")
return nil, nil, nil, dispatcher.EndGroups
}
replied, err = ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingMessages, nil)), nil)
if err != nil {
logger.Errorf("failed to reply: %s", err)
return nil, nil, nil, dispatcher.EndGroups
}
editReplied = func(text string, markup tg.ReplyMarkupClass) {
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: text,
ReplyMarkup: markup,
}); err != nil {
logger.Errorf("failed to edit message: %s", err)
}
}
user, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
logger.Errorf("failed to get user from db: %s", err)
editReplied(i18n.T(i18nk.BotMsgCommonErrorGetUserInfoFailed, map[string]any{
"Error": err.Error(),
}), nil)
return nil, nil, nil, dispatcher.EndGroups
}
files = make([]tfile.TGFileMessage, 0, len(msgLinks))
addFile := func(client downloader.Client, msg *tg.Message) {
if msg == nil || msg.Media == nil {
logger.Warn("message is nil, skipping")
return
}
media, ok := msg.GetMedia()
if !ok {
logger.Debugf("message %d has no media", msg.GetID())
return
}
opts := mediautil.TfileOptions(ctx, user, msg)
file, err := tfile.FromMediaMessage(media, client, msg, opts...)
if err != nil {
logger.Errorf("failed to create file from media: %s", err)
return
}
files = append(files, file)
}
tctx := ctx
if config.C().Telegram.Userbot.Enable {
if uc.GetCtx() != nil {
tctx = uc.GetCtx()
}
}
for _, link := range msgLinks {
linkUrl, err := url.Parse(link)
if err != nil {
logger.Errorf("failed to parse message link %s: %s", link, err)
continue
}
chatId, msgId, err := tgutil.ParseMessageLink(tctx, link)
if err != nil {
logger.Errorf("failed to parse message link %s: %s", link, err)
continue
}
msg, err := tgutil.GetMessageByID(tctx, chatId, msgId)
if err != nil {
logger.Error(err)
continue
}
groupID, isGroup := msg.GetGroupedID()
if isGroup && groupID != 0 && !linkUrl.Query().Has("single") {
gmsgs, err := tgutil.GetGroupedMessages(tctx, chatId, msg)
if err != nil {
logger.Errorf("failed to get grouped messages: %s", err)
} else {
for _, gmsg := range gmsgs {
addFile(tctx.Raw, gmsg)
}
}
} else {
addFile(tctx.Raw, msg)
}
}
if len(files) == 0 {
editReplied(i18n.T(i18nk.BotMsgCommonErrorNoSavableFilesFound, nil), nil)
return nil, nil, nil, dispatcher.EndGroups
}
return replied, files, editReplied, nil
}
func GetCallbackDataWithAnswer[DataType any](ctx *ext.Context, update *ext.Update, dataid string) (DataType, error) {
data, ok := cache.Get[DataType](dataid)
if !ok {
log.FromContext(ctx).Warnf("Invalid data ID: %s", dataid)
queryID := update.CallbackQuery.GetQueryID()
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgCommonErrorDataExpired, nil)))
var zero DataType
return zero, dispatcher.EndGroups
}
return data, nil
}
type TelegraphResult struct {
Pics []string `json:"pics"` // image urls
TphDir string `json:"tph_dir"` // telegraph path, unescaped
Page *telegraph.Page `json:"page"` // telegraph page node
}
// return replied message, image urls, telegraph path(unescaped), error
func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) {
logger := log.FromContext(ctx)
tphurl := re.TelegraphUrlRegexp.FindString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message))
if tphurl == "" {
logger.Warnf("No telegraph url found but called handleTelegraph")
return nil, nil, dispatcher.ContinueGroups
}
pagepath := strings.Split(tphurl, "/")[len(strings.Split(tphurl, "/"))-1]
tphdir, err := url.PathUnescape(pagepath)
if err != nil {
logger.Errorf("Failed to unescape telegraph path: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorParseTelegraphPathFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil, nil, dispatcher.EndGroups
}
tphdir = strings.TrimSpace(tphdir)
msg, err := ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonInfoFetchingTelegraphPage, nil)), nil)
if err != nil {
logger.Errorf("Failed to reply to update: %s", err)
return nil, nil, dispatcher.EndGroups
}
logger.Debugf("Fetching telegraph page: %s", pagepath)
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
if err != nil {
logger.Errorf("Failed to get telegraph page: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetTelegraphPageFailed, map[string]any{
"Error": err.Error(),
})), nil)
return nil, nil, dispatcher.EndGroups
}
imgs := make([]string, 0)
for _, elem := range page.Content {
var node telegraph.NodeElement
data, err := json.Marshal(elem)
if err != nil {
logger.Errorf("Failed to marshal element: %s", err)
continue
}
err = json.Unmarshal(data, &node)
if err != nil {
logger.Errorf("Failed to unmarshal element: %s", err)
continue
}
if len(node.Children) != 0 {
for _, child := range node.Children {
imgs = append(imgs, tphutil.GetNodeImages(child)...)
}
}
if node.Tag == "img" {
if src, ok := node.Attrs["src"]; ok {
if strings.HasPrefix(src, "/file/") {
// handle images on telegra.ph server
src = "https://telegra.ph" + src
}
imgs = append(imgs, src)
}
}
}
if len(imgs) == 0 {
logger.Warn("No images found in telegraph page")
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorNoImagesInTelegraphPage, nil)), nil)
return nil, nil, dispatcher.EndGroups
}
return msg, &TelegraphResult{
Pics: imgs,
TphDir: tphdir,
Page: page,
}, nil
}

View File

@@ -0,0 +1,39 @@
package shortcut
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
parsed "github.com/krau/SaveAny-Bot/core/tasks/parsed"
"github.com/krau/SaveAny-Bot/pkg/parser"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func CreateAndAddParsedTaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, item *parser.Item, msgID int, userID int64) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := parsed.NewTask(xid.New().String(), injectCtx, stor, stor.JoinStoragePath(dirPath), item, parsed.NewProgress(msgID, userID))
if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
text, entities := msgelem.BuildTaskAddedEntities(ctx, item.Title, core.GetLength(ctx))
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: text,
Entities: entities,
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,228 @@
package shortcut
import (
"path"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/batchtfile"
tftask "github.com/krau/SaveAny-Bot/core/tasks/tfile"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
// 创建一个 tfile.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int) error {
logger := log.FromContext(ctx)
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
logger.Errorf("Failed to get user by chat ID: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
if user.ApplyRule && user.Rules != nil {
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
goto startCreateTask
}
if matchedDirPath != "" {
dirPath = matchedDirPath.String()
}
if matchedStorageName.Usable() {
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
}
}
startCreateTask:
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task, err := tftask.NewTGFileTask(taskid, injectCtx, file, stor, storagePath,
tftask.NewProgressTrack(
trackMsgID,
userID))
if err != nil {
logger.Errorf("create task failed: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("add task failed: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
text, entities := msgelem.BuildTaskAddedEntities(ctx, file.Name(), core.GetLength(injectCtx))
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: text,
Entities: entities,
})
return dispatcher.EndGroups
}
// 创建一个 batchtfile.BatchTGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
func CreateAndAddBatchTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, files []tfile.TGFileMessage, trackMsgID int) error {
logger := log.FromContext(ctx)
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
logger.Errorf("Failed to get user by chat ID: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorGetUserWithErrFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
useRule := user.ApplyRule && user.Rules != nil
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
if !useRule {
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
storname := storName.String()
if !storName.Usable() {
storname = stor.Name()
}
return storname, dirP
}
elems := make([]batchtfile.TaskElement, 0, len(files))
type albumFile struct {
file tfile.TGFileMessage
storage storage.Storage
}
albumFiles := make(map[int64][]albumFile, 0)
for _, file := range files {
storName, dirPath := applyRule(file)
fileStor := stor
if storName != stor.Name() && storName != "" {
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
}
if !dirPath.NeedNewForAlbum() {
storPath := fileStor.JoinStoragePath(path.Join(dirPath.String(), file.Name()))
elem, err := batchtfile.NewTaskElement(fileStor, storPath, file)
if err != nil {
logger.Errorf("Failed to create task element: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
elems = append(elems, *elem)
} else {
groupId, isGroup := file.Message().GetGroupedID()
if !isGroup || groupId == 0 {
logger.Warnf("File %s is not in a group, skipping album handling", file.Name())
continue
}
if _, ok := albumFiles[groupId]; !ok {
albumFiles[groupId] = make([]albumFile, 0)
}
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
file: file,
storage: fileStor,
})
}
}
for _, afiles := range albumFiles {
if len(afiles) <= 1 {
continue
}
// 对于需要新建目录的文件, 将第一个文件的文件名(去除扩展名)作为目录名
// 存储以第一个文件的存储为准
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
albumStor := afiles[0].storage
for _, af := range afiles {
afstorPath := af.storage.JoinStoragePath(path.Join(dirPath, albumDir, af.file.Name()))
elem, err := batchtfile.NewTaskElement(albumStor, afstorPath, af.file)
if err != nil {
logger.Errorf("Failed to create task element for album file: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskCreateFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
elems = append(elems, *elem)
}
}
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTracker(trackMsgID, userID), true)
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add batch task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{
"Count": len(files),
}),
ReplyMarkup: nil,
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,56 @@
package shortcut
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/core"
tphtask "github.com/krau/SaveAny-Bot/core/tasks/telegraph"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func CreateAndAddtelegraphWithEdit(
ctx *ext.Context,
userID int64,
tphpage *telegraph.Page,
dirPath string, // unescaped ph path for file storage
pics []string,
stor storage.Storage,
trackMsgID int) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := tphtask.NewTask(xid.New().String(),
injectCtx,
tphpage.Path,
pics,
stor,
stor.JoinStoragePath(dirPath),
tphutil.DefaultClient(),
tphtask.NewProgress(trackMsgID, userID),
)
if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
text, entities := msgelem.BuildTaskAddedEntities(ctx, tphpage.Title, core.GetLength(ctx))
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: text,
Entities: entities,
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,258 @@
package handlers
import (
"fmt"
"path"
"regexp"
"strings"
"text/template"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
return dispatcher.EndGroups
}
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("Failed to get user: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), nil)
return dispatcher.EndGroups
}
if user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorDefaultStorageNotSet)), nil)
return dispatcher.EndGroups
}
chatArg := args[1]
chatID, err := tgutil.ParseChatID(ctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
watching, err := user.WatchingChat(ctx, chatID)
if err != nil {
logger.Errorf("Failed to check if user is watching chat %d: %s", chatID, err)
return dispatcher.EndGroups
}
if watching {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoAlreadyWatchingChat)), nil)
return dispatcher.EndGroups
}
filter := ""
if len(args) > 2 {
filterArg := strings.Join(args[2:], " ")
filterType := strings.Split(filterArg, ":")[0]
filterData := strings.Split(filterArg, ":")[1]
if filterType == "" || filterData == "" {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorFilterFormatInvalid)), nil)
return dispatcher.EndGroups
}
switch filterType {
case "msgre":
_, err := regexp.Compile(filterData)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidRegex, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
filter = filterType + ":" + filterData
default:
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorFilterTypeUnsupported)), nil)
return dispatcher.EndGroups
}
}
if err := user.WatchChat(ctx, database.WatchChat{
UserID: user.ID,
ChatID: chatID,
Filter: filter,
}); err != nil {
logger.Errorf("Failed to watch chat %d: %s", chatID, err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorWatchChatFailed, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchChatStarted, map[string]any{"Chat": chatArg})), nil)
return dispatcher.EndGroups
}
func handleLswatchCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("Failed to get user: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), nil)
return dispatcher.EndGroups
}
chats := user.WatchChats
if len(chats) == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchListEmpty)), nil)
return dispatcher.EndGroups
}
var sb strings.Builder
sb.WriteString(i18n.T(i18nk.BotMsgWatchInfoWatchListHeader))
for _, chat := range chats {
sb.WriteString("- ")
sb.WriteString(fmt.Sprintf("%d", chat.ChatID))
if chat.Filter != "" {
sb.WriteString(i18n.T(i18nk.BotMsgWatchInfoWatchListFilterPrefix))
sb.WriteString(chat.Filter)
sb.WriteString(")")
}
sb.WriteString("\n")
}
ctx.Reply(update, ext.ReplyTextString(sb.String()), nil)
return dispatcher.EndGroups
}
func handleUnwatchCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorUnwatchNoChatProvided)), nil)
return dispatcher.EndGroups
}
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("Failed to get user: %s", err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetUserFailed)), nil)
return dispatcher.EndGroups
}
chatArg := args[1]
chatID, err := tgutil.ParseChatID(ctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorInvalidIdOrUsername, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
if err := user.UnwatchChat(ctx, chatID); err != nil {
logger.Errorf("Failed to unwatch chat %d: %s", chatID, err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchErrorUnwatchChatFailed, map[string]any{"Error": err.Error()})), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchInfoWatchChatStopped, map[string]any{"Chat": chatArg})), nil)
return dispatcher.EndGroups
}
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
if userclient.GetCtx() == nil {
return
}
logger := log.FromContext(userclient.GetCtx())
for event := range ch {
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
ctx := event.Ctx
file := event.File
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
if err != nil {
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
continue
}
msgText := event.File.Message().GetMessage()
for _, chat := range chats {
if chat.Filter != "" {
filter := strings.Split(chat.Filter, ":")
if len(filter) != 2 {
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
continue
}
filterType := filter[0]
filterData := filter[1]
switch filterType {
case "msgre": // [TODO] enums for filter types
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
continue
} else if !ok {
continue
}
default:
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
continue
}
}
user, err := database.GetUserByID(ctx, chat.UserID)
if err != nil {
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
continue
}
if user.DefaultStorage == "" {
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
continue
}
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
if err != nil {
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
continue
}
switch user.FilenameStrategy {
case fnamest.Message.String():
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
case fnamest.Template.String():
if user.FilenameTemplate == "" {
logger.Warnf("Empty filename template for user %d, using default filename", user.ChatID)
break
}
message := file.Message()
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
if err != nil {
logger.Errorf("Failed to parse filename template for user %d: %s", user.ChatID, err)
break
}
data := mediautil.BuildFilenameTemplateData(message)
var sb strings.Builder
err = tmpl.Execute(&sb, data)
if err != nil {
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
break
}
file.SetName(sb.String())
}
var dirPath string
if user.ApplyRule && user.Rules != nil {
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
goto startCreateTask
}
dirPath = matchedDirPath.String()
if matchedStorageName.Usable() {
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
continue
}
}
}
startCreateTask:
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task, err := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
if err != nil {
logger.Errorf("create task failed: %s", err)
continue
}
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("add task failed: %s", err)
continue
}
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
}
}
}

View File

@@ -0,0 +1,30 @@
package middleware
import (
"context"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/gotd/contrib/middleware/floodwait"
"github.com/gotd/td/telegram"
"github.com/krau/SaveAny-Bot/client/middleware/recovery"
"github.com/krau/SaveAny-Bot/client/middleware/retry"
"github.com/krau/SaveAny-Bot/config"
)
// https://github.com/iyear/tdl/blob/master/core/tclient/tclient.go
func NewDefaultMiddlewares(ctx context.Context, timeout time.Duration) []telegram.Middleware {
return []telegram.Middleware{
recovery.New(ctx, newBackoff(timeout)),
retry.New(config.C().Telegram.RpcRetry),
floodwait.NewSimpleWaiter(),
}
}
func newBackoff(timeout time.Duration) backoff.BackOff {
b := backoff.NewExponentialBackOff()
b.Multiplier = 1.1
b.MaxElapsedTime = timeout
b.MaxInterval = 10 * time.Second
return b
}

View File

@@ -1,4 +1,4 @@
package bot
package middleware
import (
"time"
@@ -9,8 +9,8 @@ import (
"golang.org/x/time/rate"
)
func FloodWaitMiddleware() []telegram.Middleware {
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(5)
func NewFloodWaitMiddlewares(maxRetries uint) []telegram.Middleware {
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(maxRetries)
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
return []telegram.Middleware{
waiter,

View File

@@ -0,0 +1,61 @@
package recovery
import (
"context"
"fmt"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/charmbracelet/log"
"github.com/gotd/td/bin"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
)
type recovery struct {
ctx context.Context
backoff backoff.BackOff
}
func New(ctx context.Context, backoff backoff.BackOff) telegram.Middleware {
return &recovery{
ctx: ctx,
backoff: backoff,
}
}
func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
return backoff.RetryNotify(func() error {
if err := next.Invoke(ctx, input, output); err != nil {
if r.shouldRecover(ctx, err) {
return fmt.Errorf("recovery: %w", err)
}
return backoff.Permanent(err)
}
return nil
}, r.backoff, func(err error, duration time.Duration) {
log.FromContext(ctx).Debug("Wait for connection recovery", "error", err, "duration", duration)
})
}
}
func (r *recovery) shouldRecover(ctx context.Context, err error) bool {
// context in recovery is used to stop recovery process by external os signal, otherwise we will wait till max retries when user press ctrl+c
select {
case <-r.ctx.Done():
return false
case <-ctx.Done():
return false
default:
}
// we try recover when encountered any error that is not telegram business error
_, ok := tgerr.As(err)
return !ok
}

View File

@@ -0,0 +1,56 @@
package retry
import (
"context"
"fmt"
"github.com/charmbracelet/log"
"github.com/gotd/td/bin"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
)
var internalErrors = []string{
"Timedout", // #373
"No workers running",
"RPC_CALL_FAIL",
"RPC_MCGET_FAIL",
"WORKER_BUSY_TOO_LONG_RETRY", // #462
"memory limit exit", // #504
}
type retry struct {
max int
errors []string
}
func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
retries := 0
for retries < r.max {
if err := next.Invoke(ctx, input, output); err != nil {
if tgerr.Is(err, r.errors...) {
log.FromContext(ctx).Debug("retry middleware", "retries", retries, "error", err)
retries++
continue
}
// retry middleware skip
return err
}
return nil
}
return fmt.Errorf("retry limit reached after %d attempts", r.max)
}
}
// New returns middleware that retries request if it fails with one of provided errors.
func New(max int, errors ...string) telegram.Middleware {
return retry{
max: max,
errors: append(errors, internalErrors...), // #373
}
}

View File

@@ -0,0 +1,57 @@
package user
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/celestix/gotgproto"
"golang.org/x/term"
)
type terminalAuthConversator struct{}
func readLine(prompt string) (string, error) {
fmt.Print(prompt)
reader := bufio.NewReader(os.Stdin)
text, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(text), nil
}
func (t *terminalAuthConversator) AskPhoneNumber() (string, error) {
fmt.Println("Your Phone Number (e.g. +44 123456):")
return readLine("> ")
}
func (t *terminalAuthConversator) AskCode() (string, error) {
fmt.Println("Your Code (e.g. 123456):")
return readLine("> ")
}
func (t *terminalAuthConversator) AskPassword() (string, error) {
fmt.Println("Your 2FA Password:")
fmt.Print("> ")
bytePwd, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Println()
if err != nil {
return "", err
}
return strings.TrimSpace(string(bytePwd)), nil
}
func (t *terminalAuthConversator) AuthStatus(authStatus gotgproto.AuthStatus) {
switch authStatus.Event {
case gotgproto.AuthStatusPhoneRetrial:
fmt.Printf("The phone number is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
case gotgproto.AuthStatusPasswordRetrial:
fmt.Printf("The 2FA password is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
case gotgproto.AuthStatusPhoneCodeRetrial:
fmt.Printf("The OTP code is incorrect. Attempts left: %d\n", authStatus.AttemptsLeft)
default:
}
}

112
client/user/userclient.go Normal file
View File

@@ -0,0 +1,112 @@
package user
import (
"context"
"time"
"github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/dispatcher/handlers"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/sessionMaker"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
)
var uc *gotgproto.Client
var ectx *ext.Context
func GetCtx() *ext.Context {
if ectx != nil {
return ectx
}
if uc == nil {
return nil
}
ectx = uc.CreateContext()
return ectx
}
func Login(ctx context.Context) (*gotgproto.Client, error) {
log.FromContext(ctx).Debug("Logging in user client")
if uc != nil {
return uc, nil
}
res := make(chan struct {
client *gotgproto.Client
err error
})
go func() {
resolver, err := tgutil.NewConfigProxyResolver()
if err != nil {
res <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
tclient, err := gotgproto.NewClient(
config.C().Telegram.AppID,
config.C().Telegram.AppHash,
gotgproto.ClientTypePhone(""),
&gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(database.GetDialect(config.C().Telegram.Userbot.Session)),
AuthConversator: &terminalAuthConversator{},
Context: ctx,
DisableCopyright: true,
Resolver: resolver,
MaxRetries: config.C().Telegram.RpcRetry,
AutoFetchReply: true,
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
log.FromContext(ctx).Errorf("Unhandled error: %s", s)
return dispatcher.EndGroups
},
},
)
if err != nil {
res <- struct {
client *gotgproto.Client
err error
}{nil, err}
}
res <- struct {
client *gotgproto.Client
err error
}(struct {
client *gotgproto.Client
err error
}{tclient, nil})
}()
select {
case <-ctx.Done():
return nil, ctx.Err()
case r := <-res:
if r.err != nil {
return nil, r.err
}
uc = r.client
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, func(ctx *ext.Context, u *ext.Update) error {
switch u.UpdateClass.(type) {
case *tg.UpdateEditChannelMessage, *tg.UpdateEditMessage, *tg.UpdateDeleteChannelMessages, *tg.UpdateDeleteMessages:
return dispatcher.EndGroups
}
chatId := u.EffectiveChat().GetID()
watchChats, err := database.GetWatchChatsByChatID(ctx, chatId)
if err != nil || len(watchChats) == 0 {
return dispatcher.EndGroups
}
return dispatcher.ContinueGroups
}))
uc.Dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleMediaMessage))
log.FromContext(ctx).Infof("User client logged in successfully: %s", uc.Self.FirstName+" "+uc.Self.LastName)
return uc, nil
}
}

100
client/user/watch.go Normal file
View File

@@ -0,0 +1,100 @@
package user
import (
"sync"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
type MediaMessageEvent struct {
Ctx *ext.Context
ChatID int64 // from witch the media message was sent
MessageID int
File tfile.TGFileMessage
}
type messageKey struct {
ChatID int64
MessageID int
}
type MediaMessageHandler struct {
events map[messageKey]MediaMessageEvent
timers map[messageKey]*time.Timer
mu sync.Mutex
debounce time.Duration
}
var (
mediaMessageCh = make(chan MediaMessageEvent, 100)
mediaMessageHandler = &MediaMessageHandler{
events: make(map[messageKey]MediaMessageEvent),
timers: make(map[messageKey]*time.Timer),
debounce: 5 * time.Second,
}
)
func GetMediaMessageCh() chan MediaMessageEvent {
return mediaMessageCh
}
func sendMediaMessageEvent(event MediaMessageEvent) {
key := messageKey{ChatID: event.ChatID, MessageID: event.MessageID}
mediaMessageHandler.mu.Lock()
defer mediaMessageHandler.mu.Unlock()
if timer, exists := mediaMessageHandler.timers[key]; exists {
timer.Stop()
} else {
mediaMessageHandler.events[key] = event
}
mediaMessageHandler.timers[key] = time.AfterFunc(mediaMessageHandler.debounce, func() {
mediaMessageHandler.mu.Lock()
event := mediaMessageHandler.events[key]
delete(mediaMessageHandler.events, key)
delete(mediaMessageHandler.timers, key)
mediaMessageHandler.mu.Unlock()
mediaMessageCh <- event
})
}
func handleMediaMessage(ctx *ext.Context, update *ext.Update) error {
message := update.EffectiveMessage
media, ok := message.GetMedia()
if !ok || media == nil {
return dispatcher.EndGroups
}
support := func() bool {
switch media.(type) {
case *tg.MessageMediaDocument, *tg.MessageMediaPhoto:
return true
default:
return false
}
}()
if !support {
return dispatcher.EndGroups
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, message.Message, tfile.WithNameIfEmpty(
tgutil.GenFileNameFromMessage(*message.Message),
))
if err != nil {
return err
}
chatId := update.EffectiveChat().GetID()
sendMediaMessageEvent(MediaMessageEvent{
Ctx: ctx,
ChatID: chatId,
MessageID: message.ID,
File: file,
})
return dispatcher.EndGroups
}

115
cmd/geni18n/main.go Normal file
View File

@@ -0,0 +1,115 @@
// cmd/geni18n/main.go
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"go/format"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/goccy/go-yaml"
)
func main() {
dir := flag.String("dir", "./common/i18n/locale", "Locales directory path")
out := flag.String("out", "common/i18n/i18nk/keys.go", "Output file path")
pkg := flag.String("pkg", "i18nk", "Package name for generated file")
flag.Parse()
keys := make(map[string]struct{})
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !(strings.HasSuffix(d.Name(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
var content map[string]any
if err := yaml.Unmarshal(data, &content); err != nil {
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
}
collectKeys(content, "", keys)
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
os.Exit(1)
}
var list []string
for k := range keys {
list = append(list, k)
}
sort.Strings(list)
// Generate code to buffer
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
fmt.Fprintf(w, "// Code generated by cmd/geni18n. DO NOT EDIT.\n")
fmt.Fprintf(w, "package %s\n\n", *pkg)
fmt.Fprintf(w, "type Key string\n\n")
fmt.Fprintf(w, "const (\n")
for _, key := range list {
name := toPascal(key)
fmt.Fprintf(w, "\t%s Key = %q\n", name, key)
}
fmt.Fprintf(w, ")\n")
w.Flush()
// Format the generated code
formatted, err := format.Source(buf.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to format generated code: %v\n", err)
formatted = buf.Bytes()
}
// Write to output file
if err := os.WriteFile(*out, formatted, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing output file: %v\n", err)
os.Exit(1)
}
}
func collectKeys(node map[string]any, prefix string, keys map[string]struct{}) {
for k, v := range node {
fullKey := k
if prefix != "" {
fullKey = prefix + "." + k
}
switch val := v.(type) {
case map[string]any:
collectKeys(val, fullKey, keys)
default:
keys[fullKey] = struct{}{}
}
}
}
// 转 PascalCase
func toPascal(key string) string {
parts := strings.Split(key, ".")
for i, p := range parts {
subs := strings.Split(p, "_")
for j, s := range subs {
if len(s) > 0 {
subs[j] = strings.ToUpper(s[:1]) + s[1:]
}
}
parts[i] = strings.Join(subs, "")
}
return strings.Join(parts, "")
}

View File

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

View File

@@ -1,22 +1,107 @@
package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"path/filepath"
"time"
"github.com/krau/SaveAny-Bot/bootstrap"
"slices"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/parsers"
"github.com/krau/SaveAny-Bot/storage"
"github.com/spf13/cobra"
)
func Run(_ *cobra.Command, _ []string) {
bootstrap.InitAll()
core.Run()
func Run(cmd *cobra.Command, _ []string) {
ctx, cancel := context.WithCancel(cmd.Context())
logger := log.NewWithOptions(os.Stdout, log.Options{
Level: log.DebugLevel,
ReportTimestamp: true,
TimeFormat: time.TimeOnly,
ReportCaller: true,
})
ctx = log.WithContext(ctx, logger)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
logger.L.Info(sig, "exit")
exitChan, err := initAll(ctx, cmd)
if err != nil {
logger.Fatal("Init failed", "error", err)
}
go func() {
<-exitChan
cancel()
}()
core.Run(ctx)
<-ctx.Done()
logger.Info("Exiting...")
defer logger.Info("Exit complete")
cleanCache()
}
func initAll(ctx context.Context, cmd *cobra.Command) (<-chan struct{}, error) {
configFile := config.GetConfigFile(cmd)
if err := config.Init(ctx, configFile); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
cache.Init()
logger := log.FromContext(ctx)
i18n.Init(config.C().Lang)
logger.Info("Initializing...")
database.Init(ctx)
storage.LoadStorages(ctx)
if config.C().Parser.PluginEnable {
for _, dir := range config.C().Parser.PluginDirs {
if err := parsers.LoadPlugins(ctx, dir); err != nil {
logger.Error("Failed to load parser plugins", "dir", dir, "error", err)
} else {
logger.Debug("Loaded parser plugins from directory", "dir", dir)
}
}
}
if config.C().Telegram.Userbot.Enable {
_, err := userclient.Login(ctx)
if err != nil {
logger.Fatal("User login failed", "error", err)
}
}
return bot.Init(ctx), nil
}
func cleanCache() {
if config.C().NoCleanCache {
return
}
if config.C().Temp.BasePath != "" && !config.C().Stream {
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
log.Error("Invalid cache directory", "path", config.C().Temp.BasePath)
return
}
currentDir, err := os.Getwd()
if err != nil {
log.Error("Failed to get working directory", "error", err)
return
}
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
cachePath, err = filepath.Abs(cachePath)
if err != nil {
log.Error("Failed to get absolute cache path", "error", err)
return
}
log.Info("Cleaning cache directory", "path", cachePath)
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
log.Error("Failed to clean cache directory", "error", err)
}
}
}

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

@@ -0,0 +1,128 @@
package upload
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
stortype "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/krau/SaveAny-Bot/storage"
"github.com/spf13/cobra"
)
var uploadCmd = &cobra.Command{
Use: "upload",
Short: "upload local files to storage",
RunE: Upload,
}
func Register(root *cobra.Command) {
uploadCmd.Flags().StringP("file", "f", "", "file path to upload")
uploadCmd.MarkFlagRequired("file")
uploadCmd.Flags().StringP("storage", "s", "", "storage name to upload to")
uploadCmd.MarkFlagRequired("storage")
uploadCmd.Flags().StringP("dir", "d", "", "storage dir to upload to, default is the base_path of the storage")
uploadCmd.Flags().Bool("no-progress", false, "disable progress bar")
root.AddCommand(uploadCmd)
}
func Upload(cmd *cobra.Command, args []string) error {
storname, err := cmd.Flags().GetString("storage")
if err != nil {
return err
}
fp, err := cmd.Flags().GetString("file")
if err != nil {
return err
}
dirPath, err := cmd.Flags().GetString("dir")
if err != nil {
return err
}
noProgress, err := cmd.Flags().GetBool("no-progress")
if err != nil {
return err
}
ctx := cmd.Context()
log := log.FromContext(ctx)
configFile := config.GetConfigFile(cmd)
if err := config.Init(ctx, configFile); err != nil {
return fmt.Errorf("failed to load config: %w", err)
}
cache.Init()
database.Init(ctx)
stor, err := storage.GetStorageByName(ctx, storname)
if err != nil {
log.Fatal("Failed to get storage", "error", err)
}
switch stor.Type() {
case stortype.Telegram:
bot.Init(ctx)
default:
// placeholder for other storage types that may need special initialization
}
file, err := os.Open(filepath.Clean(fp))
if err != nil {
log.Fatal("Failed to open file", "error", err)
}
defer file.Close()
fileInfo, err := file.Stat()
if err != nil {
log.Fatal("Failed to get file info", "error", err)
}
fileName := fileInfo.Name()
fileSize := fileInfo.Size()
uploadPath := stor.JoinStoragePath(path.Join(dirPath, fileName))
ctx = context.WithValue(ctx, ctxkey.ContentLength, fileSize)
ctx = tgutil.ExtWithContext(ctx, bot.ExtContext())
// Create progress reader and UI
var reader io.Reader
var progressUI *UploadProgress
log.Info("Uploading file...", "file", fp, "to", storname, "as", uploadPath)
if !noProgress && fileSize > 0 {
progressUI = NewUploadProgress(ctx, fileName, fileSize)
progressUI.Start()
reader = ioutil.NewProgressReader(file, fileSize, func(read int64, total int64) {
if total > 0 {
progressUI.UpdateProgress(float64(read) / float64(total))
}
})
} else {
reader = file
}
if err := stor.Save(ctx, reader, uploadPath); err != nil {
if progressUI != nil {
progressUI.SetError(err)
progressUI.Wait()
}
log.Fatal("Failed to upload file", "error", err)
}
if progressUI != nil {
progressUI.Done()
progressUI.Wait()
}
log.Info("File uploaded successfully")
return nil
}

View File

@@ -0,0 +1,35 @@
//go:build no_bubbletea
package upload
import "context"
type uploadModel struct {
}
// UploadProgress manages the progress UI for uploads
type UploadProgress struct {
}
// NewUploadProgress creates a new upload progress tracker
func NewUploadProgress(ctx context.Context, fileName string, fileSize int64) *UploadProgress {
return &UploadProgress{}
}
// Start starts the progress UI in a goroutine and returns immediately
func (up *UploadProgress) Start() {}
// UpdateProgress updates the progress bar with a new percentage (0.0 - 1.0)
func (up *UploadProgress) UpdateProgress(percent float64) {}
// SetError sets an error and quits the progress UI
func (up *UploadProgress) SetError(err error) {}
// Done signals that the upload is complete
func (up *UploadProgress) Done() {}
// Wait waits for the progress UI to finish
func (up *UploadProgress) Wait() {}
// Quit quits the progress UI
func (up *UploadProgress) Quit() {}

178
cmd/upload/progress_tea.go Normal file
View File

@@ -0,0 +1,178 @@
//go:build !no_bubbletea
package upload
import (
"context"
"fmt"
"strings"
"github.com/charmbracelet/bubbles/progress"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize"
)
var (
helpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
)
// progressMsg is sent to update the progress bar
type progressMsg float64
// progressErrMsg is sent when an error occurs
type progressErrMsg struct{ err error }
// progressDoneMsg is sent when the upload is complete
type progressDoneMsg struct{}
// uploadModel is the bubbletea model for the upload progress UI
type uploadModel struct {
progress progress.Model
fileName string
fileSize int64
bytesRead int64
err error
done bool
quitting bool
width int
}
func newUploadModel(fileName string, fileSize int64) uploadModel {
p := progress.New(
progress.WithDefaultGradient(),
progress.WithWidth(50),
)
return uploadModel{
progress: p,
fileName: fileName,
fileSize: fileSize,
}
}
func (m uploadModel) Init() tea.Cmd {
return nil
}
func (m uploadModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.progress.Width = min(msg.Width-10, 80)
return m, nil
case progressMsg:
var cmds []tea.Cmd
percent := float64(msg)
m.bytesRead = int64(percent * float64(m.fileSize))
cmds = append(cmds, m.progress.SetPercent(percent))
return m, tea.Batch(cmds...)
case progressErrMsg:
m.err = msg.err
return m, tea.Quit
case progressDoneMsg:
m.done = true
m.progress.SetPercent(1.0)
return m, tea.Quit
case progress.FrameMsg:
// Don't process frame messages if we're done or quitting
if m.done || m.quitting {
return m, nil
}
progressModel, cmd := m.progress.Update(msg)
m.progress = progressModel.(progress.Model)
return m, cmd
}
return m, nil
}
func (m uploadModel) View() string {
if m.err != nil {
return fmt.Sprintf("\n ❌ Error: %s\n\n", m.err.Error())
}
var sb strings.Builder
sb.WriteString("\n")
// File info
sb.WriteString(fmt.Sprintf(" 📁 %s\n", m.fileName))
sb.WriteString(fmt.Sprintf(" 📊 %s / %s\n\n",
humanize.Bytes(uint64(m.bytesRead)),
humanize.Bytes(uint64(m.fileSize)),
))
// Progress bar
sb.WriteString(" ")
sb.WriteString(m.progress.View())
sb.WriteString("\n\n")
if m.done {
sb.WriteString(" √ Upload complete!\n\n")
} else {
sb.WriteString(helpStyle.Render(" Press Ctrl+C to cancel"))
sb.WriteString("\n\n")
}
return sb.String()
}
// UploadProgress manages the progress UI for uploads
type UploadProgress struct {
program *tea.Program
ctx context.Context
cancel context.CancelFunc
}
// NewUploadProgress creates a new upload progress tracker
func NewUploadProgress(ctx context.Context, fileName string, fileSize int64) *UploadProgress {
model := newUploadModel(fileName, fileSize)
ctx, cancel := context.WithCancel(ctx)
p := tea.NewProgram(
model,
tea.WithoutSignalHandler(),
tea.WithContext(ctx),
tea.WithInput(nil), // Disable keyboard input, rely on context cancellation
)
return &UploadProgress{
program: p,
ctx: ctx,
cancel: cancel,
}
}
// Start starts the progress UI in a goroutine and returns immediately
func (up *UploadProgress) Start() {
go func() {
up.program.Run()
}()
}
// UpdateProgress updates the progress bar with a new percentage (0.0 - 1.0)
func (up *UploadProgress) UpdateProgress(percent float64) {
up.program.Send(progressMsg(percent))
}
// SetError sets an error and quits the progress UI
func (up *UploadProgress) SetError(err error) {
up.program.Send(progressErrMsg{err: err})
}
// Done signals that the upload is complete
func (up *UploadProgress) Done() {
up.program.Send(progressDoneMsg{})
}
// Wait waits for the progress UI to finish
func (up *UploadProgress) Wait() {
up.program.Wait()
}
// Quit quits the progress UI
func (up *UploadProgress) Quit() {
up.program.Quit()
}

View File

@@ -4,8 +4,8 @@ import (
"fmt"
"runtime"
"github.com/krau/SaveAny-Bot/common"
"github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/krau/SaveAny-Bot/config"
"github.com/unvgo/ghselfupdate"
"github.com/blang/semver"
"github.com/spf13/cobra"
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
Aliases: []string{"v"},
Short: "Print the version number of saveany-bot",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", common.Version, runtime.GOOS, runtime.GOARCH, common.BuildTime, common.GitCommit)
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", config.Version, runtime.GOOS, runtime.GOARCH, config.BuildTime, config.GitCommit)
},
}
@@ -25,18 +25,33 @@ var upgradeCmd = &cobra.Command{
Aliases: []string{"up"},
Short: "Upgrade saveany-bot to the latest version",
Run: func(cmd *cobra.Command, args []string) {
v := semver.MustParse(common.Version)
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
v := semver.MustParse(config.Version)
latest, found, err := ghselfupdate.DetectLatest(config.GitRepo)
if err != nil {
fmt.Println("Binary update failed:", err)
fmt.Println("Error occurred while detecting latest version:", err)
return
}
if latest.Version.Equals(v) {
fmt.Println("Current binary is the latest version", common.Version)
} else {
fmt.Println("Successfully updated to version", latest.Version)
fmt.Println("Release note:\n", latest.ReleaseNotes)
if !found {
fmt.Println("No releases found")
return
}
if latest.Version.Major != v.Major {
fmt.Printf("Major version upgrade detected: %s -> %s. Please manually download the latest version and check the migration guide.\n", v, latest.Version)
return
}
if latest.Version.Equals(v) || latest.Version.LT(v) {
fmt.Println("Current binary is the latest version", config.Version)
return
}
fmt.Printf("Updating to version %s...\n", latest.Version)
latest, err = ghselfupdate.UpdateSelf(v, config.GitRepo)
if err != nil {
fmt.Println("Update failed:", err)
return
}
fmt.Println("Successfully updated to version", latest.Version)
fmt.Println("Release note:\n", latest.ReleaseNotes)
},
}

View File

@@ -1,59 +0,0 @@
package common
import (
"bytes"
"encoding/gob"
"sync"
"github.com/coocood/freecache"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/types"
)
type CommonCache struct {
cache *freecache.Cache
mu sync.RWMutex
}
var Cache *CommonCache
func initCache() {
gob.Register(types.File{})
gob.Register(tg.InputDocumentFileLocation{})
Cache = &CommonCache{cache: freecache.NewCache(10 * 1024 * 1024)}
}
func (c *CommonCache) Get(key string, value *types.File) error {
c.mu.RLock()
defer c.mu.RUnlock()
data, err := Cache.cache.Get([]byte(key))
if err != nil {
return err
}
dec := gob.NewDecoder(bytes.NewReader(data))
err = dec.Decode(&value)
if err != nil {
return err
}
return nil
}
func (c *CommonCache) Set(key string, value *types.File, expireSeconds int) error {
c.mu.Lock()
defer c.mu.Unlock()
var buf bytes.Buffer
enc := gob.NewEncoder(&buf)
err := enc.Encode(value)
if err != nil {
return err
}
Cache.cache.Set([]byte(key), buf.Bytes(), expireSeconds)
return nil
}
func (c *CommonCache) Delete(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
Cache.cache.Del([]byte(key))
return nil
}

53
common/cache/ristretto.go vendored Normal file
View File

@@ -0,0 +1,53 @@
package cache
import (
"fmt"
"time"
"github.com/charmbracelet/log"
"github.com/dgraph-io/ristretto/v2"
"github.com/krau/SaveAny-Bot/config"
)
var cache *ristretto.Cache[string, any]
func Init() {
if cache != nil {
panic("cache already initialized")
}
c, err := ristretto.NewCache(&ristretto.Config[string, any]{
NumCounters: config.C().Cache.NumCounters,
MaxCost: config.C().Cache.MaxCost,
BufferItems: 64,
OnReject: func(item *ristretto.Item[any]) {
log.Warnf("Cache item rejected: key=%d, value=%v", item.Key, item.Value)
},
})
if err != nil {
log.Fatalf("failed to create ristretto cache: %v", err)
}
cache = c
}
func Set(key string, value any) error {
ok := cache.SetWithTTL(key, value, 0, time.Duration(config.C().Cache.TTL)*time.Second)
if !ok {
return fmt.Errorf("failed to set value in cache")
}
cache.Wait()
return nil
}
func Get[T any](key string) (T, bool) {
v, ok := cache.Get(key)
if !ok {
var zero T
return zero, false
}
vT, ok := v.(T)
if !ok {
var zero T
return zero, false
}
return vT, true
}

View File

@@ -1,5 +0,0 @@
package common
func Init() {
initCache()
}

111
common/i18n/i18n.go Normal file
View File

@@ -0,0 +1,111 @@
// [TODO] complete the i18n support
package i18n
import (
"embed"
"maps"
"github.com/goccy/go-yaml"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/text/language"
)
//go:embed locale/*
var localesFS embed.FS
var (
bundle *i18n.Bundle
localizer *i18n.Localizer
)
func Init(lang string) {
bundle = i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale")
if err != nil {
panic("failed to read locale directory: " + err.Error())
}
for _, file := range files {
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
panic("failed to load message file: " + err.Error())
}
}
if lang == "" {
lang = "zh-Hans"
}
localizer = i18n.NewLocalizer(bundle, lang)
if localizer == nil {
panic("failed to create localizer, check your config for valid language setting")
}
}
func T(key i18nk.Key, templateData ...map[string]any) string {
if localizer == nil || bundle == nil {
panic("localizer or bundle is not initialized, call Init() first")
}
templateDataMap := make(map[string]any)
for _, data := range templateData {
maps.Copy(templateDataMap, data)
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: string(key),
TemplateData: templateDataMap,
})
if err != nil {
return string(key)
}
return msg
}
func TWithLang(lang, key string, templateData ...map[string]any) string {
if bundle == nil {
panic("bundle is not initialized, call Init() first")
}
templateDataMap := make(map[string]any)
for _, data := range templateData {
maps.Copy(templateDataMap, data)
}
localizerWithLang := i18n.NewLocalizer(bundle, lang)
msg, err := localizerWithLang.Localize(&i18n.LocalizeConfig{
MessageID: key,
TemplateData: templateDataMap,
})
if err != nil {
return key
}
return msg
}
// Only use in tests or packages that load before i18n
func TWithoutInit(lang string, key i18nk.Key, templateData ...map[string]any) string {
bundle := i18n.NewBundle(language.SimplifiedChinese)
bundle.RegisterUnmarshalFunc("yaml", yaml.Unmarshal)
files, err := localesFS.ReadDir("locale")
if err != nil {
return string(key)
}
for _, file := range files {
if _, err := bundle.LoadMessageFileFS(localesFS, "locale/"+file.Name()); err != nil {
return string(key)
}
}
localizer := i18n.NewLocalizer(bundle, lang)
if localizer == nil {
return string(key)
}
templateDataMap := make(map[string]any)
for _, data := range templateData {
maps.Copy(templateDataMap, data)
}
msg, err := localizer.Localize(&i18n.LocalizeConfig{
MessageID: string(key),
TemplateData: templateDataMap,
})
if err != nil {
return string(key)
}
return msg
}

234
common/i18n/i18nk/keys.go Normal file
View File

@@ -0,0 +1,234 @@
// Code generated by cmd/geni18n. DO NOT EDIT.
package i18nk
type Key string
const (
BotMsgCancelErrorCancelFailed Key = "bot.msg.cancel.error_cancel_failed"
BotMsgCancelInfoCancelRequested Key = "bot.msg.cancel.info_cancel_requested"
BotMsgCancelInfoCancellingTask Key = "bot.msg.cancel.info_cancelling_task"
BotMsgCancelUsage Key = "bot.msg.cancel.usage"
BotMsgCmdCancel Key = "bot.msg.cmd.cancel"
BotMsgCmdConfig Key = "bot.msg.cmd.config"
BotMsgCmdDir Key = "bot.msg.cmd.dir"
BotMsgCmdDl Key = "bot.msg.cmd.dl"
BotMsgCmdFnametmpl Key = "bot.msg.cmd.fnametmpl"
BotMsgCmdHelp Key = "bot.msg.cmd.help"
BotMsgCmdLswatch Key = "bot.msg.cmd.lswatch"
BotMsgCmdParser Key = "bot.msg.cmd.parser"
BotMsgCmdRule Key = "bot.msg.cmd.rule"
BotMsgCmdSave Key = "bot.msg.cmd.save"
BotMsgCmdSilent Key = "bot.msg.cmd.silent"
BotMsgCmdStart Key = "bot.msg.cmd.start"
BotMsgCmdStorage Key = "bot.msg.cmd.storage"
BotMsgCmdTask Key = "bot.msg.cmd.task"
BotMsgCmdUnwatch Key = "bot.msg.cmd.unwatch"
BotMsgCmdUpdate Key = "bot.msg.cmd.update"
BotMsgCmdWatch Key = "bot.msg.cmd.watch"
BotMsgCommonCancelButtonText Key = "bot.msg.common.cancel_button_text"
BotMsgCommonErrorBuildDirSelectKeyboardFailed Key = "bot.msg.common.error_build_dir_select_keyboard_failed"
BotMsgCommonErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.common.error_build_storage_select_keyboard_failed"
BotMsgCommonErrorBuildStorageSelectMessageFailed Key = "bot.msg.common.error_build_storage_select_message_failed"
BotMsgCommonErrorDataExpired Key = "bot.msg.common.error_data_expired"
BotMsgCommonErrorDefaultStorageNotSet Key = "bot.msg.common.error_default_storage_not_set"
BotMsgCommonErrorGetDirFailed Key = "bot.msg.common.error_get_dir_failed"
BotMsgCommonErrorGetFileFailed Key = "bot.msg.common.error_get_file_failed"
BotMsgCommonErrorGetMessagesFailed Key = "bot.msg.common.error_get_messages_failed"
BotMsgCommonErrorGetStorageFailed Key = "bot.msg.common.error_get_storage_failed"
BotMsgCommonErrorGetTelegraphPageFailed Key = "bot.msg.common.error_get_telegraph_page_failed"
BotMsgCommonErrorGetUserFailed Key = "bot.msg.common.error_get_user_failed"
BotMsgCommonErrorGetUserInfoFailed Key = "bot.msg.common.error_get_user_info_failed"
BotMsgCommonErrorGetUserWithErrFailed Key = "bot.msg.common.error_get_user_with_err_failed"
BotMsgCommonErrorInvalidIdOrUsername Key = "bot.msg.common.error_invalid_id_or_username"
BotMsgCommonErrorInvalidMsgIdRange Key = "bot.msg.common.error_invalid_msg_id_range"
BotMsgCommonErrorInvalidRegex Key = "bot.msg.common.error_invalid_regex"
BotMsgCommonErrorNoAvailableStorage Key = "bot.msg.common.error_no_available_storage"
BotMsgCommonErrorNoImagesInTelegraphPage Key = "bot.msg.common.error_no_images_in_telegraph_page"
BotMsgCommonErrorNoMessagesInRange Key = "bot.msg.common.error_no_messages_in_range"
BotMsgCommonErrorNoPermission Key = "bot.msg.common.error_no_permission"
BotMsgCommonErrorNoSavableFilesFound Key = "bot.msg.common.error_no_savable_files_found"
BotMsgCommonErrorNoSavableMessagesInRange Key = "bot.msg.common.error_no_savable_messages_in_range"
BotMsgCommonErrorParseTelegraphPathFailed Key = "bot.msg.common.error_parse_telegraph_path_failed"
BotMsgCommonErrorTaskAddFailed Key = "bot.msg.common.error_task_add_failed"
BotMsgCommonErrorTaskCreateFailed Key = "bot.msg.common.error_task_create_failed"
BotMsgCommonErrorUpdateUserInfoFailed Key = "bot.msg.common.error_update_user_info_failed"
BotMsgCommonInfoBatchTasksAdded Key = "bot.msg.common.info_batch_tasks_added"
BotMsgCommonInfoDefaultStorageSet Key = "bot.msg.common.info_default_storage_set"
BotMsgCommonInfoDefaultStorageWithDirSet Key = "bot.msg.common.info_default_storage_with_dir_set"
BotMsgCommonInfoFetchingFileInfo Key = "bot.msg.common.info_fetching_file_info"
BotMsgCommonInfoFetchingMessages Key = "bot.msg.common.info_fetching_messages"
BotMsgCommonInfoFetchingTelegraphPage Key = "bot.msg.common.info_fetching_telegraph_page"
BotMsgCommonInfoFoundFilesSelectStorage Key = "bot.msg.common.info_found_files_select_storage"
BotMsgCommonInfoSilentModeOff Key = "bot.msg.common.info_silent_mode_off"
BotMsgCommonInfoSilentModeOn Key = "bot.msg.common.info_silent_mode_on"
BotMsgCommonInfoTaskAdded Key = "bot.msg.common.info_task_added"
BotMsgCommonPromptSelectDefaultDir Key = "bot.msg.common.prompt_select_default_dir"
BotMsgCommonPromptSelectDefaultStorage Key = "bot.msg.common.prompt_select_default_storage"
BotMsgCommonPromptSelectDir Key = "bot.msg.common.prompt_select_dir"
BotMsgConfigButtonFilenameStrategy Key = "bot.msg.config.button_filename_strategy"
BotMsgConfigErrorInvalidCallbackData Key = "bot.msg.config.error_invalid_callback_data"
BotMsgConfigErrorInvalidTemplate Key = "bot.msg.config.error_invalid_template"
BotMsgConfigFnametmplHelp Key = "bot.msg.config.fnametmpl_help"
BotMsgConfigInfoCurrentTemplatePrefix Key = "bot.msg.config.info_current_template_prefix"
BotMsgConfigInfoFilenameStrategySet Key = "bot.msg.config.info_filename_strategy_set"
BotMsgConfigInfoTemplateUpdated Key = "bot.msg.config.info_template_updated"
BotMsgConfigPromptSelectFilenameStrategy Key = "bot.msg.config.prompt_select_filename_strategy"
BotMsgConfigPromptSelectOption Key = "bot.msg.config.prompt_select_option"
BotMsgDirButtonDefault Key = "bot.msg.dir.button_default"
BotMsgDirErrorCreateDirFailed Key = "bot.msg.dir.error_create_dir_failed"
BotMsgDirErrorDeleteDirFailed Key = "bot.msg.dir.error_delete_dir_failed"
BotMsgDirErrorGetUserDirsFailed Key = "bot.msg.dir.error_get_user_dirs_failed"
BotMsgDirErrorGetUserFailed Key = "bot.msg.dir.error_get_user_failed"
BotMsgDirErrorInvalidDirId Key = "bot.msg.dir.error_invalid_dir_id"
BotMsgDirErrorUnknownOperation Key = "bot.msg.dir.error_unknown_operation"
BotMsgDirHelpAddExampleCmd Key = "bot.msg.dir.help_add_example_cmd"
BotMsgDirHelpAddExamplePrefix Key = "bot.msg.dir.help_add_example_prefix"
BotMsgDirHelpAddSuffix Key = "bot.msg.dir.help_add_suffix"
BotMsgDirHelpAvailableOps Key = "bot.msg.dir.help_available_ops"
BotMsgDirHelpDelExampleCmd Key = "bot.msg.dir.help_del_example_cmd"
BotMsgDirHelpDelExamplePrefix Key = "bot.msg.dir.help_del_example_prefix"
BotMsgDirHelpDelSuffix Key = "bot.msg.dir.help_del_suffix"
BotMsgDirHelpExistingDirsPrefix Key = "bot.msg.dir.help_existing_dirs_prefix"
BotMsgDirHelpUsage Key = "bot.msg.dir.help_usage"
BotMsgDirInfoCreateDirSuccess Key = "bot.msg.dir.info_create_dir_success"
BotMsgDirInfoDeleteDirSuccess Key = "bot.msg.dir.info_delete_dir_success"
BotMsgDlErrorNoValidLinks Key = "bot.msg.dl.error_no_valid_links"
BotMsgDlInfoFilesSelectStorage Key = "bot.msg.dl.info_files_select_storage"
BotMsgDlUsage Key = "bot.msg.dl.usage"
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
BotMsgMediaGroupErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.media_group.error_build_storage_select_keyboard_failed"
BotMsgMediaGroupInfoGroupFoundFilesSelectStorage Key = "bot.msg.media_group.info_group_found_files_select_storage"
BotMsgMediaGroupInfoSavingFiles Key = "bot.msg.media_group.info_saving_files"
BotMsgParseErrorBuildParsedTextEntityFailed Key = "bot.msg.parse.error_build_parsed_text_entity_failed"
BotMsgParseErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.parse.error_build_storage_select_keyboard_failed"
BotMsgParseErrorParseTextFailed Key = "bot.msg.parse.error_parse_text_failed"
BotMsgParseInfoAuthorPrefix Key = "bot.msg.parse.info_author_prefix"
BotMsgParseInfoDescriptionPrefix Key = "bot.msg.parse.info_description_prefix"
BotMsgParseInfoFileCountPrefix Key = "bot.msg.parse.info_file_count_prefix"
BotMsgParseInfoLinkPrefix Key = "bot.msg.parse.info_link_prefix"
BotMsgParseInfoParsing Key = "bot.msg.parse.info_parsing"
BotMsgParseInfoPromptSelectStorage Key = "bot.msg.parse.info_prompt_select_storage"
BotMsgParseInfoTotalSizePrefix Key = "bot.msg.parse.info_total_size_prefix"
BotMsgParserErrorDownloadFileFailed Key = "bot.msg.parser.error_download_file_failed"
BotMsgParserErrorFileTooLarge Key = "bot.msg.parser.error_file_too_large"
BotMsgParserErrorGetFilenameFailed Key = "bot.msg.parser.error_get_filename_failed"
BotMsgParserErrorInstallPluginFailed Key = "bot.msg.parser.error_install_plugin_failed"
BotMsgParserErrorNoValidFileInReply Key = "bot.msg.parser.error_no_valid_file_in_reply"
BotMsgParserErrorOnlyJsSupported Key = "bot.msg.parser.error_only_js_supported"
BotMsgParserErrorWrongFileType Key = "bot.msg.parser.error_wrong_file_type"
BotMsgParserHelpText Key = "bot.msg.parser.help_text"
BotMsgParserInfoInstallPluginSuccess Key = "bot.msg.parser.info_install_plugin_success"
BotMsgParserPluginNotEnabled Key = "bot.msg.parser.plugin_not_enabled"
BotMsgParserPromptReplyWithParserFile Key = "bot.msg.parser.prompt_reply_with_parser_file"
BotMsgProgressAvgSpeedPrefix Key = "bot.msg.progress.avg_speed_prefix"
BotMsgProgressBatchDonePrefix Key = "bot.msg.progress.batch_done_prefix"
BotMsgProgressBatchProcessingPrefix Key = "bot.msg.progress.batch_processing_prefix"
BotMsgProgressBatchStartPrefix Key = "bot.msg.progress.batch_start_prefix"
BotMsgProgressCurrentProgressPrefix Key = "bot.msg.progress.current_progress_prefix"
BotMsgProgressDirectDonePrefix Key = "bot.msg.progress.direct_done_prefix"
BotMsgProgressDirectStart Key = "bot.msg.progress.direct_start"
BotMsgProgressDownloadDonePrefix Key = "bot.msg.progress.download_done_prefix"
BotMsgProgressDownloadFailedPrefix Key = "bot.msg.progress.download_failed_prefix"
BotMsgProgressDownloadingPrefix Key = "bot.msg.progress.downloading_prefix"
BotMsgProgressErrorPrefix Key = "bot.msg.progress.error_prefix"
BotMsgProgressFileNamePrefix Key = "bot.msg.progress.file_name_prefix"
BotMsgProgressFileProcessingPrefix Key = "bot.msg.progress.file_processing_prefix"
BotMsgProgressFileSizePrefix Key = "bot.msg.progress.file_size_prefix"
BotMsgProgressFileStartPrefix Key = "bot.msg.progress.file_start_prefix"
BotMsgProgressParsedDonePrefix Key = "bot.msg.progress.parsed_done_prefix"
BotMsgProgressParsedStartPrefix Key = "bot.msg.progress.parsed_start_prefix"
BotMsgProgressProcessingListPrefix Key = "bot.msg.progress.processing_list_prefix"
BotMsgProgressProcessingNone Key = "bot.msg.progress.processing_none"
BotMsgProgressSavePathPrefix Key = "bot.msg.progress.save_path_prefix"
BotMsgProgressTaskCanceled Key = "bot.msg.progress.task_canceled"
BotMsgProgressTaskCanceledWithId Key = "bot.msg.progress.task_canceled_with_id"
BotMsgProgressTaskFailedWithError Key = "bot.msg.progress.task_failed_with_error"
BotMsgProgressTelegraphDonePrefix Key = "bot.msg.progress.telegraph_done_prefix"
BotMsgProgressTelegraphProgressPrefix Key = "bot.msg.progress.telegraph_progress_prefix"
BotMsgProgressTelegraphStartPrefix Key = "bot.msg.progress.telegraph_start_prefix"
BotMsgProgressTotalSizePrefix Key = "bot.msg.progress.total_size_prefix"
BotMsgRuleErrorCreateRuleFailed Key = "bot.msg.rule.error_create_rule_failed"
BotMsgRuleErrorDeleteRuleFailed Key = "bot.msg.rule.error_delete_rule_failed"
BotMsgRuleErrorGetUserRulesFailed Key = "bot.msg.rule.error_get_user_rules_failed"
BotMsgRuleErrorInvalidRuleId Key = "bot.msg.rule.error_invalid_rule_id"
BotMsgRuleErrorInvalidRuleType Key = "bot.msg.rule.error_invalid_rule_type"
BotMsgRuleErrorUpdateUserFailed Key = "bot.msg.rule.error_update_user_failed"
BotMsgRuleHelpAddSuffix Key = "bot.msg.rule.help_add_suffix"
BotMsgRuleHelpAvailableOps Key = "bot.msg.rule.help_available_ops"
BotMsgRuleHelpCurrentModeDisabled Key = "bot.msg.rule.help_current_mode_disabled"
BotMsgRuleHelpCurrentModeEnabled Key = "bot.msg.rule.help_current_mode_enabled"
BotMsgRuleHelpDelSuffix Key = "bot.msg.rule.help_del_suffix"
BotMsgRuleHelpExistingRulesPrefix Key = "bot.msg.rule.help_existing_rules_prefix"
BotMsgRuleHelpSwitchSuffix Key = "bot.msg.rule.help_switch_suffix"
BotMsgRuleHelpUsage Key = "bot.msg.rule.help_usage"
BotMsgRuleInfoCreateRuleSuccess Key = "bot.msg.rule.info_create_rule_success"
BotMsgRuleInfoDeleteRuleSuccess Key = "bot.msg.rule.info_delete_rule_success"
BotMsgRuleInfoRuleModeDisabled Key = "bot.msg.rule.info_rule_mode_disabled"
BotMsgRuleInfoRuleModeEnabled Key = "bot.msg.rule.info_rule_mode_enabled"
BotMsgRulePromptProvideRuleId Key = "bot.msg.rule.prompt_provide_rule_id"
BotMsgSaveErrorInvalidIdOrUsername Key = "bot.msg.save.error_invalid_id_or_username"
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
BotMsgStorageInfoFilenamePrefix Key = "bot.msg.storage.info_filename_prefix"
BotMsgStorageInfoPromptSelectStorage Key = "bot.msg.storage.info_prompt_select_storage"
BotMsgTasksCancelFailed Key = "bot.msg.tasks.cancel_failed"
BotMsgTasksCancelRequestedPrefix Key = "bot.msg.tasks.cancel_requested_prefix"
BotMsgTasksFieldCreated Key = "bot.msg.tasks.field_created"
BotMsgTasksFieldId Key = "bot.msg.tasks.field_id"
BotMsgTasksFieldStatus Key = "bot.msg.tasks.field_status"
BotMsgTasksFieldTitle Key = "bot.msg.tasks.field_title"
BotMsgTasksInfoAddedToQueueFull Key = "bot.msg.tasks.info_added_to_queue_full"
BotMsgTasksInfoAddedToQueuePrefix Key = "bot.msg.tasks.info_added_to_queue_prefix"
BotMsgTasksInfoFilenamePrefix Key = "bot.msg.tasks.info_filename_prefix"
BotMsgTasksInfoQueueLengthPrefix Key = "bot.msg.tasks.info_queue_length_prefix"
BotMsgTasksQueuedEmpty Key = "bot.msg.tasks.queued_empty"
BotMsgTasksQueuedTitle Key = "bot.msg.tasks.queued_title"
BotMsgTasksRunningEmpty Key = "bot.msg.tasks.running_empty"
BotMsgTasksRunningTitle Key = "bot.msg.tasks.running_title"
BotMsgTasksStatusCancelRequested Key = "bot.msg.tasks.status_cancel_requested"
BotMsgTasksStatusQueued Key = "bot.msg.tasks.status_queued"
BotMsgTasksStatusRunning Key = "bot.msg.tasks.status_running"
BotMsgTasksTotalPrefix Key = "bot.msg.tasks.total_prefix"
BotMsgTasksTruncatedNote Key = "bot.msg.tasks.truncated_note"
BotMsgTasksUsage Key = "bot.msg.tasks.usage"
BotMsgTasksUsageCancel Key = "bot.msg.tasks.usage_cancel"
BotMsgTelegraphErrorBuildStorageSelectKeyboardFailed Key = "bot.msg.telegraph.error_build_storage_select_keyboard_failed"
BotMsgTelegraphInfoPicCountPrefix Key = "bot.msg.telegraph.info_pic_count_prefix"
BotMsgTelegraphInfoPromptSelectStorage Key = "bot.msg.telegraph.info_prompt_select_storage"
BotMsgTelegraphInfoTitlePrefix Key = "bot.msg.telegraph.info_title_prefix"
BotMsgUpdateButtonUpgrade Key = "bot.msg.update.button_upgrade"
BotMsgUpdateErrorCheckLatestFailed Key = "bot.msg.update.error_check_latest_failed"
BotMsgUpdateErrorNoReleaseFound Key = "bot.msg.update.error_no_release_found"
BotMsgUpdateErrorUpgradeFailed Key = "bot.msg.update.error_upgrade_failed"
BotMsgUpdateErrorVersionVarInvalid Key = "bot.msg.update.error_version_var_invalid"
BotMsgUpdateInfoAlreadyLatest Key = "bot.msg.update.info_already_latest"
BotMsgUpdateInfoMajorUpgradeRequired Key = "bot.msg.update.info_major_upgrade_required"
BotMsgUpdateInfoNewVersionInDocker Key = "bot.msg.update.info_new_version_in_docker"
BotMsgUpdateInfoNewVersionPromptUpgrade Key = "bot.msg.update.info_new_version_prompt_upgrade"
BotMsgUpdateInfoUpgradeSuccess Key = "bot.msg.update.info_upgrade_success"
BotMsgUpdateInfoUpgradingWithVersion Key = "bot.msg.update.info_upgrading_with_version"
BotMsgWatchErrorFilterFormatInvalid Key = "bot.msg.watch.error_filter_format_invalid"
BotMsgWatchErrorFilterTypeUnsupported Key = "bot.msg.watch.error_filter_type_unsupported"
BotMsgWatchErrorUnwatchChatFailed Key = "bot.msg.watch.error_unwatch_chat_failed"
BotMsgWatchErrorUnwatchNoChatProvided Key = "bot.msg.watch.error_unwatch_no_chat_provided"
BotMsgWatchErrorWatchChatFailed Key = "bot.msg.watch.error_watch_chat_failed"
BotMsgWatchInfoAlreadyWatchingChat Key = "bot.msg.watch.info_already_watching_chat"
BotMsgWatchInfoWatchChatStarted Key = "bot.msg.watch.info_watch_chat_started"
BotMsgWatchInfoWatchChatStopped Key = "bot.msg.watch.info_watch_chat_stopped"
BotMsgWatchInfoWatchListEmpty Key = "bot.msg.watch.info_watch_list_empty"
BotMsgWatchInfoWatchListFilterPrefix Key = "bot.msg.watch.info_watch_list_filter_prefix"
BotMsgWatchInfoWatchListHeader Key = "bot.msg.watch.info_watch_list_header"
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
ErrCleanCacheFailed Key = "err.clean_cache_failed"
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
LifetimeBye Key = "lifetime.bye"
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
LifetimeExiting Key = "lifetime.exiting"
LifetimeInitfailed Key = "lifetime.initfailed"
LifetimeIniting Key = "lifetime.initing"
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
)

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

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

View File

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

View File

@@ -1,48 +0,0 @@
package common
import (
"errors"
"os"
"path/filepath"
"time"
"github.com/krau/SaveAny-Bot/logger"
)
// 删除文件, 并清理空目录. 如果文件不存在则返回 nil
func PurgeFile(path string) error {
if err := os.Remove(path); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
}
return RemoveEmptyDirectories(filepath.Dir(path))
}
func RmFileAfter(path string, td time.Duration) {
_, err := os.Stat(path)
if err != nil {
logger.L.Errorf("Failed to create timer for %s: %s", path, err)
return
}
logger.L.Debugf("Remove file after %s: %s", td, path)
time.AfterFunc(td, func() {
PurgeFile(path)
})
}
// 递归删除空目录
func RemoveEmptyDirectories(dirPath string) error {
entries, err := os.ReadDir(dirPath)
if err != nil {
return err
}
if len(entries) == 0 {
err := os.Remove(dirPath)
if err != nil {
return err
}
return RemoveEmptyDirectories(filepath.Dir(dirPath))
}
return nil
}

14
common/tdler/dler.go Normal file
View File

@@ -0,0 +1,14 @@
package tdler
import (
"github.com/gotd/td/telegram/downloader"
"github.com/krau/SaveAny-Bot/common/utils/dlutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/consts/tglimit"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
func NewDownloader(file tfile.TGFile) *downloader.Builder {
return downloader.NewDownloader().WithPartSize(tglimit.MaxPartSize).
Download(file.Dler(), file.Location()).WithThreads(dlutil.BestThreads(file.Size(), config.C().Threads))
}

33
common/utils/dlutil/dl.go Normal file
View File

@@ -0,0 +1,33 @@
package dlutil
import "time"
var threadsLevels = []struct {
threads int
size int64
}{
{1, 10 << 20},
{2, 50 << 20},
{4, 200 << 20},
{8, 500 << 20},
}
func BestThreads(size int64, max int) int {
for _, thread := range threadsLevels {
if size < thread.size {
return min(thread.threads, max)
}
}
return max
}
func GetSpeed(downloaded int64, startTime time.Time) float64 {
if startTime.IsZero() {
return 0
}
elapsed := time.Since(startTime).Seconds()
if elapsed <= 0 {
return 0
}
return float64(downloaded) / elapsed
}

77
common/utils/fsutil/fs.go Normal file
View File

@@ -0,0 +1,77 @@
package fsutil
import (
"os"
"path/filepath"
"strings"
"unicode"
"github.com/gabriel-vasile/mimetype"
)
// 删除文件夹内的所有文件和子目录, 但不删除文件夹本身
func RemoveAllInDir(dirPath string) error {
entries, err := os.ReadDir(dirPath)
if err != nil {
return err
}
for _, entry := range entries {
entryPath := filepath.Join(dirPath, entry.Name())
if err := os.RemoveAll(entryPath); err != nil {
return err
}
}
return nil
}
func DetectFileExt(fp string) string {
mt, err := mimetype.DetectFile(fp)
if err != nil {
return ""
}
return mt.Extension()
}
type File struct {
*os.File
}
func (f *File) Remove() error {
return os.Remove(f.Name())
}
func (f *File) CloseAndRemove() error {
if err := f.Close(); err != nil {
return err
}
return f.Remove()
}
func CreateFile(fp string) (*File, error) {
if err := os.MkdirAll(filepath.Dir(fp), os.ModePerm); err != nil {
return nil, err
}
file, err := os.Create(fp)
if err != nil {
return nil, err
}
return &File{File: file}, nil
}
func NormalizePathname(s string) string {
specials := `\/:*?"<>|` + "\n\r\t"
var builder strings.Builder
for _, ch := range s {
if strings.ContainsRune(specials, ch) || unicode.IsControl(ch) {
builder.WriteRune('_')
} else {
builder.WriteRune(ch)
}
}
result := strings.TrimRightFunc(builder.String(), func(r rune) bool {
return r == '.' || r == '_' || unicode.IsSpace(r)
})
return result
}

View File

@@ -0,0 +1,46 @@
package fsutil_test
import (
"testing"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
)
func TestNormalizePathname(t *testing.T) {
tests := []struct {
input string
expected string
}{
{
input: "hello/world?.txt ",
expected: "hello_world_.txt",
},
{
input: "bad|name:\nfile\r.",
expected: "bad_name__file",
},
{
input: "normal.txt",
expected: "normal.txt",
},
{
input: "test.... ",
expected: "test",
},
{
input: "abc<>def",
expected: "abc__def",
},
{
input: "with\tcontrol",
expected: "with_control",
},
}
for _, tc := range tests {
got := fsutil.NormalizePathname(tc.input)
if got != tc.expected {
t.Errorf("NormalizePathname(%q) = %q; want %q", tc.input, got, tc.expected)
}
}
}

View File

@@ -0,0 +1,65 @@
package ioutil
import (
"io"
"sync/atomic"
)
var _ io.ReadSeeker = (*ProgressReadSeeker)(nil)
// ProgressReadSeeker wraps an io.ReadSeeker and tracks read progress
type ProgressReadSeeker struct {
reader io.ReadSeeker
total atomic.Int64
read atomic.Int64
onProgress func(read int64, total int64)
}
// Seek implements io.ReadSeeker.
func (pr *ProgressReadSeeker) Seek(offset int64, whence int) (int64, error) {
return pr.reader.Seek(offset, whence)
}
// NewProgressReader creates a new ProgressReader
func NewProgressReader(rs io.ReadSeeker, total int64, onProgress func(read int64, total int64)) *ProgressReadSeeker {
prs := &ProgressReadSeeker{
reader: rs,
total: atomic.Int64{},
read: atomic.Int64{},
onProgress: onProgress,
}
prs.total.Store(total)
return prs
}
// Read implements io.Reader
func (pr *ProgressReadSeeker) Read(p []byte) (int, error) {
n, err := pr.reader.Read(p)
if n > 0 {
pr.read.Add(int64(n))
read := pr.read.Load()
if pr.onProgress != nil {
pr.onProgress(read, pr.total.Load())
}
}
return n, err
}
// Progress returns the current progress as a float64 between 0 and 1
func (pr *ProgressReadSeeker) Progress() float64 {
if pr.total.Load() <= 0 {
return 0
}
return float64(pr.read.Load()) / float64(pr.total.Load())
}
// Read returns the number of bytes read so far
func (pr *ProgressReadSeeker) BytesRead() int64 {
return pr.read.Load()
}
// Total returns the total number of bytes
func (pr *ProgressReadSeeker) Total() int64 {
return pr.total.Load()
}

View File

@@ -0,0 +1,51 @@
package ioutil
import (
"io"
)
type ProgressWriterAt struct {
wrAt io.WriterAt
onWrite func(n int)
}
func (p *ProgressWriterAt) WriteAt(buf []byte, off int64) (n int, err error) {
n, err = p.wrAt.WriteAt(buf, off)
if n > 0 {
p.onWrite(n)
}
return
}
func NewProgressWriterAt(
wrAt io.WriterAt,
onWrite func(n int),
) *ProgressWriterAt {
return &ProgressWriterAt{
wrAt: wrAt,
onWrite: onWrite,
}
}
type ProgressWriter struct {
wr io.Writer
onWrite func(n int)
}
func (p *ProgressWriter) Write(buf []byte) (n int, err error) {
n, err = p.wr.Write(buf)
if n > 0 {
p.onWrite(n)
}
return
}
func NewProgressWriter(
wr io.Writer,
onWrite func(n int),
) *ProgressWriter {
return &ProgressWriter{
wr: wr,
onWrite: onWrite,
}
}

View File

@@ -0,0 +1,78 @@
package netutil
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/net/proxy"
)
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
if proxyUrl == "" {
return http.DefaultClient, nil
}
transport, err := NewProxyTransport(proxyUrl)
if err != nil {
return nil, err
}
return &http.Client{
Transport: transport,
}, nil
}
var (
defaultProxyHttpClient *http.Client
onceLoadDefaultProxyHttpClient sync.Once
)
func DefaultParserHTTPClient() *http.Client {
onceLoadDefaultProxyHttpClient.Do(func() {
client, err := NewProxyHTTPClient(config.C().Parser.Proxy)
if err != nil {
log.Warn("Failed to create default proxy HTTP client, using http.DefaultClient", "error", err)
defaultProxyHttpClient = http.DefaultClient
} else {
defaultProxyHttpClient = client
}
})
return defaultProxyHttpClient
}
func NewProxyTransport(proxyStr string) (*http.Transport, error) {
proxyURL, err := url.Parse(proxyStr)
if err != nil {
return nil, err
}
transport := &http.Transport{
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
switch proxyURL.Scheme {
case "http", "https":
transport.Proxy = http.ProxyURL(proxyURL)
case "socks5", "socks5h":
dialer, err := proxy.FromURL(proxyURL, proxy.Direct)
if err != nil {
return nil, err
}
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.(proxy.ContextDialer).DialContext(ctx, network, addr)
}
default:
return nil, fmt.Errorf("unsupported proxy type: %s", proxyURL.Scheme)
}
return transport, nil
}

View File

@@ -0,0 +1,93 @@
package strutil
import (
"crypto/md5"
"encoding/hex"
"fmt"
"regexp"
"strconv"
"strings"
"github.com/duke-git/lancet/v2/slice"
)
func HashString(s string) string {
hash := md5.New()
hash.Write([]byte(s))
return hex.EncodeToString(hash.Sum(nil))
}
var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`)
func ExtractTagsFromText(text string) []string {
matches := TagRe.FindAllStringSubmatch(text, -1)
tags := make([]string, 0)
for _, match := range matches {
if len(match) > 1 {
tags = append(tags, match[1])
}
}
return slice.Compact(tags)
}
func ParseIntStrRange(input string, sep string) (int64, int64, error) {
parts := strings.Split(input, sep)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("invalid range format: %s", input)
}
min, err := strconv.ParseInt(strings.TrimSpace(parts[0]), 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("invalid minimum value: %s", parts[0])
}
max, err := strconv.ParseInt(strings.TrimSpace(parts[1]), 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("invalid maximum value: %s", parts[1])
}
if min > max {
min, max = max, min
}
return min, max, nil
}
func ParseArgsRespectQuotes(input string) []string {
var args []string
var current strings.Builder
inQuotes := false
escaped := false
for _, r := range input {
switch {
case escaped:
if r == '"' || r == '\\' {
current.WriteRune(r)
} else {
current.WriteRune('\\')
current.WriteRune(r)
}
escaped = false
case r == '\\':
escaped = true
case r == '"':
inQuotes = !inQuotes
case r == ' ' || r == '\t':
if inQuotes {
current.WriteRune(r)
} else if current.Len() > 0 {
args = append(args, current.String())
current.Reset()
}
default:
current.WriteRune(r)
}
}
if current.Len() > 0 {
args = append(args, current.String())
}
return args
}

View File

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

View File

@@ -0,0 +1,22 @@
package tgutil
import (
"context"
"github.com/celestix/gotgproto/ext"
)
type contextKey struct{}
var extKey = contextKey{}
func ExtFromContext(ctx context.Context) *ext.Context {
if extCtx, ok := ctx.Value(extKey).(*ext.Context); ok {
return extCtx
}
return nil
}
func ExtWithContext(ctx context.Context, extCtx *ext.Context) context.Context {
return context.WithValue(ctx, extKey, extCtx)
}

View File

@@ -0,0 +1,40 @@
package tgutil
import (
"fmt"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/tg"
)
func GetMediaFileName(media tg.MessageMediaClass) (string, error) {
switch v := media.(type) {
case *tg.MessageMediaPhoto:
f, ok := v.Photo.AsNotEmpty()
if !ok {
return "", fmt.Errorf("unknown type media: %T", media)
}
return fmt.Sprintf("%d.png", f.ID), nil
case *tg.MessageMediaDocument:
f, ok := v.Document.AsNotEmpty()
if !ok {
return "", fmt.Errorf("unknown type media: %T", media)
}
fileName := ""
for _, attribute := range f.Attributes {
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
fileName = name.GetFileName()
break
}
}
if fileName == "" {
mmt := mimetype.Lookup(f.GetMimeType())
if mmt != nil {
fileName = fmt.Sprintf("%d.%s", f.GetID(), mmt.Extension())
}
}
return fileName, nil
default:
return "", fmt.Errorf("unsupported type media: %T", media)
}
}

View File

@@ -0,0 +1,410 @@
package tgutil
import (
"fmt"
"strconv"
"strings"
"unicode"
"unicode/utf16"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/maputil"
"github.com/duke-git/lancet/v2/mathutil"
"github.com/duke-git/lancet/v2/slice"
lcstrutil "github.com/duke-git/lancet/v2/strutil"
"github.com/duke-git/lancet/v2/validator"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/constant"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/rs/xid"
)
// generate a file name from the message content and media type
//
// it will never return an empty string
func GenFileNameFromMessage(message tg.Message) string {
ext := func(media tg.MessageMediaClass) string {
switch media := media.(type) {
case *tg.MessageMediaDocument:
doc, ok := media.Document.AsNotEmpty()
if !ok {
return ""
}
mmt := mimetype.Lookup(doc.MimeType)
if mmt == nil || mmt.Extension() == "" {
return ""
}
return mmt.Extension()
case *tg.MessageMediaPhoto:
return ".jpg"
}
return ""
}(message.Media)
text := strings.TrimSpace(message.GetMessage())
if text == "" {
return fmt.Sprintf("%d_%s%s", message.GetID(), xid.New().String(), ext)
}
filename := func() string {
tags := strutil.ExtractTagsFromText(text)
if len(tags) > 0 {
tagStrRunes := make([]rune, 0, 64)
for i, tag := range tags {
if i > 0 {
tagStrRunes = append(tagStrRunes, '_')
}
tagStrRunes = append(tagStrRunes, []rune(tag)...)
if len(tagStrRunes) >= 64 {
break
}
}
tagStr := string(tagStrRunes)
return fmt.Sprintf("%s_%s", tagStr, strconv.Itoa(message.GetID()))
}
text = lcstrutil.Substring(strings.Map(func(r rune) rune {
switch r {
case '/', '\\',
':', '*', '?', '"', '<', '>', '|':
return '_'
}
if unicode.IsControl(r) || unicode.IsSpace(r) {
return '_'
}
if validator.IsPrintable(string(r)) {
return r
}
return '_'
}, text), 0, 64)
text = strings.Join(strings.FieldsFunc(text, func(r rune) bool {
return r == '_' || r == ' '
}), "_")
return text
}()
if filename == "" {
mname, err := GetMediaFileName(message.Media)
if err != nil {
filename = fmt.Sprintf("%d_%s", message.GetID(), xid.New().String())
} else {
filename = mname
}
}
return filename + ext
}
func BuildCancelButton(taskID string) tg.KeyboardButtonClass {
return &tg.KeyboardButtonCallback{
Text: i18n.T(i18nk.BotMsgCommonCancelButtonText, nil),
Data: fmt.Appendf(nil, "cancel %s", taskID),
}
}
func InputMessageClassSliceFromInt(ids []int) []tg.InputMessageClass {
result := make([]tg.InputMessageClass, 0, len(ids))
for _, id := range ids {
result = append(result, &tg.InputMessageID{
ID: id,
})
}
return result
}
func GetMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
if msg, err := getMessagesRange(ctx, chatID, minId, maxId); err == nil {
return msg, nil
}
in := constant.TDLibPeerID(chatID)
plain := in.ToPlain()
var channel constant.TDLibPeerID
channel.Channel(plain)
if msg, err := getMessagesRange(ctx, int64(channel), minId, maxId); err == nil {
return msg, nil
}
var userID constant.TDLibPeerID
userID.User(plain)
if msg, err := getMessagesRange(ctx, int64(userID), minId, maxId); err == nil {
return msg, nil
}
var chat constant.TDLibPeerID
chat.Chat(plain)
if msg, err := getMessagesRange(ctx, int64(chat), minId, maxId); err == nil {
return msg, nil
}
return nil, fmt.Errorf("failed to get messages range for chatID %d", chatID)
}
func getMessagesRange(ctx *ext.Context, chatID int64, minId, maxId int) ([]*tg.Message, error) {
if minId > maxId {
return nil, fmt.Errorf("minId (%d) cannot be greater than maxId (%d)", minId, maxId)
}
total := maxId - minId + 1
msgIds := mathutil.Range(minId, total)
toFetchIds := make([]int, 0, total)
cached := make(map[int]*tg.Message, total)
for _, id := range msgIds {
if msg, ok := cache.Get[*tg.Message](fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, id)); ok {
cached[id] = msg
} else {
toFetchIds = append(toFetchIds, id)
}
}
if len(toFetchIds) == 0 {
return maputil.Values(cached), nil
}
result := make([]*tg.Message, 0, total)
chunks := slice.Chunk(toFetchIds, 100)
for _, chunk := range chunks {
msgs, err := ctx.GetMessages(chatID, InputMessageClassSliceFromInt(chunk))
if err != nil {
return nil, err
}
if len(msgs) == 0 {
continue
}
for _, msg := range msgs {
if msg == nil {
continue
}
tgMessage, ok := msg.(*tg.Message)
if !ok {
continue
}
if tgMessage.GetID() < minId || tgMessage.GetID() > maxId {
continue
}
result = append(result, tgMessage)
}
}
for _, msg := range result {
cache.Set(fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID()), msg)
}
for _, msg := range cached {
if msg == nil {
continue
}
result = append(result, msg)
}
return result, nil
}
// [TODO]
// type MessageItem struct {
// Message *tg.Message
// Error error
// }
// func IterMessages(ctx *ext.Context, chatID int64, minId, maxId int) (<-chan MessageItem, error) {
// total := maxId - minId + 1
// ch := make(chan MessageItem, 100)
// go func() {
// defer close(ch)
// if !ctx.Self.Bot {
// perr := ctx.PeerStorage.GetInputPeerById(chatID)
// if perr == nil || perr.(*tg.InputPeerEmpty) != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("peer not found: %d", chatID),
// }
// return
// }
// for i := 0; i < total; i += 100 {
// start := minId + i
// end := min(start+100, maxId)
// msgs, err := ctx.Raw.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
// Peer: perr,
// OffsetID: start,
// AddOffset: start - end,
// Limit: 100,
// })
// if err != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("failed to get messages: %w", err),
// }
// return
// }
// var msgClass []tg.MessageClass
// switch msgsv := msgs.(type) {
// case *tg.MessagesMessages:
// msgClass = msgsv.GetMessages()
// case *tg.MessagesMessagesSlice:
// msgClass = msgsv.GetMessages()
// case *tg.MessagesChannelMessages:
// msgClass = msgsv.GetMessages()
// default:
// ch <- MessageItem{
// Error: fmt.Errorf("unsupported message type: %T", msgsv),
// }
// continue
// }
// for _, msg := range msgClass {
// msg, ok := msg.AsNotEmpty()
// if !ok {
// continue
// }
// switch msg := msg.(type) {
// case *tg.Message:
// key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msg.GetID())
// cache.Set(key, msg)
// ch <- MessageItem{
// Message: msg,
// }
// }
// }
// }
// } else {
// for i := 0; i < total; i += 100 {
// start := minId + i
// end := min(start+100, maxId)
// msgs, err := GetMessagesRange(ctx, chatID, start, end)
// if err != nil {
// ch <- MessageItem{
// Error: fmt.Errorf("failed to get messages: %w", err),
// }
// return
// }
// for _, msg := range msgs {
// if msg == nil {
// continue
// }
// ch <- MessageItem{
// Message: msg,
// }
// }
// }
// }
// }()
// return ch, nil
// }
func getMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
key := fmt.Sprintf("tgmsg:%d:%d:%d", ctx.Self.ID, chatID, msgID)
if msg, ok := cache.Get[*tg.Message](key); ok {
return msg, nil
}
msgs, err := ctx.GetMessages(chatID, []tg.InputMessageClass{
&tg.InputMessageID{ID: msgID},
})
if err != nil {
return nil, fmt.Errorf("failed to get message by ID: %w", err)
}
if len(msgs) == 0 {
return nil, fmt.Errorf("message not found: chatID=%d, msgID=%d", chatID, msgID)
}
msg := msgs[0]
tgm, ok := msg.(*tg.Message)
if !ok {
return nil, fmt.Errorf("unexpected message type: %T", msg)
}
cache.Set(key, tgm)
return tgm, nil
}
// f**k gotgproto's breaking changes
func GetMessageByID(ctx *ext.Context, chatID int64, msgID int) (*tg.Message, error) {
// we don't know what the input chatID is bot api style(e.g. channel with -100 prefix) or plain tdlib style(no any prefix and every id is positive)
if msg, err := getMessageByID(ctx, chatID, msgID); err == nil {
return msg, nil
}
in := constant.TDLibPeerID(chatID)
plain := in.ToPlain()
var channel constant.TDLibPeerID
channel.Channel(plain)
if msg, err := getMessageByID(ctx, int64(channel), msgID); err == nil {
return msg, nil
}
var chat constant.TDLibPeerID
chat.Chat(plain)
if msg, err := getMessageByID(ctx, int64(chat), msgID); err == nil {
return msg, nil
}
var userID constant.TDLibPeerID
userID.User(plain)
if msg, err := getMessageByID(ctx, int64(userID), msgID); err == nil {
return msg, nil
}
return nil, fmt.Errorf("failed to get message by ID: chatID=%d, msgID=%d", chatID, msgID)
}
func GetGroupedMessages(ctx *ext.Context, chatID int64, msg *tg.Message) ([]*tg.Message, error) {
groupID, isGroup := msg.GetGroupedID()
if !isGroup || groupID == 0 {
return nil, fmt.Errorf("message %d is not grouped", msg.GetID())
}
msgID := msg.GetID()
minID := msgID - 10
maxID := msgID + 10
if minID < 1 {
minID = 1
}
msgs, err := GetMessagesRange(ctx, chatID, minID, maxID)
if err != nil {
return nil, err
}
groupedMessages := make([]*tg.Message, 0, len(msgs))
for _, m := range msgs {
if m == nil {
continue
}
mgid, isGroup := m.GetGroupedID()
if isGroup && mgid == groupID {
groupedMessages = append(groupedMessages, m)
}
}
return groupedMessages, nil
}
func ExtractMessageEntityUrls(msg *tg.Message) []string {
if len(msg.Entities) == 0 {
return nil
}
msgText := msg.GetMessage()
if msgText == "" {
return nil
}
runes := []rune(msgText)
utf16Codes := utf16.Encode(runes)
var urls []string
for _, entity := range msg.Entities {
switch ent := entity.(type) {
case *tg.MessageEntityTextURL:
urls = append(urls, ent.GetURL())
case *tg.MessageEntityURL:
start := ent.Offset
end := ent.Offset + ent.Length
if start < 0 || end > len(utf16Codes) {
continue
}
subRunes := utf16.Decode(utf16Codes[start:end])
urls = append(urls, string(subRunes))
}
}
return urls
}
func ExtractMessageEntityUrlsText(msg *tg.Message) string {
if msg == nil {
return ""
}
urls := ExtractMessageEntityUrls(msg)
if len(urls) == 0 {
return msg.GetMessage()
}
var sb strings.Builder
for _, url := range urls {
sb.WriteString(url)
sb.WriteString(" ")
}
return sb.String()
}

View File

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

View File

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

View File

@@ -0,0 +1,119 @@
package tgutil
import (
"fmt"
"net/url"
"strconv"
"strings"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/validator"
"github.com/gotd/td/tg"
)
func ParseChatID(ctx *ext.Context, idOrUsername string) (int64, error) {
idOrUsername = strings.TrimPrefix(idOrUsername, "@")
if validator.IsIntStr(idOrUsername) {
chatID, err := strconv.Atoi(idOrUsername)
if err != nil {
return 0, err
}
return int64(chatID), nil
}
username := idOrUsername
peer := ctx.PeerStorage.GetPeerByUsername(username)
if peer != nil && peer.ID != 0 {
return peer.ID, nil
}
chat, err := ctx.ResolveUsername(username)
if err != nil {
return 0, err
}
if chat == nil {
return 0, fmt.Errorf("no chat found for username: %s", idOrUsername)
}
chatID := chat.GetID()
if chatID == 0 {
return 0, fmt.Errorf("chat ID is zero for username: %s", idOrUsername)
}
return chatID, nil
}
// return: ChatID, MessageID, error
func ParseMessageLink(ctx *ext.Context, link string) (int64, int, error) {
u, err := url.Parse(link)
if err != nil {
return 0, 0, fmt.Errorf("invalid URL: %w", err)
}
paths := strings.Split(strings.TrimPrefix(u.Path, "/"), "/")
if cmt := u.Query().Get("comment"); cmt != "" {
// 频道评论的消息链接
// https://t.me/acherkrau/123?comment=2
chid, err := ParseChatID(ctx, paths[0])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
}
chatfull, err := ctx.GetChat(chid)
if err != nil {
return 0, 0, fmt.Errorf("failed to get chat: %w", err)
}
chfull, ok := chatfull.(*tg.ChannelFull)
if !ok {
return 0, 0, fmt.Errorf("chat is not a channel: %s", chatfull.TypeName())
}
linkChatId, ok := chfull.GetLinkedChatID()
if !ok {
return 0, 0, fmt.Errorf("channel has no linked chat")
}
msgID, err := strconv.Atoi(cmt)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse comment ID: %w", err)
}
return linkChatId, msgID, nil
}
switch len(paths) {
case 2: // https://t.me/acherkrau/123
chatID, err := ParseChatID(ctx, paths[0])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 3:
// https://t.me/c/123456789/123
// https://t.me/acherkrau/123/456 , 123: topic id
chatPart, msgPart := paths[1], paths[2]
if paths[0] != "c" {
chatPart = paths[0]
}
chatID, err := ParseChatID(ctx, chatPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
}
msgID, err := strconv.Atoi(msgPart)
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
case 4:
// https://t.me/c/123456789/111/456 111: topic id
if paths[0] != "c" {
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}
chatID, err := ParseChatID(ctx, paths[1])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse chat ID: %w", err)
}
msgID, err := strconv.Atoi(paths[3])
if err != nil {
return 0, 0, fmt.Errorf("failed to parse message ID: %w", err)
}
return chatID, msgID, nil
}
return 0, 0, fmt.Errorf("invalid message link format: %s", link)
}

View File

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

View File

@@ -1,7 +0,0 @@
package common
var (
Version string = "dev"
BuildTime string = "unknown"
GitCommit string = "unknown"
)

View File

@@ -1,37 +1,54 @@
workers = 4 # 同时下载文件数
# 创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。
# 更详细的配置请在 https://sabot.unv.app/deployment/configuration 查看
workers = 4 # 同时下载文件数
retry = 3 # 下载失败重试次数
threads = 4 # 单个任务下载使用的最大线程数
stream = false # 使用流式传输模式, 建议仅在硬盘空间十分有限时使用.
[telegram]
token = "" # Bot Token
admins = [777000] # 你的 user_id
api_id = 123456 # Telegram API ID
api_hash = "0123456789abcdef0123456789abcdef" # Telegram API Hash
# Bot Token
# 更换 Bot Token 后请删除会话数据库文件 (默认路径为 data/session.db )
token = ""
# Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH
# 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org)
# app_id = 1025907
# app_hash = "452b0359b988148995f22ff0f4229750"
[telegram.proxy]
# 启用代理连接 telegram, 只支持 socks5
enable = false
url = "socks5://127.0.0.1:7890"
[log]
level = "DEBUG" # 日志等级
[temp]
base_path = "cache/" # 下载文件临时目录, 请不要在此目录下存放任何其他文件
cache_ttl = 30 # 临时文件保存时间, 单位: 秒
[db]
path = "data/data.db" # 数据库文件路径
[storage]
[storage.alist] # Alist
# 存储列表
[[storages]]
# 标识名, 需要唯一
name = "本机1"
# 存储类型, 目前可用: local, alist, webdav, s3, telegram
type = "local"
# 启用存储
enable = true
base_path = "/telegram" # 保存路径
username = "admin" # 用户名
password = "password" # 密码
url = "https://alist.com" # Alist 地址
token_exp = 86400 # token 过期时间, 单位: 秒
# 文件保存路径
base_path = "./downloads"
[storage.local] # 本地磁盘
enable = true
base_path = "downloads/" # 保存路径
[[storages]]
name = "MyWebdav"
type = "webdav"
enable = false
base_path = '/path/telegram'
url = 'https://example.com/dav'
username = 'username'
password = 'password'
[storage.webdav] # WebDav
enable = true
base_path = "/telegram"
username = "admin"
password = "password"
url = "https://alist.com/dav"
# 用户列表
[[users]]
# telegram user id
id = 114514
# 存储过滤列表, 元素为存储标识名.
# 将该列表留空并开启黑名单过滤模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储
storages = []
# 使用列表过滤黑名单模式,反之则为白名单,白名单请在列表中指定可用的存储.
blacklist = true
[[users]]
id = 123456
storages = ["本机1"]
blacklist = false # 使用白名单模式,此时,用户 123456 仅可使用标识名为 '本地1' 的存储

7
config/cache.go Normal file
View File

@@ -0,0 +1,7 @@
package config
type cacheConfig struct {
TTL int64 `toml:"ttl" mapstructure:"ttl" json:"ttl"`
NumCounters int64 `toml:"num_counters" mapstructure:"num_counters" json:"num_counters"`
MaxCost int64 `toml:"max_cost" mapstructure:"max_cost" json:"max_cost"`
}

6
config/db.go Normal file
View File

@@ -0,0 +1,6 @@
package config
type dbConfig struct {
Path string `toml:"path" mapstructure:"path"`
Session string `toml:"session" mapstructure:"session"`
}

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