Compare commits

..

327 Commits

Author SHA1 Message Date
krau
942022969c style: format codes 2026-08-17 19:01:51 +08:00
krau
ab89310325 fix: drop unbounded saved-path cache in telegram storage
Telegram cannot reliably query remote file existence, so the
cache answered a question it could not answer and grew without
bound. Exists returns false again, as before.
2026-08-16 22:05:08 +08:00
krau
b27bb734be style: gofmt test files 2026-08-16 21:56:01 +08:00
krau
fc0c8975a0 style: trim verbose comments
Drop process-style explanations; keep one-line behavior notes.
2026-08-16 21:56:01 +08:00
krau
524a259ae4 test: exercise the alist 401 refresh path
Concurrent uploads now reject the init token, share one
refresh and retry with the new token; token-only stays inert.
2026-08-16 21:51:11 +08:00
krau
e8257c46d2 fix: refresh alist token after startup 401s
The init login no longer satisfies the dedup window, so an
early 401 triggers a real refresh; later 401s reuse it.
Clarify that streaming uploads cannot replay their body.
2026-08-16 21:51:11 +08:00
krau
6a11b8ac8d test: cover cancellation with IgnoreErrors 2026-08-16 21:44:40 +08:00
krau
47d941d851 test: cover notifyProgress tracker call 2026-08-16 21:44:40 +08:00
krau
7e502442c5 fix: propagate cancellation past IgnoreErrors
Cancelled tasks must not be reported as successful: only
ordinary element failures are ignored.
2026-08-16 21:44:35 +08:00
krau
a9425cb5ea fix: call tracker from notifyProgress instead of recursing
The helper called itself, overflowing the stack on any batch
task with a progress tracker.
2026-08-16 21:44:35 +08:00
krau
6ad2a2c884 test: cover alist refresh semantics
Concurrent refresh uses username/password; token-only storage
never attempts a login on 401.
2026-08-16 21:35:21 +08:00
krau
0d42c9f23d fix: skip login refresh for token-only alist storage
401 responses surface the auth error instead of sending a
credential-less login; the refresh window never exceeds TokenExp.
2026-08-16 21:35:21 +08:00
krau
4c8f35ae80 fix: report partial failure in batch done message
IgnoreErrors runs with failed elements show success and failed
counts instead of claiming every file completed.
2026-08-16 21:35:14 +08:00
krau
0a67b8bb57 test: cover nil-tracker task execution 2026-08-16 21:35:14 +08:00
krau
196bb9941f fix: keep album order when filtering failed batch items
Download results are stored by original index so surviving
elements keep their source order.
2026-08-16 21:35:14 +08:00
krau
973f3499a9 fix: guard nil progress trackers in api-created tasks
Batch, telegraph and transfer tasks run without a Telegram tracker
when created through the API; their callbacks must not panic.
2026-08-16 21:34:52 +08:00
krau
d9cb5f9cfc fix: deduplicate concurrent storage initialization
singleflight merges first-time inits so side effects are not duplicated.
2026-08-16 21:20:12 +08:00
krau
ccb702148e fix: localize storage lookup errors in /dir
Use the shared i18n key and escape the dynamic error.
2026-08-16 21:20:12 +08:00
krau
85b1f35dc5 fix: count parsed resources in progress text 2026-08-16 21:20:12 +08:00
krau
9f93a95258 test: cover concurrent alist 401 retry
Ten parallel uploads share a single re-login under -race.
2026-08-16 21:20:12 +08:00
krau
189bf9c736 fix: deduplicate alist token refreshes
Guard token access with a mutex and merge concurrent logins.
Reuse a recent refresh to avoid login storms.
2026-08-16 21:20:11 +08:00
krau
534ed7a7c2 fix: record telegram saved paths only after upload
Skip-large returns a sentinel so skipped files are not marked as saved.
2026-08-16 21:20:11 +08:00
krau
1d4997ba2d fix: upload only downloaded batch elements
Failed elements keep partial cache files and never reach the backend.
Successfully downloaded siblings still upload when one element fails.
2026-08-16 21:20:11 +08:00
krau
f7e532ca7e fix: make cache init idempotent 2026-08-16 20:30:06 +08:00
krau
0957d93da3 chore: ignore cache directory 2026-08-16 20:30:06 +08:00
krau
324d8c100f ci: fix BuildTime formatting and align checkout
Actions format does not format dates; pass the raw timestamp.
2026-08-16 20:28:45 +08:00
krau
4cb23f04b6 docs: document parser plugin config 2026-08-16 20:28:45 +08:00
krau
a92ca75c4f refactor: drop unused hook config 2026-08-16 20:28:45 +08:00
krau
4e99b4bdc9 fix: load remote config without local lookup
Skip the local file search after reading a config URL and add a timeout.
2026-08-16 20:28:45 +08:00
krau
389be59371 refactor: remove dead code from api and bot
Drop the empty ProgressTracker shim, unused token context key and a redundant SetBotCommands call.
2026-08-16 20:28:39 +08:00
krau
0d49ae94af fix: add timeouts and backoff to webhook delivery 2026-08-16 20:28:39 +08:00
krau
48e739f06d fix: fail fast when API listen fails
Bind synchronously and surface errors instead of logging them.
2026-08-16 20:28:39 +08:00
krau
b4fbbae068 fix: notify users on invalid update version 2026-08-16 20:28:33 +08:00
krau
29be3ee90c fix: avoid leaking raw errors in /dir reply 2026-08-16 20:28:33 +08:00
krau
56660a7705 fix: initialize userbot context once
Replace the racy lazy init with sync.OnceValue.
2026-08-16 20:28:33 +08:00
krau
0dfb6af153 fix: require permission for callback handlers 2026-08-16 20:28:33 +08:00
krau
63f53fac7f fix: isolate media groups per user
Key pending groups by chat, user and group id.
2026-08-16 20:28:33 +08:00
krau
607ec8aced fix: guard callback data parsing
Reject malformed callback payloads before indexing split parts.
2026-08-16 20:28:33 +08:00
krau
cd9886d710 fix: show all queued tasks in /task list
Render up to ten tasks and append the truncation note once.
2026-08-16 20:28:33 +08:00
krau
3f7f50133e test: cover invalid plugin version rejection 2026-08-16 20:28:27 +08:00
krau
c7911cc208 refactor: remove commented-out dead code
Drop unused kemono legacy types and commented response structs.
2026-08-16 20:28:27 +08:00
krau
b31d628c19 fix: guard kemono parser against nil fields
Skip sparse preview and attachment entries instead of panicking.
2026-08-16 20:28:27 +08:00
krau
60fd2e04d9 fix: harden JS parser plugin runtime
Validate semver instead of panicking and require canHandle.
Recover plugin worker panics and time out CanHandle calls.
Return a copy from the registry and sanitize install filenames.
2026-08-16 20:28:27 +08:00
krau
b72dd67be9 fix: guard storage registry maps
Protect Storages and UserStorages with mutexes and expose read accessors.
2026-08-16 20:28:21 +08:00
krau
aa25eb1510 fix: enforce telegram album limits and track saved paths
Use tglimit.MaxAlbumItems for album batching and video splitting.
Exists now reports previously saved paths instead of always false.
2026-08-16 20:28:21 +08:00
krau
ba0deababc fix: fail alist init instead of exiting
Replace log.Fatalf with wrapped errors so a bad alist cannot kill the bot.
Cancel token refresh with the init context and re-login on 401.
2026-08-16 20:28:21 +08:00
krau
d1b2dbfe5f fix: kill rclone subprocess on reader close
Prevent cat processes from hanging after the pipe is closed.
2026-08-16 20:28:15 +08:00
krau
bd7da7c31e fix: preserve webdav error causes
Wrap mkdir and write failures with %w and drop dead error values.
2026-08-16 20:28:15 +08:00
krau
1981d8662b fix: sanitize local storage paths
Reject absolute paths and dot-dot escapes in Save.
Check close errors and wrap creation failures.
2026-08-16 20:28:15 +08:00
krau
51fcd17922 refactor: share unique filename logic
Storage backends use fsutil.UniquePath instead of local loops.
2026-08-16 20:28:15 +08:00
krau
610f586d45 refactor: share progress throttling helpers
Move size-tiered and count-based throttling into progressutil.
Localize hardcoded Chinese progress strings.
Drop five duplicated implementations and dead local copies.
2026-08-16 20:28:07 +08:00
krau
6bc12d6feb fix: validate i18n key parity across locales
geni18n fails when a language file misses any key.
Align the syncpeers completion key between en and zh-Hans.
Translate three untranslated parse keys in zh-Hans.
2026-08-16 20:28:03 +08:00
krau
ad41aec22c fix: report streamed upload bytes in batch tasks
Stream downloads report their byte count as the upload total.
2026-08-16 20:27:53 +08:00
krau
4389bfe046 fix: honor IgnoreErrors in batch tasks
Element failures no longer cancel sibling elements or stop later groups.
2026-08-16 20:27:42 +08:00
krau
832eb27d4f fix: harden per-item processing tracking in tasks
Use the resource fingerprint as the dedup key on insert and delete.
Check and set processing entries atomically instead of TOCTOU.
Count only successful downloads.
2026-08-16 20:27:42 +08:00
krau
b6e981282d fix: make resource fingerprints deterministic
Sort map keys before hashing so Resource.ID is stable.
2026-08-16 20:27:42 +08:00
krau
9ed5b5f962 fix: init task queue lazily and close on shutdown
AddTask is safe before Run by initializing the queue once.
Close unblocks workers waiting in Get.
2026-08-16 20:27:26 +08:00
krau
4527022853 fix: prevent queue deadlock after cancelling tasks
Get no longer recurses while holding the mutex.
Cancelled queued tasks leave the map, making their IDs reusable.
Closed empty queues return ErrQueueClosed.
2026-08-16 20:27:26 +08:00
krau
6fd95bbe1b chore: update agents instruction 2026-08-11 09:04:15 +08:00
krau
a1bd2ed762 fix: upgrade deps 2026-08-11 08:57:09 +08:00
krau
7e3e26fcf4 style: format codebase 2026-08-11 08:47:35 +08:00
Haopeng Huo
e49ebef977 fix(telegraph): parse page URLs from message entities (#232) 2026-08-11 08:43:46 +08:00
Haopeng Huo
da38f5bb88 fix: remove cache files after reader closes (#231) 2026-08-11 08:41:40 +08:00
Haopeng Huo
0e6ed66ef5 feat: show upload progress for batch file tasks (#228)
* feat: show upload progress for file tasks

* feat: show upload progress for batch file tasks

* style: distinguish download and upload phases

* style: mark successful task completion

* fix: preserve storage save error context

* fix: serialize single-file progress updates

* fix: stabilize batch upload progress reporting

Correct batch upload totals, completion state, actual file sizes, and synchronized progress snapshots. Add regression coverage for concurrent updates and phase transitions.

* feat: show per-file transfer progress

Format single-file and batch download and upload states with Telegram entities, blockquotes, speeds, transferred sizes, progress bars, concise counters, and accurate confirmation handling. Cover upload retries plus final, error, and cancellation messages with regression tests.

* chore: remove transfer progress tests

* test: restore critical transfer progress coverage

* test: cover interleaved batch transfers

* fix: declare progress styles in locale templates
2026-08-11 08:40:29 +08:00
Haopeng Huo
e4144e73e6 feat(telegram): split oversized videos into playable parts (#229)
* feat(telegram): split oversized videos losslessly

* fix(telegram): respect Premium upload limits

* fix(telegram): keep split videos in one album
2026-08-05 16:57:46 +08:00
Haopeng Huo
1d794a7b9c feat(telegram): preserve source media groups (#226)
* feat(telegram): preserve source media groups

* fix(telegram): rewind batch readers before inspection
2026-08-04 16:46:29 +08:00
irudisca
52f880f0f2 fix(telegram): use seekable file path for thumbnail & metadata extraction (#225)
extractFrameAt() and getVideoMetadata() piped the media into ffmpeg/ffprobe
via pipe:0. A pipe is not seekable, so ffmpeg cannot decode non-faststart MP4s
whose moov atom sits at the END of the file (very common for yt-dlp / HLS-merged
downloads): the frame grab and probe silently fail, and the video is uploaded
with no thumbnail (and, for non-mp4 containers, no dimensions).

Hand ffmpeg/ffprobe a seekable file path instead. When the reader already is an
*os.File we use it directly; otherwise we spool to a temp file and clean it up.

Verified with an A/B upload of a 56 MB non-faststart clip: stock build produced
no thumbnail, patched build produced a correct thumbnail.

Co-authored-by: pennyucloud <valentino@pennyu.co.id>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 18:28:03 +08:00
krau
fc11ca775f fix: update message link parsing to support both t.me and telegram.me domains 2026-07-14 12:53:09 +08:00
krau
056e2fd546 fix: add ca-certificates installation and environment variables for SSL support in Dockerfile, close #222 2026-07-06 11:34:28 +08:00
krau
c9bb6c9e3c feat(docs): add CLI subcommands documentation and file naming strategies
- Introduced new documentation for CLI subcommands: `upload` and `watch`, detailing their usage, flags, and examples.
- Added a section on file naming and conflict strategies, explaining how users can customize file names and handle duplicates via `/config` and `/fnametmpl` commands.
- Translated the new documentation into Chinese for broader accessibility.
2026-06-28 18:00:02 +08:00
krau
2bc460c609 feat: add preset rule import functionality and update related messages 2026-06-26 15:39:14 +08:00
Krau
f02860ff3f feat: implement task event system for progress tracking and reporting (#220) 2026-06-25 21:37:36 +08:00
krau
9c2e70ed43 feat: upgrade deps 2026-06-25 16:30:21 +08:00
krau
3d6cd45909 feat: add configuration options for video download settings 2026-06-25 16:17:03 +08:00
Krau
77ef3154cf feat: add directory watching and auto-upload functionality (#219) 2026-06-25 14:34:59 +08:00
krau
88b170acaa fix: update backoff handling to use a factory function for concurrency safety 2026-06-25 10:22:31 +08:00
Ilham Syahid S
8059e27978 fix(api): sync task lifecycle state and restore GET /api/v1/tasks (#216)
* fix(api): update task route to handle GET and POST methods

Signed-off-by: Ilham Syahid S <ilhamsyahids@gmail.com>

* fix(api): implement ExecutableWrapper to manage task execution and status updates

Signed-off-by: Ilham Syahid S <ilhamsyahids@gmail.com>

* fix(api): refactor task registration and enqueueing into a separate method

Signed-off-by: Ilham Syahid S <ilhamsyahids@gmail.com>

---------

Signed-off-by: Ilham Syahid S <ilhamsyahids@gmail.com>
2026-05-24 23:42:16 +08:00
HLD
bfab4c85c8 fix: correct S3 signature and path handling (#213)
* fix: correct S3 signature and path handling

* fix: preserve existing overwrite behavior
2026-05-22 09:22:09 +08:00
Ryan
62e4a08e28 Merge pull request #204 from Rain-kl/feat/save-strategy
新增功能: 重名文件时选择保存策略 - 重命名,覆盖,跳过
2026-05-20 16:11:00 +08:00
Krau
0982abe7bc fix: apply user filename strategy in media group processing (#207)
Co-authored-by: xiangyang <xiangyang2001@outlook.com>
2026-05-05 14:29:01 +08:00
xiangyang
e74839b8e9 fix: apply user filename strategy in media group processing 2026-05-05 13:58:08 +08:00
krau
58ce8275b2 fix: upgrade deps 2026-04-14 09:55:15 +08:00
krau
8f67f778a3 feat(config): add log level configuration and update logging initialization, close #202 2026-04-14 09:41:02 +08:00
krau
159dba6224 fix(config): comment out default config saving logic 2026-03-30 18:33:03 +08:00
krau
22d773da10 fix(entrypoint): remove config.toml check 2026-03-30 18:17:44 +08:00
krau
69ccfa664f chore: bump gotd version 2026-03-30 16:14:13 +08:00
krau
38355dfd14 docs: Add documentation for various features including Aria2, direct download links, parsers, storage rules, silent mode, storage transfer, chat watching, and yt-dlp video download
- Created new markdown files for Aria2 download, direct download links, parsers, storage rules, silent mode, storage transfer, chat watching, and yt-dlp video download.
- Included usage examples, configuration instructions, and detailed explanations for each feature.
- Translated documentation into Chinese for accessibility.
2026-03-11 19:37:25 +08:00
krau
0940258b4d fix(watch): enhance group media handling 2026-03-11 19:11:44 +08:00
krau
602fc251d8 fix: upgrade deps and medernize codes 2026-03-05 19:40:25 +08:00
krau
af28738235 test(api): add api tests 2026-03-05 19:30:23 +08:00
krau
3eb3b6e3c8 feat(api): implement task management API with handlers for creating, listing, retrieving, and canceling tasks
- Added Handlers struct and methods for task operations
- Implemented task progress tracking and storage
- Created server setup with middleware for logging and recovery
- Added support for Telegram file extraction and Telegraph image extraction
- Introduced webhook functionality for task status updates
- Defined request and response types for API interactions
2026-03-05 19:11:30 +08:00
krau
f377ee3ca4 chore: format code for consistency and readability 2026-03-05 17:20:45 +08:00
krau
70f7172162 docs: add rclone support to configuration guide 2026-01-31 21:17:30 +08:00
krau
091f581881 Merge branch 'main' of https://github.com/krau/SaveAny-Bot 2026-01-30 13:34:56 +08:00
Krau
8b86330f5c feat: add rclone storage backend (#191)
* fix: update StoragePath method to return specific path for single file

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

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

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

* feat: add  i18n for import command

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

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

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

* Update core/tasks/batchimport/progress.go

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

* Update core/tasks/batchimport/execute.go

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

* Update storage/alist/alist.go

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

* Update common/i18n/locale/en.yaml

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

* Update pkg/storagetypes/fileinfo.go

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

* Update common/i18n/locale/en.yaml

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

* Update core/tasks/batchimport/execute.go

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

* Update storage/webdav/webdav.go

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

* Update storage/telegram/telegram.go

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

* Update core/tasks/batchimport/execute.go

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

* Update storage/webdav/webdav.go

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

* fix: missing progress stats i18n

* refactor: use strutil to parse args

* chore: update generated code files for consistency

---------

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

* Implement parameter support for /ytdlp command

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

* Add comprehensive tests for ytdlp parameter parsing

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

* Improve flag parsing logic and clarify argument order

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

* Preserve critical defaults and improve comments

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: krau <71133316+krau@users.noreply.github.com>
2026-01-19 13:10:21 +08:00
krau
3ce00884a0 feat: add yt-dlp support for downloading video/audio and enhance related commands 2026-01-17 17:42:11 +08:00
krau
cd7cf4964d fix: handle context cancellation during aria2 download and improve cleanup logic 2026-01-17 15:22:41 +08:00
krau
bc3c841d1d fix: update aria2 configuration to include KeepFile option and improve download handling 2026-01-17 15:19:49 +08:00
krau
743c15f1a5 fix: enhance aria2 download handling and logging for follow-up downloads 2026-01-17 15:10:37 +08:00
krau
b05d86509c feat: basic aria2 integration 2026-01-17 14:57:03 +08:00
krau
f17a380579 fix: implement watch media group handler and enhance media processing logic 2026-01-17 13:23:47 +08:00
krau
cabe1189e2 chore: delete copilot guideline 2026-01-16 22:27:40 +08:00
krau
a988d05e24 feat: add agent guidelines 2026-01-16 22:27:17 +08:00
krau
1af2c1f7c7 fix: upgrade deps 2026-01-16 20:43:19 +08:00
krau
7b36fb45f5 refactor: simplify T function and remove unused localization functions 2026-01-16 20:41:05 +08:00
krau
62cceee592 fix: enhance handleBatchSave to support user context and improve message retrieval 2026-01-08 13:25:59 +08:00
krau
6d315f7af2 refactor: simplify GetMessagesRange function and improve error message formatting 2026-01-08 12:42:44 +08:00
krau
5352491c76 chore: update issue template 2026-01-06 10:05:59 +08:00
krau
3f914f7a64 docs: update proxy configuration comments for clarity in README and example config files 2026-01-06 09:35:21 +08:00
krau
8972d8a169 feat: implement httpProxyDialer for HTTP CONNECT proxies and enhance newProxyDialer function 2026-01-06 09:29:45 +08:00
krau
1339c69dbf feat: update fnametmpl help 2026-01-06 09:21:05 +08:00
krau
63aeabb39b feat: add msgraw var to filename template and update i18n for fnamest display 2026-01-06 09:19:32 +08:00
krau
e60e983229 docs: add lang settings guide 2026-01-05 11:56:19 +08:00
krau
75e5fd10ea feat(aria2): add Aria2 download command and client integration 2026-01-03 17:40:55 +08:00
krau
c8d8a2e0eb feat: add command to sync peer chats with userbot 2026-01-03 16:32:58 +08:00
krau
044e732084 chore: remove CLAUDE.md documentation file as it is incorrect 2026-01-01 10:36:53 +08:00
krau
0e951f641c fix(storage): add panic recovery for malformed MP4 files
- Add defer/recover in getMP4Meta to catch panics from gomedia library
- Implement fallback to ffprobe when MP4 parsing fails
- Fixes "no vosdata" panic when processing certain MP4 files

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-31 20:36:26 +08:00
krau
8dd6265d55 chore: modenrize it 2025-12-27 16:01:41 +08:00
krau
ad7e01f052 fix: improve text message handling by removing duplicates and empty lines 2025-12-21 13:17:10 +08:00
krau
c069d1edc7 fix: format the generated i18n keys 2025-12-21 13:03:50 +08:00
krau
fd3b17a360 fix(i18n): add cancel button text 2025-12-21 12:59:14 +08:00
doomwithdon
a3162eff89 Add BotMsgCancelCancelTask key for bot messages (#162) 2025-12-21 12:56:42 +08:00
krau
cebadd39f7 docs: Update issue templates to English for consistency and clarity 2025-12-19 21:54:59 +08:00
krau
95f7d5abb5 docs: Enhance documentation with new features, guidelines, and configuration details 2025-12-19 21:03:58 +08:00
krau
5b8249060d docs: update readme 2025-12-19 20:57:28 +08:00
krau
0b97d15b2a feat(i18n): Integrate internationalization for bot commands and add English locale support 2025-12-19 20:48:50 +08:00
krau
6db5633ca6 feat(i18n): Add progress tracking messages for various tasks and enhance error handling 2025-12-19 20:40:14 +08:00
krau
4ccbf8177e fix(i18n): Update log messages to English and improve error handling in bot initialization and storage loading 2025-12-19 20:29:48 +08:00
krau
6e16dc6051 feat: Refactor bot handlers to utilize i18n for internationalization
- Updated silent.go to replace hardcoded strings with i18n keys for user feedback.
- Enhanced telegraph.go to use i18n for error messages and prompts.
- Modified update.go to implement i18n for version update notifications and errors.
- Refactored dir.go to utilize i18n for help messages related to directory operations.
- Updated parse.go to replace static text with i18n keys for parsed text entities.
- Enhanced rule.go to use i18n for rule management help messages.
- Refactored storage.go to implement i18n for storage selection prompts.
- Updated task.go to utilize i18n for task addition notifications.
- Added new i18n keys in keys.go for various messages.
- Updated Chinese localization in zh-Hans.yaml to reflect new i18n keys and messages.
2025-12-19 20:20:27 +08:00
krau
9ed2c425be feat(i18n): Enhance internationalization support and error handling messages
- Added i18n keys for various error and info messages related to task management, user handling, and storage operations.
- Updated error messages in the watch and task handling commands to use i18n for better localization.
- Refactored error handling in the database initialization and user synchronization processes to provide clearer messages.
- Improved logging messages for better clarity and consistency across the application.
- Added comprehensive documentation for AI collaboration and project structure in the repository.
2025-12-19 16:01:36 +08:00
krau
bc892d9370 chore: add no_bubbletea tag to build options in Dockerfiles 2025-12-19 14:21:12 +08:00
krau
29d523bd4f feat: refactor upload command and implement progress tracking 2025-12-19 14:20:00 +08:00
krau
3f9c3f2a73 feat: implement upload command for local file storage 2025-12-19 13:49:58 +08:00
krau
b796b045a8 feat: update locale file 2025-12-19 13:49:54 +08:00
krau
5f7b936270 feat: refactor storage retrieval functions and improve documentation 2025-12-19 13:49:20 +08:00
krau
df64ec3069 feat: add configuration flags and enhance config initialization 2025-12-19 13:49:13 +08:00
krau
d3cc56c8e6 feat: enhance user client context management and improve error handling 2025-12-19 13:48:55 +08:00
krau
018ed47949 fix(docs): update contribution guidelines for Go environment setup 2025-12-19 11:45:52 +08:00
krau
09f4dd4ce7 fix(docs): update Telegram configuration for file upload settings 2025-12-19 10:44:38 +08:00
krau
adc64ad510 ci: go eat ur fking shit tmp tags 2025-12-18 20:03:57 +08:00
krau
da6cf42355 ci: fxxk github action, gpt, gemini, docker and myself 2025-12-18 19:55:29 +08:00
krau
8c76516953 fix: simplify Docker build workflow by removing unnecessary steps and improving tag handling 2025-12-18 19:37:43 +08:00
krau
15c4fffb98 fix: update Docker build workflow to streamline variable extraction and tag handling 2025-12-18 19:35:29 +08:00
krau
b5cdf1e880 fix: streamline tag formatting in Docker image build step 2025-12-18 19:32:55 +08:00
krau
264dd9f9ed fix: update Docker image tag formatting in build workflow 2025-12-18 19:30:22 +08:00
krau
b25df2e214 fix: add missing fi statement in Dockerfile path setting step 2025-12-18 19:28:28 +08:00
krau
5999ddbe1d ci: refactor Docker build workflow to consolidate build steps and improve image tagging 2025-12-18 19:25:47 +08:00
krau
7424190ee5 fix: remove redundant chmod command for saveany-bot in Dockerfile.pico 2025-12-18 19:22:13 +08:00
krau
87e8836c78 fix: update IMAGE_NAME in Docker build workflow to use a specific repository name 2025-12-18 19:08:43 +08:00
krau
1a7747c2d2 feat: refactor Docker build workflow to extract metadata and streamline image builds 2025-12-18 19:03:29 +08:00
krau
ca0fd67fba ci: add latest tags for micro and pico Docker images 2025-12-18 18:44:18 +08:00
krau
4d736b925b docs: update storage config 2025-12-18 18:44:12 +08:00
krau
ead2b20f4e docs: add docker vriant introduction 2025-12-18 18:43:57 +08:00
krau
080d474714 revert: remove variant handling and simplify binary and asset naming in build-release workflow 2025-12-18 18:31:27 +08:00
krau
f453205fde feat: add Docker build flag and update version handling in update command 2025-12-18 18:12:31 +08:00
krau
407677f270 fix: update binary and asset naming conventions in build-release workflow 2025-12-18 17:59:55 +08:00
krau
958bfd1dbe ci: update build-release workflow to include asset name and additional build flags 2025-12-18 17:55:24 +08:00
krau
debe33d84d ci: add micro and pico Docker image build steps to workflow 2025-12-18 17:46:09 +08:00
krau
52eead3bf5 feat: refactor database dialect handling and add stubs for unsupported features 2025-12-18 17:42:20 +08:00
krau
0af049a507 feat: migrate S3 client implementation to a new package structure 2025-12-18 16:42:58 +08:00
krau
8752dd865c feat: refactor S3 storage implementation and reduce binary size 2025-12-18 16:21:40 +08:00
krau
c0b4580e34 fix: correct split size calculation in Save method 2025-12-16 20:52:17 +08:00
krau
280fd6ead8 fix: update DefaultSplitSize 2025-12-16 20:51:11 +08:00
krau
0ca3d97711 feat: add task command to client and Title method to Task for tasks queue managing, #157 2025-12-15 11:29:55 +08:00
krau
51198a1e3d fix: remove redundant cancellation in Done method 2025-12-15 11:15:17 +08:00
krau
651835c467 feat: refactor queue to remove unused methods and add comments 2025-12-15 10:49:40 +08:00
krau
45c978980c feat: add support for splitting large files into parts for Telegram storage, #156 2025-12-15 10:25:50 +08:00
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
379 changed files with 32150 additions and 5391 deletions

View File

@@ -6,6 +6,7 @@
downloads/
data/
cache/
docs
docs/
config.example.toml
docker-compose.*
playwright/

2
.github/FUNDING.yml vendored
View File

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

View File

@@ -1,35 +1,41 @@
name: "👾 报告 bug"
description: "报告 bug"
name: "👾 Bug Report"
description: "Report a bug or unexpected behavior"
labels:
- "bug"
assignees:
- krau
body:
- type: markdown
attributes:
value: |
# Please Search Before Submitting / 提交前请搜索
Please make sure to search existing issues before submitting a new bug report.
提交新的 Bug 报告前请务必搜索已有的 issue避免重复
- type: textarea
attributes:
label: "👾 问题描述"
label: "👾 Description"
description: "What happened?"
placeholder: "When called ... happens ..."
validations:
required: true
- type: textarea
attributes:
label: "⚡️ 预期行为"
label: "⚡️ Expected Behavior"
description: "What was expected?"
placeholder: "It should be ..."
- type: textarea
attributes:
label: "📄 配置文件"
label: "📄 Configuration File"
description: "Please provide your config file"
placeholder: "请自行隐去密钥信息"
placeholder: "Please remove sensitive information"
render: toml
validations:
required: true
- type: textarea
attributes:
label: "🔍 日志"
label: "🔍 Logs"
description: "Please provide logs"
placeholder: "可删除隐私信息"
placeholder: "Please remove sensitive information"
render: shell
validations:
required: true

View File

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

View File

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

View File

@@ -7,38 +7,53 @@ on:
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
IMAGE_NAME: krau/saveany-bot
concurrency:
group: docker-build-${{ github.repository }}
cancel-in-progress: true
jobs:
build-and-push:
prepare:
runs-on: ubuntu-latest
permissions:
contents: read
outputs:
version: ${{ steps.vars.outputs.version }}
major_minor: ${{ steps.vars.outputs.major_minor }}
short_sha: ${{ steps.vars.outputs.short_sha }}
build_time: ${{ steps.vars.outputs.build_time }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Extract Version Components
id: vars
run: |
VERSION=${GITHUB_REF#refs/tags/v}
MAJOR_MINOR=$(echo "$VERSION" | cut -d. -f1,2)
SHORT_SHA=$(git rev-parse --short HEAD)
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "major_minor=$MAJOR_MINOR" >> "$GITHUB_OUTPUT"
echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT"
build:
needs: prepare
permissions:
contents: read
packages: write
strategy:
matrix:
arch: [amd64, arm64]
type: [default, micro, pico]
fail-fast: false
runs-on: ${{ matrix.arch == 'amd64' && 'ubuntu-latest' || 'ubuntu-24.04-arm' }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest
type=ref,event=branch
type=ref,event=tag
labels: |
org.opencontainers.image.title=${{ env.IMAGE_NAME }}
org.opencontainers.image.source=https://github.com/krau/SaveAny-Bot
org.opencontainers.image.url=https://github.com/krau/SaveAny-Bot
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -50,23 +65,99 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version from Git Ref
id: extract_version
run: |
VERSION=$(echo "${{ github.ref }}" | sed 's/refs\/tags\/v//')
echo "VERSION=${VERSION}" >> $GITHUB_ENV
- name: Build and push Docker image
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
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
build-args: |
VERSION=${{ steps.meta.outputs.version }}
GitCommit=${{ github.sha }}
BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
- name:
Export digest
# 将 digest 写入文件,供后续步骤读取
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
echo "$digest" > /tmp/digests/digest
- name: Upload digest
uses: actions/upload-artifact@v6
with:
name: digest-${{ matrix.type }}-${{ matrix.arch }}
path: /tmp/digests/digest
if-no-files-found: error
retention-days: 1
create-manifest:
needs: [prepare, build]
runs-on: ubuntu-latest
permissions:
packages: write
strategy:
matrix:
type: [default, micro, pico]
steps:
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Download digests
uses: actions/download-artifact@v7
with:
path: /tmp/digests
pattern: digest-${{ matrix.type }}-*
merge-multiple: false
- name: Create and push manifest lists
run: |
REPO="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}"
VERSION="${{ needs.prepare.outputs.version }}"
MAJOR_MINOR="${{ needs.prepare.outputs.major_minor }}"
SHA="${{ needs.prepare.outputs.short_sha }}"
TYPE="${{ matrix.type }}"
DIGEST_AMD64=$(cat /tmp/digests/digest-${TYPE}-amd64/digest)
DIGEST_ARM64=$(cat /tmp/digests/digest-${TYPE}-arm64/digest)
echo "Found digests for $TYPE:"
echo "AMD64: $DIGEST_AMD64"
echo "ARM64: $DIGEST_ARM64"
TAGS=()
if [ "$TYPE" == "default" ]; then
TAGS+=("$REPO:latest")
TAGS+=("$REPO:$VERSION")
TAGS+=("$REPO:$MAJOR_MINOR")
TAGS+=("$REPO:sha-$SHA")
else
TAGS+=("$REPO:$TYPE")
TAGS+=("$REPO:$TYPE-latest")
TAGS+=("$REPO:$TYPE-$VERSION")
fi
SRC_AMD64="${REPO}@${DIGEST_AMD64}"
SRC_ARM64="${REPO}@${DIGEST_ARM64}"
echo "Creating manifest list with sources:"
echo " $SRC_AMD64"
echo " $SRC_ARM64"
for TAG in "${TAGS[@]}"; do
echo "Pushing tag: $TAG"
docker buildx imagetools create -t "$TAG" "$SRC_AMD64" "$SRC_ARM64"
done

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@@ -23,7 +23,7 @@ jobs:
uses: softprops/action-gh-release@v2
- name: Setup node
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: 22
@@ -43,7 +43,7 @@ jobs:
goarch: arm64
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Extract version from Git Ref
id: extract_version
@@ -63,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=${{ github.event.repository.updated_at }}"
-X "github.com/krau/SaveAny-Bot/config.GitCommit=${{ github.sha }}"
binary_name: saveany-bot
env:
VERSION: ${{ env.VERSION }}

View File

@@ -6,17 +6,31 @@ on:
paths:
- "docs/**"
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
- uses: actions/checkout@v6
with:
python-version: "3.11"
- uses: actions/cache@v4
submodules: true # Fetch Hugo themes (true OR recursive)
fetch-depth: 0 # Fetch all history for .GitInfo and .Lastmod
- name: Setup Hugo
uses: peaceiris/actions-hugo@v3
with:
key: ${{ github.ref }}
path: .cache
- run: pip install mkdocs-material
- run: cd docs && mkdocs gh-deploy --force
hugo-version: '0.147.8'
extended: true
- name: Build
run: hugo --minify --destination public --source docs
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
if: github.ref == 'refs/heads/main'
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/public
publish_branch: gh-pages

View File

@@ -1,17 +0,0 @@
name: Update Contributors
on:
workflow_dispatch:
jobs:
contrib-readme-job:
runs-on: ubuntu-latest
name: A job to automate contrib in readme
permissions:
contents: write
pull-requests: write
steps:
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@v2.3.10
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

11
.gitignore vendored
View File

@@ -1,9 +1,16 @@
config.toml
logs/
/cache/
tmp/
data/
downloads/
cache/
session.*
cache.db
.vscode/
.vscode/
temp/
.hugo_build.lock
playwright/
testplugins/
*.exe
tmp-*
saveany-bot

115
AGENTS.md Normal file
View File

@@ -0,0 +1,115 @@
# Repository Guidelines
## Project Overview
SaveAny-Bot is a Telegram bot written in Go that saves files and messages from Telegram and websites to multiple storage backends (Local, S3, MinIO, WebDAV, AList, Rclone, Telegram). It supports single-file saves, batch/album saves, streaming, multi-user access, storage rules, cross-storage transfers, yt-dlp and Aria2 downloads, and a Goja-based JavaScript parser plugin system with optional Playwright browser automation.
**Tech stack**: Go 1.25, gotgproto + gotd/td v0.149.0 (MTProto), Cobra (CLI), Viper (config), GORM + SQLite, Goja (JS runtime), Playwright, charmbracelet/log. License: AGPL-3.0.
**Note on gotd versions**: `gotd/td` must stay on `v0.149.0` — v0.150+ breaks `gotgproto` (v1.0.0-beta22) compilation (`AsInputDocumentFileLocation` signature, `gotd/log` Logger interface). Do not bump beyond v0.149.
## Architecture & Data Flow
```
Telegram update → client/bot/handlers → core.AddTask(Executable) → pkg/queue (serial workers)
→ core/tasks/* (download via common/tdler) → storage.Storage → progress feedback
```
- **Startup sequence** (`cmd/run.go::initAll`, keep this order): Config → Cache → i18n → Database → Storage → Parser plugins → Userbot → API → Bot. `bot.Init` returns the exit channel; a `SAVEANTBOT-RESTART` error restarts the process (external supervisor).
- **Task pipeline**: handlers build a task (via `core.AddTask`), the queue executes `Executable{Type, Title, TaskID, Execute(ctx)}` with `config.C().Workers` workers. Lifecycle hooks (`TaskBeforeStart/Success/Fail/Cancel`) run around `Execute`. Cancellation = canceling the task's context; tasks must check `ctx.Err()`.
- **Dual progress channels**: (1) `pkg/taskevent` context bus (consumed by `api/` for HTTP/Webhook consumers — `taskevent.WithSink` must be injected for API tasks), (2) Telegram message edits via `ProgressTracker` + `tgutil.ExtFromContext(ctx)` (bot tasks). Upload progress currently reaches the Telegram channel only.
- **Capability interfaces + type assertion fallback** is the core extensibility pattern: `StorageBatchSaver`, `StorageProgressSaver` (upload progress), `StorageListable`, `StorageReadable` are optional; consumers assert and fall back (e.g. wrap reader with `ioutil.NewProgressReader`). New backends only need to implement the interface and register.
- **Config layering**: CLI flag > env `SAVEANY_*` (dots → underscores, e.g. `SAVEANY_TELEGRAM_TOKEN`) > TOML file (path or http(s) URL). `config.C()` returns a **copy** — never mutate it.
- **Storage registration (3 places)**: `pkg/enums/storage` ENUM comment (go-enum), `config/storage/factory.go::storageFactories` (config struct with `Validate()`), `storage/storage.go::storageConstructors` (implementation).
## Key Directories
| Path | Purpose |
|---|---|
| `cmd/` | CLI: `run` (main bot), `upload`, `watch` (standalone subcommands that do NOT run initAll), `geni18n` (i18n key generator) |
| `core/` | `Executable` interface, queue worker loop, hooks; `core/tasks/{tfile,batchtfile,directlinks,parsed,telegraph,transfer,ytdlp,aria2dl}` |
| `client/bot/` | gotgproto client, `handlers/` (all commands + message/callback handlers), `middleware/`, `client/user/` (userbot) |
| `storage/` | 8 backends + `storage.go` (interfaces/registry) + `load.go` (per-user storage resolution) |
| `parsers/` | `parsers.go` (registry), `js/` (Goja plugins, ghttp/playwright injection, build-tagged), `parsers/` (native: twitter, kemono) |
| `config/` | Viper setup, defaults, `storage/` per-type config structs |
| `database/` | GORM models (User/Dir/Rule/WatchChat), AutoMigrate, `syncUsers` |
| `pkg/` | `queue`, `taskevent`, `tcbdata` (callback data), `rule`, `enums/{tasktype,storage,ctxkey,fnamest}`, `storagetypes`, `tfile`, `parser` |
| `common/` | `tdler` (unified downloader), `utils/{tgutil,dlutil,ioutil,fsutil,strutil,tphutil,netutil}`, `i18n` (embedded locales), `cache` (ristretto) |
| `api/` | HTTP API + webhook (task factory with sink injection) |
| `docs/` | Hugo site (hugo-book theme, zh+en mirrored), separate go.mod |
| `plugins/` | JS parser examples + `README.md` (plugin author contract) |
## Development Commands
```bash
# Build (standard; CGO_ENABLED=0 for static)
CGO_ENABLED=0 go build -trimpath -o saveany-bot .
go run ./cmd
# Test — known failures: storage/telegram TestCreateSplitZip/TestExtractThumbFrame/TestGetVideoMetadata
# (need gitignored fixtures tests/testfile.dat, tests/testvideo; ffmpeg/ffprobe)
go test ./...
go test -race ./core/tasks/... ./storage/... ./pkg/queue/... ./common/...
go test -run TestQueueBasic ./pkg/queue
# Codegen — run after editing locale YAML or enum comments
go generate ./... # geni18n (i18nk keys) + go-enum (pkg/enums/*)
# go-enum is NOT in go.mod; install externally. geni18n runs via go run.
# Verify
go vet ./...
go fmt ./...
```
**Build variants** (Dockerfile.default/micro/pico): `-tags=no_jsparser,no_playwright,no_minio,no_bubbletea,sqlite_glebarez` — each has a `*_stub.go`/`*_glebarez.go` pairing; keep stubs in sync.
Docker: `docker build -t saveany-bot .`, `docker compose up -d` (host network, mounts `./data ./config.toml ./downloads ./cache`). CI (`.github/workflows/`) runs **no tests/lint** — only tag-triggered release/docker builds and docs deployment; run `go test ./...` manually before pushing.
## Code Conventions & Common Patterns
- **Imports**: stdlib → third-party → project-internal, blank-line separated. Aliases for clarity (`storconfig`, `storenum`).
- **Naming**: PascalCase exported, camelCase unexported, files `snake_case.go`; **not** ALL_CAPS constants.
- **Errors**: always wrap with `fmt.Errorf("context: %w", err)`; check with `errors.Is/As`; never ignore.
- **Logging**: `log.FromContext(ctx)` with prefixes (`logger.WithPrefix("component")`); never global logger when ctx is available.
- **Context values** (read from the passed ctx, never globals): `log.FromContext`, `tgutil.ExtFromContext` (Telegram ext — **required for message edits; if nil, edits are silently dropped**), `storage.FromContext`, `storagetypes.WithSourceCaption`, `ctxkey.ContentLength` / `ctxkey.OverwriteExisting`.
- **Progress rendering** (#228 convention): i18n templates declare styles with Telegram HTML (`<b>/<code>/<blockquote>/<i>`); dynamic data MUST go through `i18n.T(key, tgutil.EscapeHTMLTemplateData(data))` before `tgutil.RenderHTML`. Never interpolate user data raw, never render-then-substring-search.
- **Progress tracking**: each task package defines its own small `ProgressTracker` interface; optional `UploadProgressTracker` is probed via type assertion (skip if absent). Serialize state + message edits with a mutex; throttle edits (≥1s); aggregate per-item progress monotonically.
- **i18n**: only edit `common/i18n/locale/{zh-Hans,en}.yaml``go generate ./...` → use `i18nk.<Key>` constants. No raw strings in user-facing messages. zh-Hans and en must stay in sync.
- **Registration points** (never forget): new bot command → `client/bot/handlers/register.go::CommandHandlers` (auto-publishes /help menu); new task type → `pkg/enums/tasktype` + `core/tasks/<name>/` + `api/factory.go::CreateTask`; new storage → 3 places above + `docs/content/{en,zh}/deployment/configuration/storages.md`; new enum value → ENUM comment + `go generate`.
- **Concurrency**: `errgroup.WithContext` + `SetLimit(config.C().Workers)`, `atomic.Int64` counters, `sync.Once` for single-shot events, mutex around render state. No lock-in-callback (callbacks fire after unlock).
- **Cancellation**: queue tasks carry a `WithCancel`-derived ctx; check `ctx.Err()` in loops; classify with `errors.Is(err, context.Canceled)`.
- **JS plugins**: `registerParser({metadata, canHandle, parse})`, `version >= 1.0.0`; per-plugin goja VM is single-goroutine (reqCh buffer 10). Changing `pkg/parser.Item/Resource` JSON fields requires updating `plugins/README.md` and example plugins.
- **Message edits**: `ext.EditMessage(chatID, &tg.MessagesEditMessageRequest{...})`; cancel buttons via `tgutil.BuildCancelButton(taskID)`; callback payloads via `pkg/tcbdata` + `common/cache`.
## Important Files
- `main.go``//go:generate` for i18n keys
- `cmd/run.go` — startup sequence `Run/initAll/cleanCache` (cache cleanup on exit, `NoCleanCache` opt-out)
- `core/core.go` — worker loop, hooks, AddTask/CancelTask
- `pkg/queue/queue.go` — generic serial queue (cond/list; duplicate TaskID rejected)
- `storage/storage.go` — interfaces + registry + compile-time capability assertions
- `config/viper.go`, `config.example.toml` — config schema (authoritative field docs)
- `database/db.go` — GORM init, `GetDialect` (build-tag selectable SQLite driver)
- `client/bot/handlers/register.go` — handler dispatch order and CommandHandlers
- `common/tdler/dler.go` — unified download entry
- `core/tasks/batchtfile/item_progress.go` — per-item phase state machine (Downloading/Transferring/Uploading/Retrying/Confirming, FailureStage)
- `parsers/js/plugin.go` — Goja plugin runtime
- `.github/workflows/` — release/docker/docs (no test gate)
## Runtime/Tooling Preferences
- **Go 1.25+**: `t.Context()`, `sync.WaitGroup.Go`, `for range n` are available.
- **Runtime binaries**: ffmpeg/ffprobe (media processing/video split), yt-dlp (ytdlp tasks), aria2 optional; Playwright browsers install on demand to `./playwright` (`playwright.Install(chromium, ...)` at first `pw.get()`); Docker images: default has ffmpeg+yt-dlp, micro only curl, pico is scratch static.
- **No Makefile, no golangci.yml, no test/lint CI** — verification is manual (`go vet`, `go test`).
- **go-enum** required externally for enum generation; **geni18n** is in-repo.
- **Docs**: Hugo site in `docs/` (separate go.mod, hugo-book); edit `docs/content/{zh,en}/` — keep both languages mirrored. `docs/public/` is gitignored build output.
- **gitignored fixtures**: `storage/telegram/tests/` (missing — 3 tests fail locally), `data/`, `config.toml`, `playwright/`, `testplugins/`.
## Testing & QA
- Pure stdlib `testing` (no testify); table-driven (`[]struct{name...}` + `t.Run`) with `t.Fatalf` got/want assertions. Mock via hand-written interface impls or package-variable replacement (`runMediaTool` in `video_split_test.go`, restored with `t.Cleanup`); in-process services for HTTP (`httptest`), S3 (`gofakes3+s3mem`), WebDAV (`x/net/webdav`).
- **Locale-dependent tests**: pin with `i18n.Init("zh-Hans")` + `t.Cleanup(...)`.
- **Progress/HTML tests**: assert rendered text with `strings.Contains` AND entity counts (`tg.MessageEntityBold/Code/Blockquote/Italic`) — verify style injection stays escaped (`<b>A&B</b>` input must render as literal text).
- **Known failures**: `storage/telegram` `TestCreateSplitZip`, `TestExtractThumbFrame`, `TestGetVideoMetadata` need gitignored fixtures + real ffmpeg — skip with `-skip 'Test(CreateSplitZip|ExtractThumbFrame|GetVideoMetadata)$'`; `api/handlers_test.go` has one `t.Skip` (needs initialized core).
- **Coverage expectations**: pure logic gets table tests (parsers, URL/path utils, progress throttling, grouping); regressions get bug-scenario-named tests (`progress_regression_test.go`). Network/Telegram/Playwright must never be touched by tests.
- When a permanent feature/API change ships: update `config.example.toml` if config, `docs/` if user-facing, `plugins/README.md` if plugin contract, and i18n YAML + `go generate` for new strings.

View File

@@ -6,22 +6,34 @@ ARG BuildTime="Unknown"
WORKDIR /app
COPY . .
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/common.Version=${VERSION} \
-X github.com/krau/SaveAny-Bot/common.GitCommit=${GiTCommit} \
-X github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}" \
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 yt-dlp
WORKDIR /app
COPY --from=builder /app/saveany-bot .
COPY entrypoint.sh .
ENTRYPOINT ["/app/saveany-bot"]
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"]

41
Dockerfile.pico Normal file
View File

@@ -0,0 +1,41 @@
# 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
RUN apk add --no-cache ca-certificates
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 .
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
ENV SSL_CERT_DIR=/etc/ssl/certs
ENTRYPOINT ["/app/saveany-bot"]

126
README.md
View File

@@ -1,71 +1,97 @@
<div align="center">
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
**简体中文** | [English](README_EN.md)
**English** | [简体中文](./README_zh.md)
把 Telegram 的文件保存到各类存储端.
> **Save Any Telegram File to Anywhere 📂. Support restrict saving content and beyond telegram.**
> _就像 PikPak Bot 一样_
[![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>
## [部署](https://sabot.unv.app/deploy/)
## 🎯 Features
## [参与开发](https://sabot.unv.app/contribute/)
- Support documents / videos / photos / stickers… and even [Telegraph](https://telegra.ph/)
- Bypass "restrict saving content" media
- Batch download
- Streaming transfer
- Multi-user support
- Auto organize files based on storage rules
- Watch specified chats and auto-save messages, with filters
- Transfer files between different storage backends
- Integrate with yt-dlp to download and save media from 1000+ websites
- Aria2 integration to download files from URLs/magnets and save to storages
- Write JS parser plugins to save files from almost any website
- Storage backends:
- Alist
- S3
- WebDAV
- Local filesystem
- Rclone (via command line)
- Telegram (re-upload to specified chats)
---
## 📦 Quick Start
## 赞助
Create a `config.toml` file with the following content:
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
```toml
lang = "en" # Language setting, "en" for English
[telegram]
token = "" # Your bot token, obtained from @BotFather
[telegram.proxy]
# Enable proxy for Telegram
enable = false
url = "socks5://127.0.0.1:7890"
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
[[storages]]
name = "Local Disk"
type = "local"
enable = true
base_path = "./downloads"
- [爱发电](https://afdian.com/a/acherkrau)
[[users]]
id = 114514 # Your Telegram account id
storages = []
blacklist = true
```
## Contributors
Run Save Any Bot with Docker:
<!-- readme: contributors -start -->
<table>
<tbody>
<tr>
<td align="center">
<a href="https://github.com/krau">
<img src="https://avatars.githubusercontent.com/u/71133316?v=4" width="100;" alt="krau"/>
<br />
<sub><b>Krau</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/TG-Twilight">
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
<br />
<sub><b>Simon Twilight</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ysicing">
<img src="https://avatars.githubusercontent.com/u/8605565?v=4" width="100;" alt="ysicing"/>
<br />
<sub><b>缘生</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ahcorn">
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="ahcorn"/>
<br />
<sub><b>安和</b></sub>
</a>
</td>
</tr>
<tbody>
</table>
<!-- readme: contributors -end -->
```bash
docker run -d --name saveany-bot \
-v ./config.toml:/app/config.toml \
-v ./downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
## Thanks
Please [**read the docs**](https://sabot.unv.app/en/) for more configuration options and usage.
## Sponsors
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
If this project is helpful to you, consider sponsoring me via:
- [Afdian](https://afdian.com/a/unvapp)
## Thanks To
- [gotd](https://github.com/gotd/td)
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
- [gotgproto](https://github.com/celestix/gotgproto)
- All the dependencies
- [tdl](https://github.com/iyear/tdl)
- All the dependencies, contributors, sponsors and users.
## Contact
- [![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)

View File

@@ -1,108 +0,0 @@
<div align="center">
# <img src="docs/logo.jpg" width="45" align="center"> Save Any Bot
[简体中文](README.md) | **English**
Save Telegram files to various storage endpoints.
> _Just like PikPak Bot_
</div>
## Deployment
### Deploy from Binary
Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page.
Create a `config.toml` file in the extracted directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
Run:
```bash
chmod +x saveany-bot
./saveany-bot
```
#### Add as systemd Service
Create file `/etc/systemd/system/saveany-bot.service` and write the following content:
```
[Unit]
Description=SaveAnyBot
After=systemd-user-sessions.service
[Service]
Type=simple
WorkingDirectory=/yourpath/
ExecStart=/yourpath/saveany-bot
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
Enable auto-start and start the service:
```bash
systemctl enable --now saveany-bot
```
### Deploy with Docker
#### Docker Compose
Download [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file and create a `config.toml` file in the same directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration.
Run:
```bash
docker compose up -d
```
#### Docker
```shell
docker run -d --name saveany-bot \
-v /path/to/config.toml:/app/config.toml \
-v /path/to/downloads:/app/downloads \
ghcr.io/krau/saveany-bot:latest
```
## Update
Use `upgrade` or `up` command to upgrade to the latest version:
```bash
./saveany-bot upgrade
```
If deployed with Docker, use the following commands to update:
```bash
docker pull ghcr.io/krau/saveany-bot:latest
docker restart saveany-bot
```
## Usage
Send (forward) files to the Bot and follow the prompts.
---
## Sponsors
This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport).
You can consider sponsoring me if this project helps you:
- [Afdian](https://afdian.com/a/acherkrau)
## Thanks
- [gotd](https://github.com/gotd/td)
- [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot)
- [gotgproto](https://github.com/celestix/gotgproto)
- All the dependencies

94
README_zh.md Normal file
View File

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

42
api/auth.go Normal file
View File

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

382
api/factory.go Normal file
View File

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

242
api/handlers.go Normal file
View File

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

695
api/handlers_test.go Normal file
View File

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

200
api/progress.go Normal file
View File

@@ -0,0 +1,200 @@
package api
import (
"sync"
"time"
"github.com/krau/SaveAny-Bot/pkg/taskevent"
)
// TaskProgressInfo stores the progress of an API-submitted task. All fields are
// guarded by mu. It implements taskevent.Sink so the task layer can update it
// without knowing about the API.
type TaskProgressInfo struct {
mu sync.Mutex
TaskID string
Type string
Status TaskStatus
Title string
TotalBytes int64
DownloadedBytes int64
TotalFiles int
DownloadedFiles int
Storage string
Path string
Error string
CreatedAt time.Time
UpdatedAt time.Time
StartedAt time.Time
Webhook string
webhookNotified bool
}
// progressStore holds all API tasks. Entries are removed a fixed duration after
// they reach a terminal state to bound memory usage.
type progressStore struct {
mu sync.RWMutex
tasks map[string]*TaskProgressInfo
retention time.Duration
}
var store = &progressStore{
tasks: make(map[string]*TaskProgressInfo),
retention: 24 * time.Hour,
}
// RegisterTask registers a new API task and returns its progress info.
func RegisterTask(taskID, taskType, storage, path, title, webhook string) *TaskProgressInfo {
info := &TaskProgressInfo{
TaskID: taskID,
Type: taskType,
Status: TaskStatusQueued,
Title: title,
Storage: storage,
Path: path,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
Webhook: webhook,
}
store.mu.Lock()
store.tasks[taskID] = info
store.mu.Unlock()
return info
}
// GetTask returns the progress info for a task.
func GetTask(taskID string) (*TaskProgressInfo, bool) {
store.mu.RLock()
defer store.mu.RUnlock()
info, ok := store.tasks[taskID]
return info, ok
}
// GetAllTasks returns all tracked tasks.
func GetAllTasks() []*TaskProgressInfo {
store.mu.RLock()
defer store.mu.RUnlock()
tasks := make([]*TaskProgressInfo, 0, len(store.tasks))
for _, info := range store.tasks {
tasks = append(tasks, info)
}
return tasks
}
// DeleteTask removes a task record.
func DeleteTask(taskID string) {
store.mu.Lock()
defer store.mu.Unlock()
delete(store.tasks, taskID)
}
// CleanupExpired removes tasks that reached a terminal state more than the
// store's retention duration ago. It is safe to call periodically.
func CleanupExpired() {
now := time.Now()
store.mu.Lock()
defer store.mu.Unlock()
for id, info := range store.tasks {
info.mu.Lock()
terminal := info.Status == TaskStatusCompleted || info.Status == TaskStatusFailed || info.Status == TaskStatusCancelled
stale := terminal && now.Sub(info.UpdatedAt) > store.retention
info.mu.Unlock()
if stale {
delete(store.tasks, id)
}
}
}
// StartCleanupLoop runs CleanupExpired on a fixed interval until ctx is done.
// It should be started once during API server initialization.
func StartCleanupLoop(ctx interface{ Done() <-chan struct{} }) {
go func() {
ticker := time.NewTicker(10 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
CleanupExpired()
}
}
}()
}
// UpdateStatus sets the task status.
func (t *TaskProgressInfo) UpdateStatus(status TaskStatus) {
t.mu.Lock()
t.Status = status
t.UpdatedAt = time.Now()
if status == TaskStatusRunning && t.StartedAt.IsZero() {
t.StartedAt = t.UpdatedAt
}
t.mu.Unlock()
}
// SetError marks the task failed with an error message.
func (t *TaskProgressInfo) SetError(err string) {
t.mu.Lock()
t.Error = err
t.Status = TaskStatusFailed
t.UpdatedAt = time.Now()
t.mu.Unlock()
}
// snapshot returns a point-in-time copy of the fields needed to render a
// response, so callers never touch the mutex directly.
func (t *TaskProgressInfo) snapshot() (status TaskStatus, total, downloaded int64, totalFiles, downloadedFiles int, startedAt time.Time, err string, updatedAt time.Time) {
t.mu.Lock()
defer t.mu.Unlock()
return t.Status, t.TotalBytes, t.DownloadedBytes, t.TotalFiles, t.DownloadedFiles, t.StartedAt, t.Error, t.UpdatedAt
}
// Emit implements taskevent.Sink. It translates task lifecycle events into
// status/progress updates and fires the webhook on terminal transitions.
func (t *TaskProgressInfo) Emit(e taskevent.Event) {
t.mu.Lock()
switch e.Phase {
case taskevent.PhaseStart:
t.Status = TaskStatusRunning
if t.StartedAt.IsZero() {
t.StartedAt = time.Now()
}
if e.TotalBytes > 0 {
t.TotalBytes = e.TotalBytes
}
case taskevent.PhaseProgress:
t.Status = TaskStatusRunning
if e.TotalBytes > 0 {
t.TotalBytes = e.TotalBytes
}
t.DownloadedBytes = e.DownloadedBytes
if e.TotalFiles > 0 {
t.TotalFiles = e.TotalFiles
}
if e.DownloadedFiles > 0 {
t.DownloadedFiles = e.DownloadedFiles
}
case taskevent.PhaseDone:
if e.Err != nil {
t.Status = TaskStatusFailed
t.Error = e.Err.Error()
} else {
t.Status = TaskStatusCompleted
}
}
t.UpdatedAt = time.Now()
notify := t.Webhook != "" && !t.webhookNotified && (t.Status == TaskStatusCompleted || t.Status == TaskStatusFailed)
if notify {
t.webhookNotified = true
}
t.mu.Unlock()
if notify {
payload := CreateWebhookPayload(t.TaskID, t.Type, t.Status, t.Storage, t.Path, e.Err)
SendWebhook(nil, payload)
}
}

177
api/server.go Normal file
View File

@@ -0,0 +1,177 @@
package api
import (
"context"
"fmt"
"net"
"net/http"
"time"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
)
// Server API 服务器
type Server struct {
httpServer *http.Server
factory *TaskFactory
}
// NewServer 创建新的 API 服务器
func NewServer(ctx context.Context) *Server {
cfg := config.C().API
factory := NewTaskFactory(ctx)
handlers := NewHandlers(factory)
// 设置路由
mux := http.NewServeMux()
// 健康检查
mux.HandleFunc("/health", handlers.HealthCheckHandler)
// API v1 路由
mux.HandleFunc("/api/v1/tasks", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handlers.ListTasksHandler(w, r)
case http.MethodPost:
handlers.CreateTaskHandler(w, r)
default:
MethodNotAllowedHandler(w, r)
}
})
mux.HandleFunc("/api/v1/tasks/", func(w http.ResponseWriter, r *http.Request) {
// 根据方法和路径分发
switch r.Method {
case http.MethodGet:
handlers.GetTaskHandler(w, r)
case http.MethodDelete:
handlers.CancelTaskHandler(w, r)
default:
MethodNotAllowedHandler(w, r)
}
})
mux.HandleFunc("/api/v1/storages", handlers.ListStoragesHandler)
mux.HandleFunc("/api/v1/task-types", handlers.GetTaskTypesHandler)
// 404 处理
mux.HandleFunc("/", NotFoundHandler)
// Apply middleware chain.
var handler http.Handler = mux
// Apply auth middleware when a token is configured.
token := cfg.Token
if token != "" {
handler = AuthMiddleware()(handler)
}
// Add logging middleware.
handler = loggingMiddleware(handler)
// Add recovery middleware.
handler = recoveryMiddleware(handler)
return &Server{
httpServer: &http.Server{
Addr: fmt.Sprintf("%s:%d", cfg.Host, cfg.Port),
Handler: handler,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
},
factory: factory,
}
}
// Start 启动服务器
func (s *Server) Start(ctx context.Context) error {
logger := log.FromContext(ctx).With("module", "api")
logger.Infof("Starting API server on %s", s.httpServer.Addr)
// Bind synchronously so listen failures are returned to the caller.
ln, err := net.Listen("tcp", s.httpServer.Addr)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", s.httpServer.Addr, err)
}
// 在 goroutine 中启动服务器
go func() {
if err := s.httpServer.Serve(ln); err != nil && err != http.ErrServerClosed {
logger.Errorf("API server error: %v", err)
}
}()
// 监听 context 取消
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.httpServer.Shutdown(shutdownCtx); err != nil {
logger.Errorf("API server shutdown error: %v", err)
}
}()
return nil
}
// loggingMiddleware 日志中间件
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
// 包装 ResponseWriter 以获取状态码
wrapped := &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
next.ServeHTTP(wrapped, r)
log.Infof("%s %s %d %s", r.Method, r.URL.Path, wrapped.statusCode, time.Since(start))
})
}
// recoveryMiddleware 恢复中间件
func recoveryMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Errorf("Panic recovered: %v", err)
WriteError(w, http.StatusInternalServerError, "internal_error", "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
// responseWriter 包装 http.ResponseWriter 以捕获状态码
type responseWriter struct {
http.ResponseWriter
statusCode int
}
func (rw *responseWriter) WriteHeader(code int) {
rw.statusCode = code
rw.ResponseWriter.WriteHeader(code)
}
// Start initializes and starts the API server. It refuses to start without a
// token, since an open download proxy is a security risk.
func Start(ctx context.Context) error {
cfg := config.C().API
if !cfg.Enable {
return nil
}
if cfg.Token == "" {
return fmt.Errorf("API server is enabled but no token is set; refusing to start insecurely")
}
server := NewServer(ctx)
if err := server.Start(ctx); err != nil {
return err
}
StartCleanupLoop(ctx)
return nil
}

283
api/tgfiles.go Normal file
View File

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

80
api/tphpics.go Normal file
View File

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

163
api/types.go Normal file
View File

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

114
api/webhook.go Normal file
View File

@@ -0,0 +1,114 @@
package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"time"
"github.com/charmbracelet/log"
)
// webhookClient Webhook 客户端
var webhookClient = &http.Client{
Timeout: 30 * time.Second,
}
// SendWebhook 发送 Webhook 回调
func SendWebhook(ctx context.Context, payload *WebhookPayload) {
if payload == nil || payload.TaskID == "" {
return
}
// 获取任务信息以获取 webhook URL
info, ok := GetTask(payload.TaskID)
if !ok || info.Webhook == "" {
return
}
webhookURL := info.Webhook
// Async send with retries.
go func() {
var logger *log.Logger
if ctx != nil {
logger = log.FromContext(ctx).With("task_id", payload.TaskID)
} else {
logger = log.Default().With("task_id", payload.TaskID)
}
if ctx == nil {
ctx = context.Background()
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
logger.Errorf("Failed to marshal webhook payload: %v", err)
return
}
// 重试 3 次, 指数退避 (100ms/400ms/1.6s)
const maxAttempts = 3
const requestTimeout = 30 * time.Second
backoff := 100 * time.Millisecond
for i := range maxAttempts {
reqCtx, cancel := context.WithTimeout(ctx, requestTimeout)
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, webhookURL, bytes.NewBuffer(payloadBytes))
if err != nil {
cancel()
logger.Errorf("Failed to create webhook request: %v", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "SaveAny-Bot/1.0")
resp, err := webhookClient.Do(req)
cancel()
if err != nil {
logger.Warnf("Webhook request failed (attempt %d/%d): %v", i+1, maxAttempts, err)
if i < maxAttempts-1 {
time.Sleep(backoff)
}
backoff *= 4
continue
}
resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
logger.Debugf("Webhook sent successfully: %s", webhookURL)
return
}
logger.Warnf("Webhook returned non-2xx status (attempt %d/%d): %d", i+1, maxAttempts, resp.StatusCode)
if i < maxAttempts-1 {
time.Sleep(backoff)
}
backoff *= 4
}
logger.Errorf("Failed to send webhook after %d attempts", maxAttempts)
}()
}
// CreateWebhookPayload creates a Webhook payload.
func CreateWebhookPayload(taskID string, taskType string, status TaskStatus, storage, path string, err error) *WebhookPayload {
payload := &WebhookPayload{
TaskID: taskID,
Type: taskType,
Status: status,
Storage: storage,
Path: path,
}
if status == TaskStatusCompleted || status == TaskStatusFailed {
now := time.Now()
payload.CompletedAt = &now
}
if err != nil {
payload.Error = err.Error()
}
return payload
}

View File

@@ -1,101 +0,0 @@
package bot
import (
"context"
"net/url"
"time"
"github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/sessionMaker"
"github.com/glebarez/sqlite"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/net/proxy"
)
var Client *gotgproto.Client
func newProxyDialer(proxyUrl string) (proxy.Dialer, error) {
url, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
return proxy.FromURL(url, proxy.Direct)
}
func Init() {
common.Log.Info("初始化 Telegram 客户端...")
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Cfg.Telegram.Timeout)*time.Second)
defer cancel()
go InitTelegraphClient()
resultChan := make(chan struct {
client *gotgproto.Client
err error
})
go func() {
var resolver dcs.Resolver
if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" {
dialer, err := newProxyDialer(config.Cfg.Telegram.Proxy.URL)
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
} else {
resolver = dcs.DefaultResolver()
}
client, err := gotgproto.NewClient(config.Cfg.Telegram.AppID,
config.Cfg.Telegram.AppHash,
gotgproto.ClientTypeBot(config.Cfg.Telegram.Token),
&gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(sqlite.Open(config.Cfg.DB.Session)),
DisableCopyright: true,
Middlewares: FloodWaitMiddleware(),
Resolver: resolver,
MaxRetries: config.Cfg.Telegram.RpcRetry,
},
)
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
_, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
Scope: &tg.BotCommandScopeDefault{},
Commands: []tg.BotCommand{
{Command: "start", Description: "开始使用"},
{Command: "help", Description: "显示帮助"},
{Command: "silent", Description: "开启/关闭静默模式"},
{Command: "storage", Description: "设置默认存储端"},
{Command: "save", Description: "保存所回复的文件"},
{Command: "dir", Description: "管理存储文件夹"},
{Command: "rule", Description: "管理规则"},
},
})
resultChan <- struct {
client *gotgproto.Client
err error
}{client, err}
}()
select {
case <-ctx.Done():
common.Log.Panic("初始化客户端失败: 超时")
case result := <-resultChan:
if result.err != nil {
common.Log.Panicf("初始化客户端失败: %s", result.err)
}
Client = result.client
RegisterHandlers(Client.Dispatcher)
common.Log.Info("客户端初始化完成")
}
}

View File

@@ -1,216 +0,0 @@
package bot
import (
"errors"
"fmt"
"path"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/types"
"github.com/krau/SaveAny-Bot/userclient"
"gorm.io/gorm"
)
func AddToQueue(ctx *ext.Context, update *ext.Update) error {
if !slice.Contain(config.Cfg.GetUsersID(), update.CallbackQuery.UserID) {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "你没有权限",
CacheTime: 5,
})
return dispatcher.EndGroups
}
args := strings.Split(string(update.CallbackQuery.Data), " ")
addToDir := args[0] == "add_to_dir" // 已经选择了路径
cbDataId, _ := strconv.Atoi(args[1])
cbData, err := dao.GetCallbackData(uint(cbDataId))
if err != nil {
common.Log.Errorf("获取回调数据失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取回调数据失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
data := strings.Split(cbData, " ")
fileChatID, _ := strconv.Atoi(data[0])
fileMessageID, _ := strconv.Atoi(data[1])
storageName := data[2]
dirIdInt, _ := strconv.Atoi(data[3])
dirId := uint(dirIdInt)
user, err := dao.GetUserByChatID(update.CallbackQuery.UserID)
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取用户失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if !addToDir {
dirs, err := dao.GetDirsByUserIDAndStorageName(user.ID, storageName)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取路径失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if len(dirs) != 0 {
markup, err := getSelectDirMarkup(fileChatID, fileMessageID, storageName, dirs)
if err != nil {
common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取路径失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "请选择要保存到的路径",
ReplyMarkup: markup,
})
if err != nil {
common.Log.Errorf("编辑消息失败: %s", err)
}
return dispatcher.EndGroups
}
}
common.Log.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName)
record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID)
if err != nil {
common.Log.Errorf("获取记录失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "查询记录失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if update.CallbackQuery.MsgID != record.ReplyMessageID {
record.ReplyMessageID = update.CallbackQuery.MsgID
if _, err := dao.SaveReceivedFile(record); err != nil {
common.Log.Errorf("更新记录失败: %s", err)
}
}
var dir *dao.Dir
if addToDir && dirId != 0 {
dir, err = dao.GetDirByID(dirId)
if err != nil {
common.Log.Errorf("获取路径失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取路径失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
}
var task types.Task
if record.IsTelegraph {
task = types.Task{
Ctx: ctx,
Status: types.Pending,
IsTelegraph: true,
TelegraphURL: record.TelegraphURL,
StorageName: storageName,
FileChatID: record.ChatID,
FileMessageID: record.MessageID,
ReplyMessageID: record.ReplyMessageID,
ReplyChatID: record.ReplyChatID,
UserID: update.GetUserChat().GetID(),
}
if dir != nil {
task.StoragePath = path.Join(dir.Path, record.FileName)
}
} else {
var file *types.File
var err error
if record.UseUserClient && userclient.UC != nil {
uctx := userclient.UC.CreateContext()
file, err = FileFromMessage(uctx, record.ChatID, record.MessageID, record.FileName)
} else {
file, err = FileFromMessage(ctx, record.ChatID, record.MessageID, record.FileName)
}
if err != nil {
common.Log.Errorf("获取消息中的文件失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: fmt.Sprintf("获取消息中的文件失败: %s", err),
CacheTime: 5,
})
return dispatcher.EndGroups
}
task = types.Task{
Ctx: ctx,
Status: types.Pending,
UseUserClient: record.UseUserClient,
FileDBID: record.ID,
File: file,
StorageName: storageName,
FileChatID: record.ChatID,
ReplyMessageID: record.ReplyMessageID,
FileMessageID: record.MessageID,
ReplyChatID: record.ReplyChatID,
UserID: update.GetUserChat().GetID(),
}
if dir != nil {
task.StoragePath = path.Join(dir.Path, file.FileName)
}
}
queue.AddTask(&task)
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", record.FileName, queue.Len())
if err := styling.Perform(&entityBuilder,
styling.Plain("已添加到任务队列\n文件名: "),
styling.Code(record.FileName),
styling.Plain("\n当前排队任务数: "),
styling.Bold(strconv.Itoa(queue.Len())),
); err != nil {
common.Log.Errorf("Failed to build entity: %s", err)
} else {
text, entities = entityBuilder.Complete()
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ID: record.ReplyMessageID,
})
return dispatcher.EndGroups
}

View File

@@ -1,27 +0,0 @@
package bot
import (
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/queue"
)
func cancelTask(ctx *ext.Context, update *ext.Update) error {
key := strings.Split(string(update.CallbackQuery.Data), " ")[1]
ok := queue.CancelTask(key)
if ok {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Message: "任务已取消",
})
return dispatcher.EndGroups
}
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Message: "任务取消失败",
})
return dispatcher.EndGroups
}

View File

@@ -1,110 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/storage"
)
func sendDirHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error {
dirs, err := dao.GetUserDirsByChatID(userChatID)
if err != nil {
common.Log.Errorf("获取用户路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户路径失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextStyledTextArray(
[]styling.StyledTextOption{
styling.Bold("使用方法: /dir <操作> <参数...>"),
styling.Plain("\n\n可用操作:\n"),
styling.Code("add"),
styling.Plain(" <存储名> <路径> - 添加路径\n"),
styling.Code("del"),
styling.Plain(" <路径ID> - 删除路径\n"),
styling.Plain("\n添加路径示例:\n"),
styling.Code("/dir add local1 path/to/dir"),
styling.Plain("\n\n删除路径示例:\n"),
styling.Code("/dir del 3"),
styling.Plain("\n\n当前已添加的路径:\n"),
styling.Blockquote(func() string {
var sb strings.Builder
for _, dir := range dirs {
sb.WriteString(fmt.Sprintf("%d: ", dir.ID))
sb.WriteString(dir.StorageName)
sb.WriteString(" - ")
sb.WriteString(dir.Path)
sb.WriteString("\n")
}
return sb.String()
}(), true),
},
), nil)
return dispatcher.EndGroups
}
func dirCmd(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
switch args[1] {
case "add":
// /dir add local1 path/to/dir
if len(args) < 4 {
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
}
return addDir(ctx, update, user, args[2], args[3])
case "del":
// /dir del 3
if len(args) < 3 {
return sendDirHelp(ctx, update, update.GetUserChat().GetID())
}
dirID, err := strconv.Atoi(args[2])
if err != nil {
ctx.Reply(update, ext.ReplyTextString("路径ID无效"), nil)
return dispatcher.EndGroups
}
return delDir(ctx, update, dirID)
default:
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
return dispatcher.EndGroups
}
}
func addDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, path string) error {
if _, err := storage.GetStorageByUserIDAndName(user.ChatID, storageName); err != nil {
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
return dispatcher.EndGroups
}
if err := dao.CreateDirForUser(user.ID, storageName, path); err != nil {
common.Log.Errorf("创建路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建路径失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("路径添加成功"), nil)
return dispatcher.EndGroups
}
func delDir(ctx *ext.Context, update *ext.Update, dirID int) error {
if err := dao.DeleteDirByID(uint(dirID)); err != nil {
common.Log.Errorf("删除路径失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除路径失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("路径删除成功"), nil)
return dispatcher.EndGroups
}

View File

@@ -1,86 +0,0 @@
package bot
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/types"
)
func handleFileMessage(ctx *ext.Context, update *ext.Update) error {
common.Log.Trace("Got media: ", update.EffectiveMessage.Media.TypeName())
supported, err := supportedMediaFilter(update.EffectiveMessage.Message)
if err != nil {
return err
}
if !supported {
return dispatcher.EndGroups
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
// storages := storage.GetUserStorages(user.ChatID)
// if len(storages) == 0 {
// ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
// return dispatcher.EndGroups
// }
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
media := update.EffectiveMessage.Media
file, err := FileFromMedia(media, "")
if err != nil {
common.Log.Errorf("获取文件失败: %s", err)
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil)
return dispatcher.EndGroups
}
if file.FileName == "" {
file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file)
}
record, err := dao.SaveReceivedFile(&dao.ReceivedFile{
Processing: false,
FileName: file.FileName,
ChatID: update.EffectiveChat().GetID(),
MessageID: update.EffectiveMessage.ID,
ReplyMessageID: msg.ID,
ReplyChatID: update.GetUserChat().GetID(),
})
if err != nil {
common.Log.Errorf("添加接收的文件失败: %s", err)
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("添加接收的文件失败: %s", err),
ID: msg.ID,
}); err != nil {
common.Log.Errorf("编辑消息失败: %s", err)
}
return dispatcher.EndGroups
}
if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID)
}
return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
File: file,
StorageName: user.DefaultStorage,
FileChatID: update.EffectiveChat().GetID(),
ReplyMessageID: msg.ID,
ReplyChatID: update.GetUserChat().GetID(),
FileMessageID: update.EffectiveMessage.ID,
UserID: user.ChatID,
})
}

View File

@@ -1,235 +0,0 @@
package bot
import (
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/types"
"github.com/krau/SaveAny-Bot/userclient"
)
var (
linkRegexString = `https?://t\.me/(?:c/\d+|[a-zA-Z0-9_]+)/\d+(?:\?[^\s]*)?`
linkRegex = regexp.MustCompile(linkRegexString)
)
type parseResult struct {
ChatID int64
MessageID int
Files []*types.File
UserClient bool
}
func parseLink(ctx *ext.Context, link string) (*parseResult, error) {
u, err := url.Parse(link)
if err != nil {
return nil, fmt.Errorf("无法解析链接: %s", err)
}
strSlice := strings.Split(u.Path, "/")
if len(strSlice) < 3 {
return nil, fmt.Errorf("链接格式错误: %s", link)
}
messageID, err := strconv.Atoi(strSlice[len(strSlice)-1])
if err != nil {
return nil, fmt.Errorf("无法解析消息 ID: %s", err)
}
var chatID int64
if len(strSlice) == 3 {
chatUsername := strSlice[1]
peer := ctx.PeerStorage.GetPeerByUsername(chatUsername)
if peer != nil {
chatID = peer.ID
} else {
linkChat, err := ctx.ResolveUsername(chatUsername)
if err != nil {
return nil, fmt.Errorf("解析用户名失败: %s", err)
}
if linkChat == nil {
return nil, fmt.Errorf("找不到该聊天: %s", chatUsername)
}
chatID = linkChat.GetID()
}
} else if len(strSlice) == 4 {
chatIDInt, err := strconv.Atoi(strSlice[2])
if err != nil {
return nil, fmt.Errorf("无法解析 Chat ID: %s", err)
}
chatID = int64(chatIDInt)
} else {
return nil, errors.New("链接格式不正确,无法解析 Chat ID")
}
if chatID == 0 || messageID == 0 {
return nil, fmt.Errorf("链接中缺少 Chat ID 或 Message ID: %s", link)
}
msg, _, err := tryFetchMessage(ctx, chatID, messageID)
if err != nil {
return nil, fmt.Errorf("获取消息失败: %s", err)
}
mediaGroup, isGroup := msg.GetGroupedID()
if u.Query().Has("single") || !isGroup || (mediaGroup == 0) || userclient.UC == nil {
file, useUserClient, err := tryFetchFileFromMessage(ctx, chatID, messageID, "")
if err != nil {
return nil, fmt.Errorf("获取文件失败: %s", err)
}
if file.FileName == "" {
file.FileName = GenFileNameFromMessage(*msg, file)
}
return &parseResult{
ChatID: chatID,
MessageID: messageID,
Files: []*types.File{file},
UserClient: useUserClient,
}, nil
}
groupMessages, isUserClient, err := tryGetMediaGroup(chatID, messageID, mediaGroup)
if err != nil {
return nil, fmt.Errorf("获取媒体组消息失败: %s", err)
}
var files []*types.File
for _, groupMsg := range groupMessages {
file, err := FileFromMedia(groupMsg.Media, "")
if err != nil {
return nil, fmt.Errorf("获取媒体文件失败: %s", err)
}
if file.FileName == "" {
file.FileName = GenFileNameFromMessage(*groupMsg, file)
}
files = append(files, file)
}
return &parseResult{
ChatID: chatID,
MessageID: messageID,
Files: files,
UserClient: isUserClient,
}, nil
}
// use passed ctx client to fetch file from message,
//
// if failed try using userclient
func tryFetchFileFromMessage(ctx *ext.Context, chatID int64, messageID int, fileName string) (*types.File, bool, error) {
file, err := FileFromMessage(ctx, chatID, messageID, fileName)
if err == nil {
return file, false, nil
}
if (strings.Contains(err.Error(), "peer not found") || strings.Contains(err.Error(), "unexpected message type")) && userclient.UC != nil {
common.Log.Warnf("无法获取文件 %d:%d, 尝试使用 userbot: %s", chatID, messageID, err)
uctx := userclient.GetCtx()
peer := uctx.PeerStorage.GetInputPeerById(chatID)
if peer == nil {
return nil, true, fmt.Errorf("failed to get peer for chat %d: %w", chatID, err)
}
msg, err := GetSingleHistoryMessage(uctx, uctx.Raw, peer, messageID)
if err != nil {
return nil, true, err
}
file, err = FileFromMedia(msg.Media, fileName)
if err != nil {
return nil, true, fmt.Errorf("failed to get file from userbot message %d:%d: %w", chatID, messageID, err)
}
return file, true, nil
}
return nil, false, err
}
func tryGetMediaGroup(chatID int64, messageID int, mediaGroupID int64) ([]*tg.Message, bool, error) {
if userclient.UC != nil {
uctx := userclient.GetCtx()
messages, err := GetMediaGroup(uctx, chatID, messageID, mediaGroupID)
if err != nil {
return nil, true, fmt.Errorf("failed to get media group from userbot: %w", err)
}
return messages, true, nil
}
return nil, false, errors.New("userclient is not available, cannot fetch media group")
}
func tryFetchMessage(ctx *ext.Context, chatID int64, messageID int) (*tg.Message, bool, error) {
msg, err := GetTGMessage(ctx, chatID, messageID)
if err == nil {
return msg, false, nil
}
if userclient.UC != nil && (strings.Contains(err.Error(), "peer not found") || strings.Contains(err.Error(), "unexpected message type")) {
common.Log.Warnf("无法获取消息 %d:%d, 尝试使用 userbot: %s", chatID, messageID, err)
uctx := userclient.GetCtx()
msg, err := GetTGMessage(uctx, chatID, messageID)
if err == nil {
return msg, true, nil
}
return nil, true, fmt.Errorf("获取消息失败: %w", err)
}
return nil, false, fmt.Errorf("获取消息失败: %s", err)
}
func handleLinkMessage(ctx *ext.Context, update *ext.Update) error {
common.Log.Trace("Got link message")
link := linkRegex.FindString(update.EffectiveMessage.Text)
if link == "" {
return dispatcher.ContinueGroups
}
result, err := parseLink(ctx, link)
if err != nil {
common.Log.Errorf("解析链接失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("解析链接失败"), nil)
return dispatcher.EndGroups
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
// TODO: handle group files
receivedFile := &dao.ReceivedFile{
Processing: false,
FileName: result.Files[0].FileName,
ChatID: result.ChatID,
MessageID: result.MessageID,
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
UseUserClient: result.UserClient,
}
record, err := dao.SaveReceivedFile(receivedFile)
if err != nil {
common.Log.Errorf("保存接收的文件失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法保存文件: " + err.Error(),
ID: replied.ID,
})
return dispatcher.EndGroups
}
file := result.Files[0]
if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file.FileName, result.ChatID, result.MessageID, replied.ID)
}
return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
UseUserClient: result.UserClient,
File: file,
StorageName: user.DefaultStorage,
UserID: user.ChatID,
FileChatID: result.ChatID,
FileMessageID: result.MessageID,
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
})
}

View File

@@ -1,141 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/types"
)
func sendRuleHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error {
user, err := dao.GetUserByChatID(userChatID)
if err != nil {
common.Log.Errorf("获取用户规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextStyledTextArray(
[]styling.StyledTextOption{
styling.Bold("使用方法: /rule <操作> <参数...>"),
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[user.ApplyRule])),
styling.Plain("\n\n可用操作:\n"),
styling.Code("switch"),
styling.Plain(" - 开关规则模式\n"),
styling.Code("add"),
styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"),
styling.Code("del"),
styling.Plain(" <规则ID> - 删除规则\n"),
styling.Plain("\n当前已添加的规则:\n"),
styling.Blockquote(func() string {
var sb strings.Builder
for _, rule := range user.Rules {
ruleText := fmt.Sprintf("%s %s %s %s", rule.Type, rule.Data, rule.StorageName, rule.DirPath)
sb.WriteString(fmt.Sprintf("%d: %s\n", rule.ID, ruleText))
}
return sb.String()
}(), true),
},
), nil)
return dispatcher.EndGroups
}
func ruleCmd(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
return sendRuleHelp(ctx, update, update.GetUserChat().GetID())
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
switch args[1] {
case "switch":
// /rule switch
return switchApplyRule(ctx, update, user)
case "add":
// /rule add <type> <data> <storage> <dirpath>
if len(args) < 6 {
return sendRuleHelp(ctx, update, user.ChatID)
}
return addRule(ctx, update, user, args)
case "del":
// /rule del <id>
if len(args) < 3 {
return sendRuleHelp(ctx, update, user.ChatID)
}
ruleID := args[2]
id, err := strconv.Atoi(ruleID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
return dispatcher.EndGroups
}
if err := dao.DeleteRule(uint(id)); err != nil {
common.Log.Errorf("删除规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
return dispatcher.EndGroups
default:
return sendRuleHelp(ctx, update, user.ChatID)
}
}
func switchApplyRule(ctx *ext.Context, update *ext.Update, user *dao.User) error {
applyRule := !user.ApplyRule
if err := dao.UpdateUserApplyRule(user.ChatID, applyRule); err != nil {
common.Log.Errorf("更新用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
return dispatcher.EndGroups
}
if applyRule {
ctx.Reply(update, ext.ReplyTextString("已启用规则模式"), nil)
} else {
ctx.Reply(update, ext.ReplyTextString("已禁用规则模式"), nil)
}
return dispatcher.EndGroups
}
func addRule(ctx *ext.Context, update *ext.Update, user *dao.User, args []string) error {
// /rule add <type> <data> <storage> <dirpath>
ruleType := args[2]
ruleData := args[3]
storageName := args[4]
dirPath := args[5]
if !slice.Contain(types.RuleTypes, types.RuleType(ruleType)) {
var ruleTypesStylingArray []styling.StyledTextOption
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Bold("无效的规则类型, 可用类型:\n"))
for i, ruleType := range types.RuleTypes {
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Code(string(ruleType)))
if i != len(types.RuleTypes)-1 {
ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Plain(", "))
}
}
ctx.Reply(update, ext.ReplyTextStyledTextArray(ruleTypesStylingArray), nil)
return dispatcher.EndGroups
}
rule := &dao.Rule{
Type: ruleType,
Data: ruleData,
StorageName: storageName,
DirPath: dirPath,
UserID: user.ID,
}
if err := dao.CreateRule(rule); err != nil {
common.Log.Errorf("添加规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("添加规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("添加规则成功"), nil)
return dispatcher.EndGroups
}

View File

@@ -1,266 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
func sendSaveHelp(ctx *ext.Context, update *ext.Update) error {
helpText := `
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @moreacg 114-514
`
ctx.Reply(update, ext.ReplyTextString(helpText), nil)
return dispatcher.EndGroups
}
func saveCmd(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) >= 3 {
return handleBatchSave(ctx, update, args[1:])
}
replyToMsgID := func() int {
res, ok := update.EffectiveMessage.GetReplyTo()
if !ok || res == nil {
return 0
}
replyHeader, ok := res.(*tg.MessageReplyHeader)
if !ok {
return 0
}
replyToMsgID, ok := replyHeader.GetReplyToMsgID()
if !ok {
return 0
}
return replyToMsgID
}()
if replyToMsgID == 0 {
return sendSaveHelp(ctx, update)
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
// storages := storage.GetUserStorages(user.ChatID)
// if len(storages) == 0 {
// ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
// return dispatcher.EndGroups
// }
msg, err := GetTGMessage(ctx, update.EffectiveChat().GetID(), replyToMsgID)
if err != nil {
common.Log.Errorf("获取消息失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("无法获取消息"), nil)
return dispatcher.EndGroups
}
supported, _ := supportedMediaFilter(msg)
if !supported {
ctx.Reply(update, ext.ReplyTextString("不支持的消息类型或消息中没有文件"), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
cmdText := update.EffectiveMessage.Text
customFileName := strings.TrimSpace(strings.TrimPrefix(cmdText, "/save"))
file, err := FileFromMessage(ctx, update.EffectiveChat().GetID(), msg.ID, customFileName)
if err != nil {
common.Log.Errorf("获取文件失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("获取文件失败: %s", err),
ID: replied.ID,
})
return dispatcher.EndGroups
}
if file.FileName == "" {
file.FileName = GenFileNameFromMessage(*msg, file)
}
receivedFile := &dao.ReceivedFile{
Processing: false,
FileName: file.FileName,
ChatID: update.EffectiveChat().GetID(),
MessageID: replyToMsgID,
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
}
record, err := dao.SaveReceivedFile(receivedFile)
if err != nil {
common.Log.Errorf("保存接收的文件失败: %s", err)
if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("保存接收的文件失败: %s", err),
ID: replied.ID,
}); err != nil {
common.Log.Errorf("编辑消息失败: %s", err)
}
return dispatcher.EndGroups
}
if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), msg.ID, replied.ID)
}
return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
File: file,
StorageName: user.DefaultStorage,
FileChatID: update.EffectiveChat().GetID(),
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
FileMessageID: msg.ID,
UserID: user.ChatID,
})
}
func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error {
// args: [0] = @channel, [1] = 114-514
chatArg := args[0]
var chatID int64
var err error
msgIdSlice := strings.Split(args[1], "-")
if len(msgIdSlice) != 2 {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
return dispatcher.EndGroups
}
minMsgID, minerr := strconv.ParseInt(msgIdSlice[0], 10, 64)
maxMsgID, maxerr := strconv.ParseInt(msgIdSlice[1], 10, 64)
if minerr != nil || maxerr != nil {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
return dispatcher.EndGroups
}
if minMsgID > maxMsgID || minMsgID <= 0 || maxMsgID <= 0 {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil)
return dispatcher.EndGroups
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
if user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString("请先设置默认存储"), nil)
return dispatcher.EndGroups
}
storages := storage.GetUserStorages(user.ChatID)
if len(storages) == 0 {
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
return dispatcher.EndGroups
}
if strings.HasPrefix(chatArg, "@") {
chatUsername := strings.TrimPrefix(chatArg, "@")
chat, err := ctx.ResolveUsername(chatUsername)
if err != nil {
common.Log.Errorf("解析频道用户名失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("解析频道用户名失败"), nil)
return dispatcher.EndGroups
}
if chat == nil {
ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil)
return dispatcher.EndGroups
}
chatID = chat.GetID()
} else {
chatID, err = strconv.ParseInt(chatArg, 10, 64)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil)
return dispatcher.EndGroups
}
}
if chatID == 0 {
ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在批量保存..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
total := maxMsgID - minMsgID + 1
successadd := 0
failedGetFile := 0
failedGetMsg := 0
failedSaveDB := 0
for i := minMsgID; i <= maxMsgID; i++ {
file, err := FileFromMessage(ctx, chatID, int(i), "")
if err != nil {
common.Log.Errorf("获取文件失败: %s", err)
failedGetFile++
continue
}
if file.FileName == "" {
message, err := GetTGMessage(ctx, chatID, int(i))
if err != nil {
common.Log.Errorf("获取消息失败: %s", err)
failedGetMsg++
continue
}
file.FileName = GenFileNameFromMessage(*message, file)
}
receivedFile := &dao.ReceivedFile{
Processing: false,
FileName: file.FileName,
ChatID: chatID,
MessageID: int(i),
ReplyChatID: update.GetUserChat().GetID(),
ReplyMessageID: 0,
}
record, err := dao.SaveReceivedFile(receivedFile)
if err != nil {
common.Log.Errorf("保存接收的文件失败: %s", err)
failedSaveDB++
continue
}
task := &types.Task{
Ctx: ctx,
Status: types.Pending,
FileDBID: record.ID,
File: file,
StorageName: user.DefaultStorage,
FileChatID: chatID,
FileMessageID: int(i),
UserID: user.ChatID,
ReplyMessageID: 0,
ReplyChatID: update.GetUserChat().GetID(),
}
queue.AddTask(task)
successadd++
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("批量添加任务完成\n成功添加: %d/%d\n获取文件失败: %d\n获取消息失败: %d\n保存数据库失败: %d", successadd, total, failedGetFile, failedGetMsg, failedSaveDB),
ID: replied.ID,
})
return dispatcher.EndGroups
}

View File

@@ -1,95 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
tgtypes "github.com/celestix/gotgproto/types"
"github.com/gotd/td/tg"
)
func copyMediaToChat(ctx *ext.Context, msg *tg.Message, chatID int64) (*tgtypes.Message, error) {
media, ok := msg.GetMedia()
if !ok {
return nil, fmt.Errorf("获取媒体失败")
}
req := &tg.MessagesSendMediaRequest{
InvertMedia: msg.InvertMedia,
Message: msg.Message,
}
switch m := media.(type) {
case *tg.MessageMediaDocument:
document, ok := m.Document.AsNotEmpty()
if !ok {
return nil, ErrEmptyDocument
}
inputMedia := &tg.InputMediaDocument{
ID: document.AsInput(),
}
inputMedia.SetFlags()
req.Media = inputMedia
case *tg.MessageMediaPhoto:
photo, ok := m.Photo.AsNotEmpty()
if !ok {
return nil, ErrEmptyPhoto
}
inputMedia := &tg.InputMediaPhoto{
ID: photo.AsInput(),
}
inputMedia.SetFlags()
req.Media = inputMedia
default:
return nil, fmt.Errorf("不支持的媒体类型: %T", media)
}
req.SetEntities(msg.Entities)
req.SetFlags()
return ctx.SendMedia(chatID, req)
}
func sendFileToTelegram(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.CallbackQuery.Data), " ")
if len(args) < 3 {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "参数错误",
CacheTime: 5,
})
return dispatcher.EndGroups
}
fileChatID, _ := strconv.Atoi(args[1])
fileMessageID, _ := strconv.Atoi(args[2])
fileMessage, err := GetTGMessage(ctx, int64(fileChatID), fileMessageID)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "无法获取文件消息",
CacheTime: 5,
})
return dispatcher.EndGroups
}
_, err = copyMediaToChat(ctx, fileMessage, update.EffectiveChat().GetID())
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: fmt.Sprintf("发送文件失败: %s", err),
CacheTime: 5,
})
} else {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
})
}
return dispatcher.EndGroups
}

View File

@@ -1,30 +0,0 @@
package bot
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
)
func silent(ctx *ext.Context, update *ext.Update) error {
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
return dispatcher.EndGroups
}
if !user.Silent && user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
return dispatcher.EndGroups
}
user.Silent = !user.Silent
if err := dao.UpdateUser(user); err != nil {
common.Log.Errorf("更新用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])), nil)
return dispatcher.EndGroups
}

View File

@@ -1,40 +0,0 @@
package bot
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
)
func start(ctx *ext.Context, update *ext.Update) error {
if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil {
common.Log.Errorf("创建用户失败: %s", err)
return dispatcher.EndGroups
}
return help(ctx, update)
}
const helpText string = `
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
默认存储位置: 在静默模式下保存到的位置
向 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件
`
func help(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, common.Version, common.GitCommit)), nil)
return dispatcher.EndGroups
}

View File

@@ -1,99 +0,0 @@
package bot
import (
"fmt"
"strconv"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/storage"
)
func storageCmd(ctx *ext.Context, update *ext.Update) error {
userChatID := update.GetUserChat().GetID()
storages := storage.GetUserStorages(userChatID)
if len(storages) == 0 {
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
return dispatcher.EndGroups
}
markup, err := getSetDefaultStorageMarkup(userChatID, storages)
if err != nil {
common.Log.Errorf("Failed to get markup: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取存储位置失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
Markup: markup,
})
return dispatcher.EndGroups
}
func setDefaultStorage(ctx *ext.Context, update *ext.Update) error {
args := strings.Split(string(update.CallbackQuery.Data), " ")
userID, _ := strconv.Atoi(args[1])
if userID != int(update.CallbackQuery.GetUserID()) {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "你没有权限",
CacheTime: 5,
})
return dispatcher.EndGroups
}
cbDataId, _ := strconv.Atoi(args[2])
storageName, err := dao.GetCallbackData(uint(cbDataId))
if err != nil {
common.Log.Errorf("获取回调数据失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取回调数据失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
selectedStorage, err := storage.GetStorageByName(storageName)
if err != nil {
common.Log.Errorf("获取指定存储失败: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取指定存储失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
user, err := dao.GetUserByChatID(int64(userID))
if err != nil {
common.Log.Errorf("Failed to get user: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "获取用户失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
user.DefaultStorage = storageName
if err := dao.UpdateUser(user); err != nil {
common.Log.Errorf("Failed to update user: %s", err)
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.QueryID,
Alert: true,
Message: "更新用户失败",
CacheTime: 5,
})
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("已将 %s (%s) 设为默认存储位置", selectedStorage.Name(), selectedStorage.Type()),
ID: update.CallbackQuery.GetMsgID(),
})
return dispatcher.EndGroups
}

View File

@@ -1,114 +0,0 @@
package bot
import (
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/telegraph-go/v2"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
var (
TelegraphClient *telegraph.TelegraphClient
TelegraphUrlRegexString = `https://telegra.ph/.*`
TelegraphUrlRegex = regexp.MustCompile(TelegraphUrlRegexString)
)
func InitTelegraphClient() {
var httpClient *http.Client
if config.Cfg.Telegram.Proxy.Enable {
proxyUrl, err := url.Parse(config.Cfg.Telegram.Proxy.URL)
if err != nil {
fmt.Println("Error parsing proxy URL:", err)
return
}
proxy := http.ProxyURL(proxyUrl)
httpClient = &http.Client{
Transport: &http.Transport{
Proxy: proxy,
},
Timeout: 30 * time.Second,
}
} else {
httpClient = &http.Client{
Timeout: 30 * time.Second,
}
}
TelegraphClient = telegraph.GetTelegraphClient(&telegraph.ClientOpt{HttpClient: httpClient})
}
func handleTelegraph(ctx *ext.Context, update *ext.Update) error {
common.Log.Trace("Got telegraph link")
tgphUrl := TelegraphUrlRegex.FindString(update.EffectiveMessage.Text)
if tgphUrl == "" {
return dispatcher.ContinueGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil)
if err != nil {
common.Log.Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
user, err := dao.GetUserByChatID(update.GetUserChat().GetID())
if err != nil {
common.Log.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
storages := storage.GetUserStorages(user.ChatID)
if len(storages) == 0 {
ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil)
return dispatcher.EndGroups
}
tgphPath := strings.Split(tgphUrl, "/")[len(strings.Split(tgphUrl, "/"))-1]
fileName, err := url.PathUnescape(tgphPath)
if err != nil {
common.Log.Errorf("解析 Telegraph 路径失败: %s", err)
fileName = tgphPath
}
record := &dao.ReceivedFile{
Processing: false,
FileName: fileName,
ChatID: update.EffectiveChat().GetID(),
MessageID: update.EffectiveMessage.GetID(),
ReplyMessageID: replied.ID,
ReplyChatID: update.EffectiveChat().GetID(),
IsTelegraph: true,
TelegraphURL: tgphUrl,
}
if _, err := dao.SaveReceivedFile(record); err != nil {
common.Log.Errorf("保存接收的文件失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法保存文件: " + err.Error(),
ID: replied.ID,
})
return dispatcher.EndGroups
}
if !user.Silent || user.DefaultStorage == "" {
return ProvideSelectMessage(ctx, update, fileName, update.EffectiveChat().GetID(), update.EffectiveMessage.GetID(), replied.ID)
}
return HandleSilentAddTask(ctx, update, user, &types.Task{
Ctx: ctx,
Status: types.Pending,
StorageName: user.DefaultStorage,
UserID: user.ChatID,
ReplyMessageID: replied.ID,
ReplyChatID: update.GetUserChat().GetID(),
IsTelegraph: true,
TelegraphURL: tgphUrl,
})
}

View File

@@ -1,41 +0,0 @@
package bot
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/dispatcher/handlers"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/ext"
"github.com/krau/SaveAny-Bot/common"
)
func RegisterHandlers(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))
disp.AddHandler(handlers.NewCommand("start", start))
disp.AddHandler(handlers.NewCommand("help", help))
disp.AddHandler(handlers.NewCommand("silent", silent))
disp.AddHandler(handlers.NewCommand("storage", storageCmd))
disp.AddHandler(handlers.NewCommand("save", saveCmd))
disp.AddHandler(handlers.NewCommand("dir", dirCmd))
disp.AddHandler(handlers.NewCommand("rule", ruleCmd))
linkRegexFilter, err := filters.Message.Regex(linkRegexString)
if err != nil {
common.Log.Panicf("创建正则表达式过滤器失败: %s", err)
}
disp.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage))
telegraphUrlRegexFilter, err := filters.Message.Regex(TelegraphUrlRegexString)
if err != nil {
common.Log.Panicf("创建 Telegraph URL 正则表达式过滤器失败: %s", err)
}
disp.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleTelegraph))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), cancelTask))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("send_here"), sendFileToTelegram))
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage))
}

View File

@@ -1,37 +0,0 @@
package bot
import (
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/duke-git/lancet/v2/slice"
"github.com/gotd/contrib/middleware/floodwait"
"github.com/gotd/contrib/middleware/ratelimit"
"github.com/gotd/td/telegram"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/time/rate"
)
func FloodWaitMiddleware() []telegram.Middleware {
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(uint(config.Cfg.Telegram.FloodRetry))
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
return []telegram.Middleware{
waiter,
ratelimiter,
}
}
const noPermissionText string = `
您不在白名单中, 无法使用此 Bot.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
`
func checkPermission(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
if !slice.Contain(config.Cfg.GetUsersID(), userID) {
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
return dispatcher.EndGroups
}
return dispatcher.ContinueGroups
}

View File

@@ -1,450 +0,0 @@
package bot
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gabriel-vasile/mimetype"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/telegram/query"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
)
var (
ErrEmptyDocument = errors.New("document is empty")
ErrEmptyPhoto = errors.New("photo is empty")
ErrEmptyPhotoSize = errors.New("photo size is empty")
ErrEmptyPhotoSizes = errors.New("photo size slice is empty")
ErrNoStorages = errors.New("no available storage")
ErrEmptyMessage = errors.New("message is empty")
)
func supportedMediaFilter(m *tg.Message) (bool, error) {
if not := m.Media == nil; not {
return false, dispatcher.EndGroups
}
switch m.Media.(type) {
case *tg.MessageMediaDocument:
return true, nil
case *tg.MessageMediaPhoto:
return true, nil
default:
return false, nil
}
}
func getSelectStorageMarkup(userChatID int64, fileChatID, fileMessageID int) (*tg.ReplyInlineMarkup, error) {
user, err := dao.GetUserByChatID(userChatID)
if err != nil {
return nil, fmt.Errorf("failed to get user by chat ID: %d, error: %w", userChatID, err)
}
storages := storage.GetUserStorages(user.ChatID)
// if len(storages) == 0 {
// return nil, ErrNoStorages
// }
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range storages {
cbData := fmt.Sprintf("%d %d %s 0", fileChatID, fileMessageID, storage.Name()) // 0 for empty dir id
cbDataId, err := dao.CreateCallbackData(cbData)
if err != nil {
return nil, fmt.Errorf("failed to create callback data: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: storage.Name(),
Data: fmt.Appendf(nil, "add %d", cbDataId),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
markup.Rows = append(markup.Rows, tg.KeyboardButtonRow{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: "发送到当前聊天",
Data: []byte(fmt.Sprintf("send_here %d %d", fileChatID, fileMessageID)),
},
},
})
return markup, nil
}
func getSelectDirMarkup(fileChatID, fileMessageID int, storageName string, dirs []dao.Dir) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, dir := range dirs {
if dir.ID == 0 || dir.StorageName != storageName {
return nil, fmt.Errorf("unexpected dir: %v", dir)
}
cbDataId, err := dao.CreateCallbackData(fmt.Sprintf("%d %d %s %d", fileChatID, fileMessageID, storageName, dir.ID))
if err != nil {
return nil, fmt.Errorf("failed to create callback data: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: dir.Path,
Data: []byte(fmt.Sprintf("add_to_dir %d", cbDataId)),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func getSetDefaultStorageMarkup(userChatID int64, storages []storage.Storage) (*tg.ReplyInlineMarkup, error) {
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range storages {
cbDataId, err := dao.CreateCallbackData(storage.Name())
if err != nil {
return nil, fmt.Errorf("failed to create callback data: %w", err)
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: storage.Name(),
Data: []byte(fmt.Sprintf("set_default %d %d", userChatID, cbDataId)),
})
}
markup := &tg.ReplyInlineMarkup{}
for i := 0; i < len(buttons); i += 3 {
row := tg.KeyboardButtonRow{}
row.Buttons = buttons[i:min(i+3, len(buttons))]
markup.Rows = append(markup.Rows, row)
}
return markup, nil
}
func FileFromMedia(media tg.MessageMediaClass, customFileName string) (*types.File, error) {
switch media := media.(type) {
case *tg.MessageMediaDocument:
document, ok := media.Document.AsNotEmpty()
if !ok {
return nil, ErrEmptyDocument
}
if customFileName != "" {
return &types.File{
Location: document.AsInputDocumentFileLocation(),
FileSize: document.Size,
FileName: customFileName,
}, nil
}
fileName := ""
for _, attribute := range document.Attributes {
if name, ok := attribute.(*tg.DocumentAttributeFilename); ok {
fileName = name.GetFileName()
break
}
}
return &types.File{
Location: document.AsInputDocumentFileLocation(),
FileSize: document.Size,
FileName: fileName,
}, nil
case *tg.MessageMediaPhoto:
photo, ok := media.Photo.AsNotEmpty()
if !ok {
return nil, ErrEmptyPhoto
}
sizes := photo.Sizes
if len(sizes) == 0 {
return nil, ErrEmptyPhotoSizes
}
photoSize := sizes[len(sizes)-1]
size, ok := photoSize.AsNotEmpty()
if !ok {
return nil, ErrEmptyPhotoSize
}
location := new(tg.InputPhotoFileLocation)
location.ID = photo.GetID()
location.AccessHash = photo.GetAccessHash()
location.FileReference = photo.GetFileReference()
location.ThumbSize = size.GetType()
fileName := customFileName
if fileName == "" {
fileName = fmt.Sprintf("photo_%s_%d.jpg", time.Now().Format("2006-01-02_15-04-05"), photo.GetID())
}
return &types.File{
Location: location,
FileSize: 0,
FileName: fileName,
}, nil
}
return nil, fmt.Errorf("unexpected type %T", media)
}
func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileName string) (*types.File, error) {
key := fmt.Sprintf("file:%d:%d", chatID, messageID)
cachedFile, err := common.CacheGet[*types.File](ctx, key)
if err == nil {
if customFileName != "" {
cachedFile.FileName = customFileName
}
return cachedFile, nil
}
common.Log.Debugf("Getting file: %s", key)
message, err := GetTGMessage(ctx, chatID, messageID)
if err != nil {
return nil, err
}
file, err := FileFromMedia(message.Media, customFileName)
if err != nil {
return nil, err
}
if err := common.CacheSet(ctx, key, file); err != nil {
common.Log.Errorf("Failed to cache file: %s", err)
}
return file, nil
}
func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) {
key := fmt.Sprintf("message:%d:%d", chatId, messageID)
cacheMessage, err := common.CacheGet[*tg.Message](ctx, key)
if err == nil {
return cacheMessage, nil
}
common.Log.Debugf("Fetching message: %d:%d", chatId, messageID)
messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}})
if err != nil {
return nil, err
}
if len(messages) == 0 {
return nil, ErrEmptyMessage
}
msg := messages[0]
tgMessage, ok := msg.(*tg.Message)
if !ok {
return nil, fmt.Errorf("unexpected message type: %T", msg)
}
if err := common.CacheSet(ctx, key, tgMessage); err != nil {
common.Log.Errorf("Failed to cache message: %s", err)
}
return tgMessage, nil
}
// Userbot only
//
// https://github.com/iyear/tdl/blob/fbb396da774ba544e527c3ef41c44921ad74ee98/core/util/tutil/tutil.go#L174
func GetSingleHistoryMessage(ctx context.Context, client *tg.Client, peer tg.InputPeerClass, msg int) (*tg.Message, error) {
it := query.Messages(client).GetHistory(peer).OffsetID(msg + 1).BatchSize(1).Iter()
if !it.Next(ctx) {
return nil, fmt.Errorf("failed to get message %d from %s: %w", msg, peer, it.Err())
}
m, ok := it.Value().Msg.(*tg.Message)
if !ok {
return nil, fmt.Errorf("invalid message %d", msg)
}
if m.GetID() != msg {
return nil, fmt.Errorf("the message %d/%d may be deleted", GetInputPeerID(peer), msg)
}
return m, nil
}
// Userbot only
func GetHistoryMessages(ctx context.Context, client *tg.Client, peer tg.InputPeerClass, startID, limit int) ([]*tg.Message, error) {
endID := startID + limit - 1
msgs, err := client.MessagesGetHistory(ctx, &tg.MessagesGetHistoryRequest{
Peer: peer,
OffsetID: startID,
Limit: limit,
AddOffset: startID - endID,
})
if err != nil {
return nil, fmt.Errorf("failed to get history messages: %w", err)
}
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:
return nil, fmt.Errorf("unexpected messages type: %T", msgs)
}
messageBatch := make([]*tg.Message, 0, 100)
for _, msg := range msgClass {
msgNotEmpty, ok := msg.AsNotEmpty()
if !ok {
continue
}
switch msgNotEmptyV := msgNotEmpty.(type) {
case *tg.Message:
messageBatch = append(messageBatch, msgNotEmptyV)
default:
common.Log.Warnf("Unexpected message type: %T, skipping", msgNotEmptyV)
continue
}
}
if len(messageBatch) == 0 {
return nil, fmt.Errorf("no messages found for peer %s with startID %d and limit %d", peer, startID, limit)
}
return messageBatch, nil
}
func GetMediaGroup(ctx *ext.Context, chatID int64, messageID int, groupID int64) ([]*tg.Message, error) {
peer := ctx.PeerStorage.GetInputPeerById(chatID)
if peer == nil {
return nil, fmt.Errorf("无法获取聊天 %d 的输入 Peer", chatID)
}
messages, err := GetHistoryMessages(ctx, ctx.Raw, peer, messageID-9, 20)
if err != nil {
return nil, fmt.Errorf("获取消息失败: %s", err)
}
var groupMessages []*tg.Message
for _, msg := range messages {
gID, isGroup := msg.GetGroupedID()
if isGroup && gID == groupID {
groupMessages = append(groupMessages, msg)
}
}
if len(groupMessages) == 0 || (len(groupMessages) == 1 && groupMessages[0].ID == messageID) {
return nil, fmt.Errorf("未找到媒体组 %d 中的消息", groupID)
}
return groupMessages, nil
}
func GetInputPeerID(peer tg.InputPeerClass) int64 {
switch p := peer.(type) {
case *tg.InputPeerUser:
return p.UserID
case *tg.InputPeerChat:
return p.ChatID
case *tg.InputPeerChannel:
return p.ChannelID
}
return 0
}
func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, fileName string, chatID int64, fileMsgID, toEditMsgID int) error {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("文件名: %s\n请选择存储位置", fileName)
if err := styling.Perform(&entityBuilder,
styling.Plain("文件名: "),
styling.Code(fileName),
styling.Plain("\n请选择存储位置"),
); err != nil {
common.Log.Errorf("Failed to build entity: %s", err)
} else {
text, entities = entityBuilder.Complete()
}
markup, err := getSelectStorageMarkup(update.GetUserChat().GetID(), int(chatID), fileMsgID)
if errors.Is(err, ErrNoStorages) {
common.Log.Errorf("Failed to get select storage markup: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无可用存储",
ID: toEditMsgID,
})
return dispatcher.EndGroups
} else if err != nil {
common.Log.Errorf("Failed to get select storage markup: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "无法获取存储",
ID: toEditMsgID,
})
return dispatcher.EndGroups
}
_, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: text,
Entities: entities,
ReplyMarkup: markup,
ID: toEditMsgID,
})
if err != nil {
common.Log.Errorf("Failed to reply: %s", err)
}
return dispatcher.EndGroups
}
func HandleSilentAddTask(ctx *ext.Context, update *ext.Update, user *dao.User, task *types.Task) error {
if user.DefaultStorage == "" {
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: "请先使用 /storage 设置默认存储位置",
ID: task.ReplyMessageID,
})
return dispatcher.EndGroups
}
queue.AddTask(task)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", task.FileName(), queue.Len()),
ID: task.ReplyMessageID,
})
return dispatcher.EndGroups
}
func GenFileNameFromMessage(message tg.Message, file *types.File) string {
if file.FileName != "" {
return file.FileName
}
fileName := genFileNameFromMessageText(message, file)
media, ok := message.GetMedia()
if !ok {
return fileName
}
ext, ok := extraMediaExt(media)
if ok {
return fileName + ext
}
return fileName
}
func genFileNameFromMessageText(message tg.Message, file *types.File) string {
text := strings.TrimSpace(message.GetMessage())
if text == "" {
return file.Hash()
}
tags := common.ExtractTagsFromText(text)
if len(tags) > 0 {
return fmt.Sprintf("%s_%s", strings.Join(tags, "_"), strconv.Itoa(message.GetID()))
}
// 删除换行和特殊字符
text = strings.Map(func(r rune) rune {
if r == '\n' || r == '\r' || r == '\t' || r == ' ' {
return '_'
}
return r
}, text)
runes := []rune(text)
return string(runes[:min(128, len(runes))])
}
func extraMediaExt(media tg.MessageMediaClass) (string, bool) {
switch media := media.(type) {
case *tg.MessageMediaDocument:
doc, ok := media.Document.AsNotEmpty()
if !ok {
return "", false
}
ext := mimetype.Lookup(doc.MimeType).Extension()
if ext == "" {
return "", false
}
return ext, true
case *tg.MessageMediaPhoto:
return ".jpg", true
}
return "", false
}

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

@@ -0,0 +1,99 @@
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
}
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,116 @@
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 {
dataParts := strings.Split(string(update.CallbackQuery.Data), " ")
if len(dataParts) < 2 {
return fmt.Errorf("invalid callback data: %q", update.CallbackQuery.Data)
}
dataid := dataParts[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
} else if data.SelectedDirPath != "" {
dirPath = data.SelectedDirPath
}
switch data.TaskType {
case tasktype.TaskTypeTgfiles:
if data.AsBatch {
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID, data.ConflictStrategy)
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID, data.ConflictStrategy)
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)
case tasktype.TaskTypeAria2:
client := GetAria2Client()
if client == nil {
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, i18n.T(i18nk.BotMsgAria2ErrorAria2ClientInitFailed, map[string]any{
"Error": "aria2 client not initialized",
})))
return dispatcher.EndGroups
}
shortcut.CreateAndAddAria2TaskWithEdit(ctx, selectedStorage, dirPath, data.Aria2URIs, client, msgID, userID)
case tasktype.TaskTypeYtdlp:
shortcut.CreateAndAddYtdlpTaskWithEdit(ctx, selectedStorage, dirPath, data.YtdlpURLs, data.YtdlpFlags, msgID, userID)
case tasktype.TaskTypeTransfer:
return handleTransferCallback(ctx, userID, selectedStorage, dirPath, data, msgID)
default:
return fmt.Errorf("unexcept task type: %s", data.TaskType)
}
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,58 @@
package handlers
import (
"fmt"
"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 {
dataParts := strings.Split(string(update.CallbackQuery.Data), " ")
if len(dataParts) < 2 {
return fmt.Errorf("invalid callback data: %q", update.CallbackQuery.Data)
}
taskid := dataParts[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,200 @@
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/client/bot/handlers/utils/conflictutil"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString(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"),
},
&tg.KeyboardButtonCallback{
Text: i18n.T(i18nk.BotMsgConfigButtonConflictStrategy),
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "conflictst"),
},
},
},
},
},
})
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)
case "conflictst":
return handleConfigConflictSTCallback(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.GetDisplay(st, config.C().Lang),
}),
})
return dispatcher.EndGroups
}
opts := fnamest.FnameSTValues()
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
for _, opt := range opts {
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: fnamest.GetDisplay(opt, config.C().Lang),
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.GetDisplay(currentSt, config.C().Lang),
}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func handleConfigConflictSTCallback(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]
if !tcbdata.IsConflictStrategy(selected) {
return fmt.Errorf("invalid conflict strategy: %s", selected)
}
user.ConflictStrategy = selected
if err := database.UpdateUser(ctx, user); err != nil {
return err
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgConfigInfoConflictStrategySet, map[string]any{
"Strategy": conflictutil.Display(selected),
}),
})
return dispatcher.EndGroups
}
opts := tcbdata.ConflictStrategyValues()
rows := make([]tg.KeyboardButtonRow, 0, len(opts))
for _, opt := range opts {
rows = append(rows, tg.KeyboardButtonRow{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: conflictutil.Display(opt),
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "conflictst", opt),
},
},
})
}
markup := &tg.ReplyInlineMarkup{Rows: rows}
currentSt := conflictutil.EffectiveStrategy(user)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: i18n.T(i18nk.BotMsgConfigPromptSelectConflictStrategy, map[string]any{
"Strategy": conflictutil.Display(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,79 @@
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/common/utils/tgutil"
"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 {
logger.Errorf("Failed to get storage %q: %s", args[2], err)
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgCommonErrorGetStorageFailed,
tgutil.EscapeHTMLTemplateData(map[string]any{"Error": 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
}

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

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

View File

@@ -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,148 @@
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/config"
"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"
)
// mediaGroupKey uniquely identifies a media group by chat, sender, and group
// ID so files from different users in the same chat can never be mixed.
type mediaGroupKey struct {
chatID int64
userID int64
groupID int64
}
type MediaGroupHandler struct {
groups map[mediaGroupKey][]tfile.TGFileMessage
timers map[mediaGroupKey]*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[mediaGroupKey][]tfile.TGFileMessage),
timers: make(map[mediaGroupKey]*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
}
userId := update.GetUserChat().GetID()
userDB, err := database.GetUserByChatID(ctx, userId)
if err != nil {
return err
}
tfOpts := mediautil.TfileOptions(ctx, userDB, message)
file, err := tfile.FromMediaMessage(media, ctx.Raw, message, tfOpts...)
if err != nil {
logger.Errorf("Failed to get file from media: %s", err)
return dispatcher.EndGroups
}
mediaGroupHandler.mu.Lock()
defer mediaGroupHandler.mu.Unlock()
key := mediaGroupKey{
chatID: update.EffectiveChat().GetID(),
userID: userId,
groupID: groupID,
}
if mediaGroupHandler.groups[key] == nil {
mediaGroupHandler.groups[key] = make([]tfile.TGFileMessage, 0)
}
mediaGroupHandler.groups[key] = append(mediaGroupHandler.groups[key], file)
if timer, exists := mediaGroupHandler.timers[key]; exists {
timer.Stop()
}
mediaGroupHandler.timers[key] = time.AfterFunc(mediaGroupHandler.timeout, func() {
processMediaGroup(ctx, update, key)
})
return dispatcher.EndGroups
}
func processMediaGroup(ctx *ext.Context, update *ext.Update, key mediaGroupKey) {
logger := log.FromContext(ctx)
mediaGroupHandler.mu.Lock()
items := mediaGroupHandler.groups[key]
delete(mediaGroupHandler.groups, key)
delete(mediaGroupHandler.timers, key)
mediaGroupHandler.mu.Unlock()
if len(items) == 0 {
logger.Warn("No media items to process for group", "groupID", key.groupID)
return
}
logger.Debugf("Processing media group %d with %d items", key.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,73 @@
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
}
// withPermission wraps a callback handler with the same whitelist check used
// for message handlers (checkPermission).
func withPermission(handler func(*ext.Context, *ext.Update) error) func(*ext.Context, *ext.Update) error {
return func(ctx *ext.Context, update *ext.Update) error {
if err := checkPermission(ctx, update); err != nil {
return err
}
return handler(ctx, update)
}
}
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,72 @@
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},
{"aria2dl", i18nk.BotMsgCmdAria2dl, handleAria2DlCmd},
{"ytdlp", i18nk.BotMsgCmdYtdlp, handleYtdlpCmd},
{"transfer", i18nk.BotMsgCmdTransfer, handleTransferCmd},
{"task", i18nk.BotMsgCmdTask, handleTaskCmd},
{"cancel", i18nk.BotMsgCmdCancel, handleCancelCmd},
{"config", i18nk.BotMsgCmdConfig, handleConfigCmd},
{"fnametmpl", i18nk.BotMsgCmdFnametmpl, handleConfigFnameTmpl},
{"help", i18nk.BotMsgCmdHelp, handleHelpCmd},
{"parser", i18nk.BotMsgCmdParser, handleParserCmd},
{"watch", i18nk.BotMsgCmdWatch, handleWatchCmd},
{"unwatch", i18nk.BotMsgCmdUnwatch, handleUnwatchCmd},
{"lswatch", i18nk.BotMsgCmdLswatch, handleLswatchCmd},
{"syncpeers", i18nk.BotMsgCmdSyncpeers, handleSyncpeersCmd},
{"update", i18nk.BotMsgCmdUpdate, handleUpdateCmd},
}
func Register(disp dispatcher.Dispatcher) {
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"), withPermission(handleUpdateCallback)))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), withPermission(handleAddCallback)))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), withPermission(handleSetDefaultCallback)))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), withPermission(handleCancelCallback)))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), withPermission(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())
}
}

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

@@ -0,0 +1,151 @@
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/config"
"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 "preset":
// /rule preset <storage> [base_path]
if len(args) < 3 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
storageName := args[2]
if !config.C().HasStorage(user.ChatID, storageName) {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorStorageNotFound, map[string]any{
"Storage": storageName,
})), nil)
return dispatcher.EndGroups
}
basePath := ""
if len(args) >= 4 {
basePath = args[3]
}
presets := rule.PresetCategories(basePath)
imported := 0
for _, p := range presets {
rd := &database.Rule{
Type: rule.FileNameRegex.String(),
Data: p.Regex,
StorageName: storageName,
DirPath: p.Dir,
UserID: user.ID,
}
if err := database.CreateRule(ctx, rd); err != nil {
logger.Errorf("failed to create preset rule %s: %s", p.Name, err)
continue
}
imported++
}
if imported == 0 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleErrorCreateRuleFailed, nil)), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgRuleInfoPresetImported, map[string]any{
"Count": imported,
})), 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
}

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

@@ -0,0 +1,195 @@
package handlers
import (
"regexp"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/validator"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/dirutil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"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
}
tctx := ctx
uctx := user.GetCtx()
if uctx != nil && validator.IsIntStr(chatArg) {
tctx = uctx
}
chatID, err := tgutil.ParseChatID(tctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString(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(tctx, 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, tctx.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,155 @@
package handlers
import (
"fmt"
"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 {
dataParts := strings.Split(string(update.CallbackQuery.Data), " ")
if len(dataParts) < 2 {
return fmt.Errorf("invalid callback data: %q", update.CallbackQuery.Data)
}
dataid := dataParts[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,62 @@
package handlers
import (
"context"
"sync"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/storage"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/query/dialogs"
"github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
)
var syncpeerMu sync.Mutex
func handleSyncpeersCmd(ctx *ext.Context, u *ext.Update) error {
if !config.C().Telegram.Userbot.Enable {
return dispatcher.EndGroups
}
syncpeerMu.Lock()
defer syncpeerMu.Unlock()
uctx := user.GetCtx()
if uctx == nil {
return dispatcher.EndGroups
}
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersStart)), nil)
tapi := uctx.Raw
peerStorage := uctx.PeerStorage
log.FromContext(ctx).Info("Starting to sync peers...")
count := 0
err := dialogs.NewQueryBuilder(tapi).GetDialogs().BatchSize(50).ForEach(ctx, func(ctx context.Context, e dialogs.Elem) error {
for cid, channel := range e.Entities.Channels() {
peerStorage.AddPeer(cid, channel.AccessHash, storage.TypeChannel, channel.Username)
count++
}
for uid, user := range e.Entities.Users() {
peerStorage.AddPeer(uid, user.AccessHash, storage.TypeUser, user.Username)
count++
}
for gid := range e.Entities.Chats() {
peerStorage.AddPeer(gid, storage.DefaultAccessHash, storage.TypeChat, storage.DefaultUsername)
count++
}
return nil
})
if err != nil {
log.FromContext(ctx).Error("Failed to sync peers", "error", err)
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersFailed, map[string]any{
"Error": err.Error(),
})), nil)
return dispatcher.EndGroups
}
log.FromContext(ctx).Info("Finished syncing peers")
ctx.Reply(u, ext.ReplyTextString(i18n.T(i18nk.BotMsgSyncpeersSuccess, map[string]any{
"Count": count,
})), nil)
return dispatcher.EndGroups
}

View File

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

View File

@@ -0,0 +1,132 @@
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/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config"
"github.com/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 {
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(u.CallbackQuery.GetQueryID(), i18n.T(i18nk.BotMsgUpdateErrorVersionVarInvalid, map[string]any{
"Error": err.Error(),
})))
return dispatcher.EndGroups
}
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,55 @@
package conflictutil
import (
"fmt"
"strings"
"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"
)
const maxConflictLines = 10
func EffectiveStrategy(user *database.User) string {
if user != nil && tcbdata.IsConflictStrategy(user.ConflictStrategy) {
return user.ConflictStrategy
}
return tcbdata.ConflictStrategyRename
}
func ResolveStrategy(user *database.User, override string) string {
if tcbdata.IsConflictStrategy(override) {
return override
}
return EffectiveStrategy(user)
}
func Display(strategy string) string {
switch strategy {
case tcbdata.ConflictStrategyRename:
return i18n.T(i18nk.BotMsgConfigConflictStrategyRename, nil)
case tcbdata.ConflictStrategyAsk:
return i18n.T(i18nk.BotMsgConfigConflictStrategyAsk, nil)
case tcbdata.ConflictStrategyOverwrite:
return i18n.T(i18nk.BotMsgConfigConflictStrategyOverwrite, nil)
case tcbdata.ConflictStrategySkip:
return i18n.T(i18nk.BotMsgConfigConflictStrategySkip, nil)
default:
return strategy
}
}
func FormatPaths(conflicts []string) string {
if len(conflicts) <= maxConflictLines {
return strings.Join(conflicts, "\n")
}
return strings.Join(conflicts[:maxConflictLines], "\n") + "\n" + i18n.T(i18nk.BotMsgCommonPromptConflictMoreFiles, map[string]any{
"Count": len(conflicts) - maxConflictLines,
})
}
func FormatPath(storageName, storagePath string) string {
return fmt.Sprintf("[%s]:%s", storageName, storagePath)
}

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

View File

@@ -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 {
fmt.Fprintf(&sb, "%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,41 @@
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("preset"),
styling.Plain(i18n.T(i18nk.BotMsgRuleHelpPresetSuffix, 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,245 @@
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(),
SelectedDirPath: adddata.SelectedDirPath,
ConflictStrategy: adddata.ConflictStrategy,
Files: adddata.Files,
AsBatch: len(adddata.Files) > 1,
TphPageNode: adddata.TphPageNode,
TphPics: adddata.TphPics,
TphDirPath: adddata.TphDirPath,
ParsedItem: adddata.ParsedItem,
DirectLinks: adddata.DirectLinks,
Aria2URIs: adddata.Aria2URIs,
YtdlpURLs: adddata.YtdlpURLs,
YtdlpFlags: adddata.YtdlpFlags,
TransferSourceStorName: adddata.TransferSourceStorName,
TransferSourcePath: adddata.TransferSourcePath,
TransferFiles: adddata.TransferFiles,
}
dataid := xid.New().String()
err := cache.Set(dataid, data)
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
}
func BuildConflictStrategyMarkup(adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) {
type option struct {
text string
strategy string
}
options := []option{
{text: i18n.T(i18nk.BotMsgCommonButtonConflictRename, nil), strategy: tcbdata.ConflictStrategyRename},
{text: i18n.T(i18nk.BotMsgCommonButtonConflictOverwrite, nil), strategy: tcbdata.ConflictStrategyOverwrite},
{text: i18n.T(i18nk.BotMsgCommonButtonConflictSkip, nil), strategy: tcbdata.ConflictStrategySkip},
}
buttons := make([]tg.KeyboardButtonClass, 0, len(options))
for _, opt := range options {
data := adddata
data.ConflictStrategy = opt.strategy
dataid := xid.New().String()
if err := cache.Set(dataid, data); err != nil {
return nil, err
}
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: opt.text,
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeAdd, dataid),
})
}
rows := make([]tg.KeyboardButtonRow, 0, len(buttons))
for _, button := range buttons {
rows = append(rows, tg.KeyboardButtonRow{
Buttons: []tg.KeyboardButtonClass{button},
})
}
return &tg.ReplyInlineMarkup{Rows: rows}, 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|telegram)\.me/(?:c/\d+|[A-Za-z0-9_]+)/\d+(?:/\d+)?(?:\?[^\s#]*[A-Za-z0-9_])?\b`
TgMessageLinkRegexp = regexp.MustCompile(TgMessageLinkRegexString)
TelegraphUrlRegexString = `https://telegra\.ph/[^\s]+`
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,65 @@
package shortcut
import (
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/aria2dl"
"github.com/krau/SaveAny-Bot/pkg/aria2"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func CreateAndAddAria2TaskWithEdit(ctx *ext.Context, stor storage.Storage, dirPath string, uris []string, aria2Client *aria2.Client, msgID int, userID int64) error {
logger := log.FromContext(ctx)
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
// Now add to aria2 after user selected storage
logger.Infof("Adding download to aria2, uris type: %T, value: %+v", uris, uris)
// Ensure uris is valid
if len(uris) == 0 {
logger.Error("URIs list is empty")
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgDlErrorNoValidLinks, nil),
})
return dispatcher.EndGroups
}
gid, err := aria2Client.AddURI(ctx, uris, nil)
if err != nil {
logger.Errorf("Failed to add aria2 download: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgAria2ErrorAddingAria2Download, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
logger.Infof("Aria2 download added with GID: %s", gid)
// Create task with the GID
task := aria2dl.NewTask(xid.New().String(), injectCtx, gid, uris, aria2Client, stor, dirPath, aria2dl.NewProgress(msgID, userID))
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonErrorTaskAddFailed, map[string]any{
"Error": err.Error(),
}),
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: msgID,
Message: i18n.T(i18nk.BotMsgCommonInfoTaskAdded, nil),
})
return dispatcher.EndGroups
}

View File

@@ -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, 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,299 @@
// Some shortcuts for duplicate code in handlers, they should return dispatcher errors
package shortcut
import (
"encoding/json"
"fmt"
"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 := findTelegraphURL(update.EffectiveMessage.Message)
if tphurl == "" {
logger.Warnf("No telegraph url found but called handleTelegraph")
return nil, nil, dispatcher.ContinueGroups
}
pagepath, err := parseTelegraphPagePath(tphurl)
if err != nil {
logger.Errorf("Failed to parse 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
}
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: pagepath,
Page: page,
}, nil
}
func findTelegraphURL(msg *tg.Message) string {
if msg == nil {
return ""
}
var firstMatch string
findValid := func(text string) string {
for _, tphurl := range re.TelegraphUrlRegexp.FindAllString(text, -1) {
if firstMatch == "" {
firstMatch = tphurl
}
if _, err := parseTelegraphPagePath(tphurl); err == nil {
return tphurl
}
}
return ""
}
for _, entityURL := range tgutil.ExtractMessageEntityUrls(msg) {
if tphurl := findValid(entityURL); tphurl != "" {
return tphurl
}
}
if tphurl := findValid(msg.GetMessage()); tphurl != "" {
return tphurl
}
return firstMatch
}
func parseTelegraphPagePath(pageURL string) (string, error) {
u, err := url.Parse(pageURL)
if err != nil {
return "", fmt.Errorf("invalid telegraph URL: %w", err)
}
if u.Scheme != "https" || !strings.EqualFold(u.Hostname(), "telegra.ph") {
return "", fmt.Errorf("invalid telegraph URL host: %s", u.Host)
}
pagepath := strings.Trim(u.EscapedPath(), "/")
if pagepath == "" || strings.Contains(pagepath, "/") {
return "", fmt.Errorf("invalid telegraph URL path: %s", u.Path)
}
pagepath, err = url.PathUnescape(pagepath)
if err != nil {
return "", fmt.Errorf("failed to unescape telegraph path: %w", err)
}
pagepath = strings.TrimSpace(pagepath)
if pagepath == "" || strings.Contains(pagepath, "/") {
return "", fmt.Errorf("invalid telegraph URL path: %s", u.Path)
}
return pagepath, nil
}

View File

@@ -0,0 +1,163 @@
package shortcut
import (
"testing"
"github.com/gotd/td/tg"
)
func TestFindTelegraphURL(t *testing.T) {
tests := []struct {
name string
msg *tg.Message
want string
}{
{
name: "single URL entity",
msg: &tg.Message{
Message: "https://telegra.ph/Example-01-02",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityURL{Offset: 0, Length: 32},
},
},
want: "https://telegra.ph/Example-01-02",
},
{
name: "Telegraph URL before another URL",
msg: &tg.Message{
Message: "https://telegra.ph/Example-01-02 https://example.com/",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityURL{Offset: 0, Length: 32},
&tg.MessageEntityURL{Offset: 33, Length: 20},
},
},
want: "https://telegra.ph/Example-01-02",
},
{
name: "hidden Telegraph URL",
msg: &tg.Message{
Message: "article",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityTextURL{
Offset: 0,
Length: 7,
URL: "https://telegra.ph/Hidden-01-02",
},
},
},
want: "https://telegra.ph/Hidden-01-02",
},
{
name: "URL entity after non-BMP character",
msg: &tg.Message{
Message: "😀 https://telegra.ph/Emoji-01-02",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityURL{Offset: 3, Length: 30},
},
},
want: "https://telegra.ph/Emoji-01-02",
},
{
name: "valid Telegraph URL after invalid candidate",
msg: &tg.Message{
Message: "https://telegra.ph/nested/Bad https://telegra.ph/Valid-01-02",
Entities: []tg.MessageEntityClass{
&tg.MessageEntityURL{Offset: 0, Length: 29},
&tg.MessageEntityURL{Offset: 30, Length: 30},
},
},
want: "https://telegra.ph/Valid-01-02",
},
{
name: "plain message fallback",
msg: &tg.Message{Message: "read https://telegra.ph/Plain-01-02 now"},
want: "https://telegra.ph/Plain-01-02",
},
{
name: "nil message",
msg: nil,
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := findTelegraphURL(tt.msg); got != tt.want {
t.Fatalf("findTelegraphURL() = %q, want %q", got, tt.want)
}
})
}
}
func TestParseTelegraphPagePath(t *testing.T) {
tests := []struct {
name string
pageURL string
want string
wantErr bool
}{
{
name: "plain path",
pageURL: "https://telegra.ph/Example-01-02",
want: "Example-01-02",
},
{
name: "escaped path with query and fragment",
pageURL: "https://telegra.ph/%E6%B5%8B%E8%AF%95-01-02?source=telegram#top",
want: "测试-01-02",
},
{
name: "trailing slash",
pageURL: "https://telegra.ph/Example-01-02/",
want: "Example-01-02",
},
{
name: "root URL",
pageURL: "https://telegra.ph/",
wantErr: true,
},
{
name: "wrong host",
pageURL: "https://example.com/Example-01-02",
wantErr: true,
},
{
name: "wrong scheme",
pageURL: "http://telegra.ph/Example-01-02",
wantErr: true,
},
{
name: "invalid percent escape",
pageURL: "https://telegra.ph/Invalid-%zz",
wantErr: true,
},
{
name: "nested path",
pageURL: "https://telegra.ph/nested/Example-01-02",
wantErr: true,
},
{
name: "encoded slash",
pageURL: "https://telegra.ph/nested%2FExample-01-02",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTelegraphPagePath(tt.pageURL)
if tt.wantErr {
if err == nil {
t.Fatalf("parseTelegraphPagePath(%q) returned no error", tt.pageURL)
}
return
}
if err != nil {
t.Fatalf("parseTelegraphPagePath(%q) failed: %v", tt.pageURL, err)
}
if got != tt.want {
t.Fatalf("parseTelegraphPagePath(%q) = %q, want %q", tt.pageURL, got, tt.want)
}
})
}
}

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, 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,338 @@
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/conflictutil"
"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/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"
)
// 创建一个 tfile.TGFileTask 并添加到任务队列中, 以编辑消息的方式反馈结果
func CreateAndAddTGFileTaskWithEdit(ctx *ext.Context, userID int64, stor storage.Storage, dirPath string, file tfile.TGFileMessage, trackMsgID int, conflictStrategy ...string) error {
logger := log.FromContext(ctx)
strategy := selectedConflictStrategy(conflictStrategy)
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
}
strategy = conflictutil.ResolveStrategy(user, strategy)
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 := path.Join(dirPath, file.Name())
if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip {
exists := stor.Exists(ctx, storagePath)
if exists && strategy == tcbdata.ConflictStrategyAsk {
return promptTGFileConflictStrategy(ctx, userID, stor.Name(), dirPath, []tfile.TGFileMessage{file}, false, []string{conflictutil.FormatPath(stor.Name(), storagePath)}, trackMsgID)
}
if exists {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonInfoAllConflictFilesSkipped, map[string]any{
"Skipped": file.Name(),
}),
ReplyMarkup: nil,
})
return dispatcher.EndGroups
}
}
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
if strategy == tcbdata.ConflictStrategyOverwrite {
injectCtx = storage.WithOverwrite(injectCtx)
}
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, conflictStrategy ...string) error {
logger := log.FromContext(ctx)
strategy := selectedConflictStrategy(conflictStrategy)
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
}
strategy = conflictutil.ResolveStrategy(user, strategy)
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
}
skipped := make([]string, 0)
conflicts := make([]string, 0)
elems := make([]batchtfile.TaskElement, 0, len(files))
type albumFile struct {
file tfile.TGFileMessage
storage storage.Storage
dirPath string
}
albumFiles := make(map[int64][]albumFile, 0)
for _, file := range files {
storName, matchedDirPath := 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 !matchedDirPath.NeedNewForAlbum() {
storPath := path.Join(matchedDirPath.String(), file.Name())
if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip {
exists := fileStor.Exists(ctx, storPath)
if exists && strategy == tcbdata.ConflictStrategyAsk {
conflicts = append(conflicts, conflictutil.FormatPath(fileStor.Name(), storPath))
continue
}
if exists {
skipped = append(skipped, file.Name())
continue
}
}
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
}
fileDirPath := matchedDirPath.String()
if matchedDirPath.NeedNewForAlbum() {
fileDirPath = dirPath
}
if _, ok := albumFiles[groupId]; !ok {
albumFiles[groupId] = make([]albumFile, 0)
}
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
file: file,
storage: fileStor,
dirPath: fileDirPath,
})
}
}
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 := path.Join(af.dirPath, albumDir, af.file.Name())
if strategy == tcbdata.ConflictStrategyAsk || strategy == tcbdata.ConflictStrategySkip {
exists := albumStor.Exists(ctx, afstorPath)
if exists && strategy == tcbdata.ConflictStrategyAsk {
conflicts = append(conflicts, conflictutil.FormatPath(albumStor.Name(), afstorPath))
continue
}
if exists {
skipped = append(skipped, af.file.Name())
continue
}
}
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)
}
}
if strategy == tcbdata.ConflictStrategyAsk && len(conflicts) > 0 {
return promptTGFileConflictStrategy(ctx, userID, stor.Name(), dirPath, files, true, conflicts, trackMsgID)
}
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
if strategy == tcbdata.ConflictStrategyOverwrite {
injectCtx = storage.WithOverwrite(injectCtx)
}
if len(elems) == 0 {
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonInfoAllConflictFilesSkipped, map[string]any{
"Skipped": strings.Join(skipped, "\n"),
}),
ReplyMarkup: nil,
})
return dispatcher.EndGroups
}
taskid := xid.New().String()
task := batchtfile.NewBatchTGFileTask(taskid, injectCtx, elems, batchtfile.NewProgressTrackerWithSkipped(trackMsgID, userID, skipped), 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: buildBatchAddedMessage(len(elems), skipped),
ReplyMarkup: nil,
})
return dispatcher.EndGroups
}
func promptTGFileConflictStrategy(ctx *ext.Context, userID int64, storageName, dirPath string, files []tfile.TGFileMessage, asBatch bool, conflicts []string, trackMsgID int) error {
markup, err := msgelem.BuildConflictStrategyMarkup(tcbdata.Add{
TaskType: tasktype.TaskTypeTgfiles,
SelectedStorName: storageName,
SettedDir: true,
SelectedDirPath: dirPath,
Files: files,
AsBatch: asBatch,
})
if err != nil {
return err
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: i18n.T(i18nk.BotMsgCommonPromptSelectConflictStrategy, map[string]any{"Files": conflictutil.FormatPaths(conflicts)}),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func selectedConflictStrategy(strategies []string) string {
if len(strategies) == 0 {
return ""
}
return strategies[0]
}
func buildBatchAddedMessage(count int, skipped []string) string {
if len(skipped) == 0 {
return i18n.T(i18nk.BotMsgCommonInfoBatchTasksAdded, map[string]any{
"Count": count,
})
}
return i18n.T(i18nk.BotMsgCommonInfoBatchTasksAddedWithSkipped, map[string]any{
"Count": count,
"Skipped": strings.Join(skipped, "\n"),
})
}

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

View File

@@ -0,0 +1,435 @@
package handlers
import (
"fmt"
"path"
"regexp"
"strings"
"sync"
"text/template"
"time"
"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/config"
"github.com/krau/SaveAny-Bot/core"
coretfile "github.com/krau/SaveAny-Bot/core/tasks/tfile"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
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
}
type watchMediaGroupHandler struct {
groups map[int64]map[uint][]tfile.TGFileMessage // chatID -> userID -> files
timers map[int64]map[uint]*time.Timer
mu sync.Mutex
}
var watchMediaGroupMgr = &watchMediaGroupHandler{
groups: make(map[int64]map[uint][]tfile.TGFileMessage),
timers: make(map[int64]map[uint]*time.Timer),
}
func (w *watchMediaGroupHandler) addFile(chatID int64, userID uint, file tfile.TGFileMessage, timeout time.Duration, callback func([]tfile.TGFileMessage)) {
w.mu.Lock()
defer w.mu.Unlock()
if w.groups[chatID] == nil {
w.groups[chatID] = make(map[uint][]tfile.TGFileMessage)
}
if w.timers[chatID] == nil {
w.timers[chatID] = make(map[uint]*time.Timer)
}
if timer, exists := w.timers[chatID][userID]; exists {
timer.Stop()
}
w.groups[chatID][userID] = append(w.groups[chatID][userID], file)
w.timers[chatID][userID] = time.AfterFunc(timeout, func() {
w.mu.Lock()
files := w.groups[chatID][userID]
delete(w.groups[chatID], userID)
delete(w.timers[chatID], userID)
if len(w.groups[chatID]) == 0 {
delete(w.groups, chatID)
}
if len(w.timers[chatID]) == 0 {
delete(w.timers, chatID)
}
w.mu.Unlock()
if len(files) > 0 {
callback(files)
}
})
}
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
if userclient.GetCtx() == nil {
return
}
logger := log.FromContext(userclient.GetCtx())
for event := range ch {
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
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
}
// Resolve the default directory path from user.DefaultDir
var defaultDirPath string
if user.DefaultDir != 0 {
dir, err := database.GetDirByID(ctx, user.DefaultDir)
if err != nil {
logger.Warnf("Failed to get default dir for user %d: %v, using root", user.ChatID, err)
} else {
defaultDirPath = dir.Path
}
}
switch user.FilenameStrategy {
case fnamest.Message.String():
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
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())
}
// Check if this is a media group and if rules specify NEW-FOR-ALBUM
groupID, isGroup := file.Message().GetGroupedID()
needAlbumHandling := false
if isGroup && groupID != 0 && user.ApplyRule && user.Rules != nil {
_, _, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
needAlbumHandling = matchedDirPath.NeedNewForAlbum()
}
if needAlbumHandling {
// For media groups with NEW-FOR-ALBUM rule, collect all files of the same group
watchMediaGroupMgr.addFile(event.ChatID, user.ID, file, time.Duration(max(config.C().Telegram.MediaGroupTimeout, 1))*time.Second, func(files []tfile.TGFileMessage) {
processWatchMediaGroup(ctx, user, stor, defaultDirPath, files)
})
continue
}
// Process single file or media group without album folder creation
dirPath := defaultDirPath
if user.ApplyRule && user.Rules != nil {
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
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 := path.Join(dirPath, file.Name())
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task, err := coretfile.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())
}
}
}
func processWatchMediaGroup(ctx *ext.Context, user *database.User, stor storage.Storage, dirPath string, files []tfile.TGFileMessage) {
logger := log.FromContext(ctx)
if len(files) == 0 {
return
}
useRule := user.ApplyRule && user.Rules != nil
applyRule := func(file tfile.TGFileMessage) (string, ruleutil.MatchedDirPath) {
if !useRule {
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
matched, storName, dirP := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
return stor.Name(), ruleutil.MatchedDirPath(dirPath)
}
storname := storName.String()
if !storName.Usable() {
storname = stor.Name()
}
return storname, dirP
}
type albumFile struct {
file tfile.TGFileMessage
storage storage.Storage
dirPath string
}
albumFiles := make(map[int64][]albumFile)
// Collect files by group ID
for _, file := range files {
storName, ruleDirPath := applyRule(file)
fileStor := stor
if storName != stor.Name() && storName != "" {
var err error
fileStor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, storName)
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
continue
}
}
groupId, isGroup := file.Message().GetGroupedID()
if !isGroup || groupId == 0 {
logger.Warnf("File %s is not in a group, skipping", file.Name())
continue
}
// Use the effective dirPath: if rule returns NEW-FOR-ALBUM sentinel, fall back to the
// base dirPath passed in (which is defaultDirPath from the caller).
effectiveDirPath := string(ruleDirPath)
if ruleDirPath.NeedNewForAlbum() {
effectiveDirPath = dirPath
}
if _, ok := albumFiles[groupId]; !ok {
albumFiles[groupId] = make([]albumFile, 0)
}
albumFiles[groupId] = append(albumFiles[groupId], albumFile{
file: file,
storage: fileStor,
dirPath: effectiveDirPath,
})
}
// Process album files with folder creation
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
totalTasks := 0
for groupID, afiles := range albumFiles {
if len(afiles) <= 1 {
continue
}
// Use first file's name (without extension) as album folder name
albumDir := strings.TrimSuffix(path.Base(afiles[0].file.Name()), path.Ext(afiles[0].file.Name()))
albumStor := afiles[0].storage
logger.Infof("Creating album folder for group %d: %s with %d files", groupID, albumDir, len(afiles))
for _, af := range afiles {
afstorPath := path.Join(af.dirPath, albumDir, af.file.Name())
taskid := xid.New().String()
task, err := coretfile.NewTGFileTask(taskid, injectCtx, af.file, albumStor, afstorPath, nil)
if err != nil {
logger.Errorf("create task failed for album file: %s", err)
continue
}
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("add task failed: %s", err)
continue
}
totalTasks++
}
}
logger.Infof("Added %d watch media tasks for user %d", totalTasks, user.ChatID)
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package middlewares
package middleware
import (
"context"
@@ -7,21 +7,22 @@ import (
"github.com/cenkalti/backoff/v4"
"github.com/gotd/contrib/middleware/floodwait"
"github.com/gotd/td/telegram"
"github.com/krau/SaveAny-Bot/userclient/middlewares/recovery"
"github.com/krau/SaveAny-Bot/userclient/middlewares/retry"
"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(5),
recovery.New(ctx, func() backoff.BackOff { return 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

View File

@@ -0,0 +1,19 @@
package middleware
import (
"time"
"github.com/gotd/contrib/middleware/floodwait"
"github.com/gotd/contrib/middleware/ratelimit"
"github.com/gotd/td/telegram"
"golang.org/x/time/rate"
)
func NewFloodWaitMiddlewares(maxRetries uint) []telegram.Middleware {
waiter := floodwait.NewSimpleWaiter().WithMaxRetries(maxRetries)
ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5)
return []telegram.Middleware{
waiter,
ratelimiter,
}
}

View File

@@ -2,44 +2,53 @@ package recovery
import (
"context"
"fmt"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/go-faster/errors"
"github.com/charmbracelet/log"
"github.com/gotd/td/bin"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"github.com/krau/SaveAny-Bot/common"
)
type recovery struct {
ctx context.Context
backoff backoff.BackOff
ctx context.Context
newBackoff func() backoff.BackOff
}
func New(ctx context.Context, backoff backoff.BackOff) telegram.Middleware {
// New returns a recovery middleware.
//
// newBackoff is a factory that must return a fresh backoff.BackOff on every call: backoff implementations in
// cenkalti/backoff/v4 (notably ExponentialBackOff) are not safe for concurrent
// use, and the Telegram client invokes RPCs from many goroutines in parallel.
//
// Sharing a single instance corrupts its internal counters, breaks the
// exponential interval, and defeats MaxElapsedTime - see issue #218.
func New(ctx context.Context, newBackoff func() backoff.BackOff) telegram.Middleware {
return &recovery{
ctx: ctx,
backoff: backoff,
ctx: ctx,
newBackoff: newBackoff,
}
}
func (r *recovery) Handle(next tg.Invoker) telegram.InvokeFunc {
return func(ctx context.Context, input bin.Encoder, output bin.Decoder) error {
b := r.newBackoff()
return backoff.RetryNotify(func() error {
if err := next.Invoke(ctx, input, output); err != nil {
if r.shouldRecover(ctx, err) {
return errors.Wrap(err, "recover")
return fmt.Errorf("recovery: %w", err)
}
return backoff.Permanent(err)
}
return nil
}, r.backoff, func(err error, duration time.Duration) {
common.Log.Debug("Wait for connection recovery", "error", err, "duration", duration)
}, b, func(err error, duration time.Duration) {
log.FromContext(ctx).Debug("Wait for connection recovery", "error", err, "duration", duration)
})
}
}

View File

@@ -4,12 +4,11 @@ import (
"context"
"fmt"
"github.com/go-faster/errors"
"github.com/charmbracelet/log"
"github.com/gotd/td/bin"
"github.com/gotd/td/telegram"
"github.com/gotd/td/tg"
"github.com/gotd/td/tgerr"
"github.com/krau/SaveAny-Bot/common"
)
var internalErrors = []string{
@@ -33,11 +32,12 @@ func (r retry) Handle(next tg.Invoker) telegram.InvokeFunc {
for retries < r.max {
if err := next.Invoke(ctx, input, output); err != nil {
if tgerr.Is(err, r.errors...) {
common.Log.Debug("retry middleware", "retries", retries, "error", err)
log.FromContext(ctx).Debug("retry middleware", "retries", retries, "error", err)
retries++
continue
}
return errors.Wrap(err, "retry middleware skip")
// retry middleware skip
return err
}
return nil

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

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

@@ -0,0 +1,114 @@
package user
import (
"context"
"sync"
"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
// getEctx lazily creates the user-client ext.Context exactly once. Guarded by
// sync.OnceValue so concurrent GetCtx calls cannot race on ectx creation.
var getEctx = sync.OnceValue(func() *ext.Context {
return uc.CreateContext()
})
func GetCtx() *ext.Context {
if uc == nil {
return nil
}
return getEctx()
}
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
}

View File

@@ -1,84 +1,145 @@
// cmd/gen_i18n/main.go
// cmd/geni18n/main.go
package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"go/format"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"github.com/goccy/go-yaml"
)
func main() {
dir := flag.String("dir", "./i18n/locale", "Locales directory path")
out := flag.String("out", "i18n/i18nk/keys.go", "Output file path")
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()
type localeFile struct {
path string
keys map[string]struct{}
}
keys := make(map[string]struct{})
re := regexp.MustCompile(`^\s*\[+\s*([^\]\[]+)\s*\]+`)
var localeFiles []localeFile
err := filepath.WalkDir(*dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".toml") {
if d.IsDir() || !(strings.HasSuffix(d.Name(), ".yaml") || strings.HasSuffix(d.Name(), ".yml")) {
return nil
}
f, err := os.Open(path)
data, err := os.ReadFile(path)
if err != nil {
return err
}
defer f.Close()
s := bufio.NewScanner(f)
for s.Scan() {
if m := re.FindStringSubmatch(s.Text()); m != nil {
keys[m[1]] = struct{}{}
}
var content map[string]any
if err := yaml.Unmarshal(data, &content); err != nil {
return fmt.Errorf("failed to parse yaml %s: %w", path, err)
}
return s.Err()
fileKeys := make(map[string]struct{})
collectKeys(content, "", fileKeys)
localeFiles = append(localeFiles, localeFile{path: path, keys: fileKeys})
for k := range fileKeys {
keys[k] = struct{}{}
}
return nil
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error walking directory: %v\n", err)
os.Exit(1)
}
// 一致性校验: 每个语言文件必须包含全部 key
invalid := false
for _, f := range localeFiles {
var missing []string
for k := range keys {
if _, ok := f.keys[k]; !ok {
missing = append(missing, k)
}
}
if len(missing) > 0 {
invalid = true
sort.Strings(missing)
fmt.Fprintf(os.Stderr, "Error: locale file %s is missing %d key(s): %s\n", f.path, len(missing), strings.Join(missing, ", "))
}
}
if invalid {
os.Exit(1)
}
var list []string
for k := range keys {
list = append(list, k)
}
sort.Strings(list)
f, err := os.Create(*out)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating output file: %v\n", err)
os.Exit(1)
}
defer f.Close()
w := bufio.NewWriter(f)
fmt.Fprintf(w, "// Code generated by cmd/gen_i18n. DO NOT EDIT.\n")
// Generate code to buffer
var buf bytes.Buffer
w := bufio.NewWriter(&buf)
fmt.Fprintf(w, "// Code generated by cmd/geni18n. DO NOT EDIT.\n")
fmt.Fprintf(w, "package %s\n\n", *pkg)
fmt.Fprintf(w, "type Key string\n\n")
fmt.Fprintf(w, "const (\n")
for _, key := range list {
name := toPascal(key)
fmt.Fprintf(w, "\t%s = %q\n", name, key)
fmt.Fprintf(w, "\t%s Key = %q\n", name, key)
}
fmt.Fprintf(w, ")\n")
w.Flush()
// Format the generated code
formatted, err := format.Source(buf.Bytes())
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to format generated code: %v\n", err)
formatted = buf.Bytes()
}
// Write to output file
if err := os.WriteFile(*out, formatted, 0644); err != nil {
fmt.Fprintf(os.Stderr, "Error writing output file: %v\n", err)
os.Exit(1)
}
}
func collectKeys(node map[string]any, prefix string, keys map[string]struct{}) {
for k, v := range node {
fullKey := k
if prefix != "" {
fullKey = prefix + "." + k
}
switch val := v.(type) {
case map[string]any:
collectKeys(val, fullKey, keys)
default:
keys[fullKey] = struct{}{}
}
}
}
// 转 PascalCase
func toPascal(key string) string {
parts := strings.Split(key, ".")
for i, p := range parts {
if len(p) > 0 {
parts[i] = strings.ToUpper(string(p[0])) + p[1:]
subs := strings.Split(p, "_")
for j, s := range subs {
if len(s) > 0 {
subs[j] = strings.ToUpper(s[:1]) + s[1:]
}
}
parts[i] = strings.Join(subs, "")
}
return strings.Join(parts, "")
}

View File

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

View File

@@ -2,91 +2,120 @@ package cmd
import (
"context"
"fmt"
"os"
"os/signal"
"path/filepath"
"syscall"
"strings"
"time"
"slices"
"github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/api"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/i18n"
"github.com/krau/SaveAny-Bot/i18n/i18nk"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/parsers"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/userclient"
"github.com/spf13/cobra"
)
func Run(_ *cobra.Command, _ []string) {
InitAll()
core.Run()
func Run(cmd *cobra.Command, _ []string) {
ctx, cancel := context.WithCancel(cmd.Context())
logger := log.NewWithOptions(os.Stdout, log.Options{
Level: log.InfoLevel,
ReportTimestamp: true,
TimeFormat: time.TimeOnly,
ReportCaller: true,
})
log.SetDefault(logger)
ctx = log.WithContext(ctx, logger)
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
common.Log.Info(sig, i18n.T(i18nk.Exiting))
defer common.Log.Info(i18n.T(i18nk.Bye))
if config.Cfg.NoCleanCache {
configFile := config.GetConfigFile(cmd)
if err := config.Init(ctx, configFile); err != nil {
logger.Fatal("Init failed", "error", err)
}
level, err := log.ParseLevel(strings.TrimSpace(config.C().Log.Level))
if err != nil {
logger.Warn("Invalid log level, fallback to debug", "level", config.C().Log.Level, "error", err)
level = log.DebugLevel
}
logger.SetLevel(level)
exitChan, err := initAll(ctx)
if err != nil {
logger.Fatal("Init failed", "error", err)
}
go func() {
<-exitChan
cancel()
}()
core.Run(ctx)
<-ctx.Done()
logger.Info("Exiting...")
defer logger.Info("Exit complete")
core.Close()
cleanCache()
}
func initAll(ctx context.Context) (<-chan struct{}, error) {
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)
}
}
if err := api.Start(ctx); err != nil {
logger.Fatal("Failed to start API server", "error", err)
}
return bot.Init(ctx), nil
}
func cleanCache() {
if config.C().NoCleanCache {
return
}
if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream {
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) {
common.Log.Error(i18n.T(i18nk.InvalidCacheDir, map[string]any{
"Path": config.Cfg.Temp.BasePath,
}))
if config.C().Temp.BasePath != "" && !config.C().Stream {
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
log.Error("Invalid cache directory", "path", config.C().Temp.BasePath)
return
}
currentDir, err := os.Getwd()
if err != nil {
common.Log.Error(i18n.T(i18nk.GetWorkdirFailed, map[string]any{
"Error": err,
}))
log.Error("Failed to get working directory", "error", err)
return
}
cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath)
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
cachePath, err = filepath.Abs(cachePath)
if err != nil {
common.Log.Error(i18n.T(i18nk.GetCacheAbsPathFailed, map[string]any{
"Error": err,
}))
log.Error("Failed to get absolute cache path", "error", err)
return
}
common.Log.Info(i18n.T(i18nk.CleaningCache, map[string]any{
"Path": cachePath,
}))
if err := common.RemoveAllInDir(cachePath); err != nil {
common.Log.Error(i18n.T(i18nk.CleanCacheFailed, map[string]any{
"Error": err,
}))
log.Info("Cleaning cache directory", "path", cachePath)
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
log.Error("Failed to clean cache directory", "error", err)
}
}
}
func InitAll() {
if err := config.Init(); err != nil {
fmt.Println("Failed to load config:", err)
os.Exit(1)
}
common.InitLogger()
i18n.Init(config.Cfg.Lang)
common.Log.Info(i18n.T(i18nk.Initing))
dao.Init()
storage.LoadStorages()
common.Init()
if config.Cfg.Telegram.Userbot.Enable {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
uc, err := userclient.Login(ctx)
if err != nil {
common.Log.Errorf("User client login failed: %s", err)
os.Exit(1)
}
common.Log.Infof("User client logged in as %s", uc.Self.FirstName)
}
bot.Init()
}

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

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

View File

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

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