Compare commits

..

313 Commits

Author SHA1 Message Date
krau
c7a0076c15 fix: trim space in telegraph dir path , close #119 2025-10-03 09:32:45 +08:00
krau
ea07ff7eca fix: remove unused ChatTitle field from FilenameTemplateData struct 2025-09-30 21:36:23 +08:00
krau
4d837e946c feat: add .chatid variable in file name template 2025-09-30 21:34:35 +08:00
krau
f947ee6fc7 fix: implement filename strategy in userbot listen mode 2025-09-28 16:49:32 +08:00
krau
40ad12a892 fix: update SOCKS5 proxy dialer implementation for consistency 2025-09-18 22:28:25 +08:00
krau
697e419643 feat: refactor command registration to use a centralized handler list 2025-09-13 10:37:43 +08:00
Krau
eef051de3b feat: custom filename template (#110) 2025-09-13 10:25:45 +08:00
krau
6e29442c05 fix: update Docker deployment instructions for userbot integration 2025-09-13 10:21:10 +08:00
krau
a3f1f75caf fix: update initialization error message for clarity, close #108 2025-09-13 10:19:00 +08:00
Krau
f05dd883e3 feat: enhance URL handling by adding utility functions and filters for message entities (#105) 2025-09-09 20:16:56 +08:00
dependabot[bot]
9cb866de8c chore(deps): bump github.com/ulikunitz/xz from 0.5.12 to 0.5.14 (#102)
Bumps [github.com/ulikunitz/xz](https://github.com/ulikunitz/xz) from 0.5.12 to 0.5.14.
- [Commits](https://github.com/ulikunitz/xz/compare/v0.5.12...v0.5.14)

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

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

* fix: use json to marshal js result

* feat: add metadata handling and version validation for jsParser

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

* refactor: core code struct and impl parse task handle

* feat: impl parsed download

* fix: seek cache file when processing tph picture

* feat: implement parsed task handling and progress tracking

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

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

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

* feat: add example js plugin

* feat: implement Twitter parser

* fix: twitter parse video json decode error

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

* refactor: port handle file

* refactor: place all handlers

* fix: task info nil pointer

* feat: enhance task progress tracking and context management

* feat: cancel task

* feat: stream mode

* feat: silent mode

* feat: dir cmd

* refactor: remove unused old file

* feat: rule cmd

* feat: handle silent mode

* feat: batch task

* fix: batch task progress and temp file cleanup

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

* feat: add save command with silent mode handling

* feat: message link

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

* feat: slient save links

* refactor: reduce dup code

* feat: rule type

* feat: chose dir

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

* feat: rule mode

* feat: telegraph pics

* fix: tphpics nil pointer and inaccurate dirpath

* feat: silent save telegraph

* feat: add suffix to avoid file overwrite

* feat: new storage telegram

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

* Add files via upload

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

* add more detailed instructions.

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-12 13:41:45 +00:00
krau
169197c8fe chore: update readme 2024-12-05 09:36:06 +08:00
krau
a166ff1e64 fix: improve error messages and update reply handling in bot commands
chore: upgrade deps
2024-12-02 13:41:10 +08:00
krau
f360052784 fix: typo 2024-12-02 13:40:42 +08:00
krau
15f263cb50 chore: update readme 2024-11-10 15:57:08 +08:00
krau
420e0b1a4f chore: update readme 2024-11-10 15:41:14 +08:00
krau
71bd6a9480 chore: update readme 2024-11-10 15:36:47 +08:00
krau
5b9e177af4 chore: add demo 2024-11-10 15:35:14 +08:00
krau
9e226ae592 refactor(webdav): use write stream to upload file 2024-11-09 13:18:22 +08:00
krau
a91cce9680 fix: error type in any update 2024-11-09 12:56:03 +08:00
krau
1128ee2081 fix: update reply message id in callback query 2024-11-09 11:38:29 +08:00
krau
e3cd659eb3 feat: save file by cmd 2024-11-09 11:34:28 +08:00
krau
454d69c9d4 refactor: update logging levels and remove unused code 2024-11-09 11:00:49 +08:00
krau
71e5007228 feat: show download progress 2024-11-09 10:46:11 +08:00
krau
3e5cd60cf7 refactor: update config and readme 2024-11-09 09:43:56 +08:00
Krau
0297fdce71 Update LICENSE 2024-11-09 09:39:18 +08:00
krau
20e06fbf46 refactor: complete core features 2024-11-09 09:07:00 +08:00
krau
fbdfc04ad8 refactor: migrate to gotd (wip) 2024-11-08 23:00:57 +08:00
krau
32bd391129 update deps 2024-11-03 00:38:25 +08:00
krau
183edd0dbe update readme 2024-10-17 15:42:30 +08:00
220 changed files with 13631 additions and 1544 deletions

11
.dockerignore Normal file
View File

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

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

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

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

@@ -0,0 +1,39 @@
name: "👾 报告 bug"
description: "报告 bug"
labels:
- "bug"
assignees:
- krau
body:
- type: textarea
attributes:
label: "👾 问题描述"
description: "What happened?"
placeholder: "When called ... happens ..."
validations:
required: true
- type: textarea
attributes:
label: "⚡️ 预期行为"
description: "What was expected?"
placeholder: "It should be ..."
- type: textarea
attributes:
label: "📄 配置文件"
description: "Please provide your config file"
placeholder: "请自行隐去密钥信息"
render: toml
validations:
required: true
- type: textarea
attributes:
label: "🔍 日志"
description: "Please provide logs"
placeholder: "可删除隐私信息"
render: shell
validations:
required: true
- type: markdown
attributes:
value: |
## Thank you for contributing to the project :slightly_smiling_face:

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

@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 💬 不知道如何正确使用?
url: https://github.com/krau/SaveAny-Bot/discussions
about: "前往讨论区提问"
- name: 📄 文档
url: https://sabot.unv.app
about: "查看文档"

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

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

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

@@ -0,0 +1,69 @@
name: Build and Publish Docker Image
on:
push:
tags:
- "v*"
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Dockerfile args
id: args
run: |
echo "git_commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
echo "build_time=$(git show -s --format=%cI)" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
id: build-and-push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: |
type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
type=gha
cache-to: type=gha,mode=max
build-args: |
VERSION=${{ steps.meta.outputs.version }}
GitCommit=${{ steps.args.outputs.git_commit }}
BuildTime=${{ steps.args.outputs.build_time }}

View File

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

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

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

View File

@@ -0,0 +1,17 @@
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 }}

6
.gitignore vendored
View File

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

15
.vscode/launch.json vendored
View File

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

38
Dockerfile Normal file
View File

@@ -0,0 +1,38 @@
FROM golang:alpine AS builder
ARG VERSION="dev"
ARG GitCommit="Unknown"
ARG BuildTime="Unknown"
WORKDIR /app
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/go/pkg \
CGO_ENABLED=0 \
go build -trimpath \
-ldflags=" \
-s -w \
-X 'github.com/krau/SaveAny-Bot/config.Version=${VERSION}' \
-X 'github.com/krau/SaveAny-Bot/config.GitCommit=${GitCommit}' \
-X 'github.com/krau/SaveAny-Bot/config.BuildTime=${BuildTime}' \
" \
-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"]

143
LICENSE
View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

145
README.md
View File

@@ -1,35 +1,138 @@
# Save Any Bot
<div align="center">
把 Telegram 的文件保存到各类存储端.
# <img src="docs/static/logo.png" width="45" align="center"> Save Any Bot
> _就像 PikPak Bot 一样_
**简体中文** | [English](https://sabot.unv.app/en/)
## 部署
> **把 Telegram 上的文件转存到多种存储端.**
[Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件.
[![Release Date](https://img.shields.io/github/release-date/krau/saveany-bot?label=release)](https://github.com/krau/saveany-bot/releases)
[![tag](https://img.shields.io/github/v/tag/krau/saveany-bot.svg)](https://github.com/krau/saveany-bot/releases)
[![Build Status](https://img.shields.io/github/actions/workflow/status/krau/saveany-bot/build-release.yml)](https://github.com/krau/saveany-bot/actions/workflows/build-release.yml)
[![Stars](https://img.shields.io/github/stars/krau/saveany-bot?style=flat)](https://github.com/krau/saveany-bot/stargazers)
[![Downloads](https://img.shields.io/github/downloads/krau/saveany-bot/total)](https://github.com/krau/saveany-bot/releases)
[![Issues](https://img.shields.io/github/issues/krau/saveany-bot)](https://github.com/krau/saveany-bot/issues)
[![Pull Requests](https://img.shields.io/github/issues-pr/krau/saveany-bot?label=pr)](https://github.com/krau/saveany-bot/pulls)
[![License](https://img.shields.io/github/license/krau/saveany-bot)](./LICENSE)
在解压后目录新建 `config.toml` 文件, 参考 [config.toml.example](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件.
</div>
运行:
## 🎯 Features
```bash
chmod +x saveany-bot
./saveany-bot
```
- 支持文档/视频/图片/贴纸…甚至还有 [Telegraph](https://telegra.ph/)
- 破解禁止保存的文件
- 批量下载
- 流式传输
- 多用户使用
- 基于存储规则的自动整理
- 监听并自动转存指定聊天的消息, 支持过滤
- 使用 js 编写解析器插件以转存任意网站的文件
- 存储端支持:
- Alist
- S3 (MinioSDK)
- WebDAV
- 本地磁盘
- Telegram (重传回指定聊天)
## 使用
## 📦 Quick Start
向 Bot 发送(转发)文件, 按照提示操作.
## Bot API 版本 (v0.3.0 前)
> Bot API 版本自身不需要 API_ID 和 API_HASH, 但是部署 Telegram Bot API 服务器仍然需要.
由于 Telegram 官方 Bot API 的限制, Bot 无法下载大于 20MB 的文件. 你需要部署一个本地的 Telegram Bot API 来解决这个问题, 然后在配置文件改为你自己的 api 地址
创建文件 `config.toml` 并填入以下内容:
```toml
[telegram]
api = "http://localhost:8081"
token = "" # 你的 Bot Token, 在 @BotFather 获取
[telegram.proxy]
# 启用代理连接 telegram, 当前只支持 socks5
enable = false
url = "socks5://127.0.0.1:7890"
[[storages]]
name = "本地磁盘"
type = "local"
enable = true
base_path = "./downloads"
[[users]]
id = 114514 # 你的 Telegram 账号 id
storages = []
blacklist = true
```
参考: [telegram-bot-api-compose](https://github.com/krau/telegram-bot-api-compose)
使用 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/) 以获取更多配置选项和使用方法.
## Sponsors
本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持.
如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我:
- [爱发电](https://afdian.com/a/unvapp)
## Contributors
<!-- readme: contributors -start -->
<table>
<tbody>
<tr>
<td align="center">
<a href="https://github.com/krau">
<img src="https://avatars.githubusercontent.com/u/71133316?v=4" width="100;" alt="krau"/>
<br />
<sub><b>Krau</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Silentely">
<img src="https://avatars.githubusercontent.com/u/22141172?v=4" width="100;" alt="Silentely"/>
<br />
<sub><b>Abner</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/TG-Twilight">
<img src="https://avatars.githubusercontent.com/u/121682528?v=4" width="100;" alt="TG-Twilight"/>
<br />
<sub><b>Simon Twilight</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/ysicing">
<img src="https://avatars.githubusercontent.com/u/8605565?v=4" width="100;" alt="ysicing"/>
<br />
<sub><b>缘生</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/AHCorn">
<img src="https://avatars.githubusercontent.com/u/42889600?v=4" width="100;" alt="AHCorn"/>
<br />
<sub><b>安和</b></sub>
</a>
</td>
</tr>
<tbody>
</table>
<!-- readme: contributors -end -->
## Thanks
- [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
## 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,21 +0,0 @@
package bootstrap
import (
"github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/storage"
)
func InitAll() {
config.Init()
logger.InitLogger()
logger.L.Info("Running...")
common.Init()
storage.Init()
dao.Init()
bot.Init()
}

View File

@@ -1,59 +0,0 @@
package bot
import (
"os"
"github.com/amarnathcjd/gogram/telegram"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger"
)
var (
Client *telegram.Client
)
func Init() {
logger.L.Debug("Initializing bot...")
var err error
Client, err = telegram.NewClient(telegram.ClientConfig{
AppID: config.Cfg.Telegram.AppID,
AppHash: config.Cfg.Telegram.AppHash,
LogLevel: telegram.LogInfo,
})
if err != nil {
logger.L.Fatal("Failed to create telegram client: ", err)
os.Exit(1)
}
if err := Client.LoginBot(config.Cfg.Telegram.Token); err != nil {
logger.L.Fatal("Failed to login bot: ", err)
os.Exit(1)
}
logger.L.Info("Bot logged in")
_, err = Client.BotsSetBotCommands(&telegram.BotCommandScopeDefault{}, "", []*telegram.BotCommand{
{Command: "start", Description: "开始使用"},
{Command: "help", Description: "显示帮助"},
{Command: "silent", Description: "静默模式"},
{Command: "storage", Description: "设置默认存储位置"},
{Command: "save", Description: "保存所回复文件"},
})
if err != nil {
logger.L.Errorf("Failed to set bot commands: ", err)
}
logger.L.Info("Bot initialized")
}
func Run() {
if Client == nil {
Init()
}
Client.On("command:start", Start, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On("command:help", Help, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On("command:silent", ChangeSilentMode, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On("command:storage", SetDefaultStorage, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On("command:save", SaveCmd, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...))
Client.On(telegram.OnMessage, HandleFileMessage, telegram.FilterPrivate, telegram.FilterChats(config.Cfg.Telegram.Admins...), telegram.FilterMedia)
Client.On("callback:add", AddToQueue)
Client.Idle()
}

View File

@@ -1,271 +0,0 @@
package bot
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/amarnathcjd/gogram/telegram"
"github.com/duke-git/lancet/v2/slice"
"github.com/gookit/goutil/maputil"
"github.com/krau/SaveAny-Bot/dao"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/model"
"github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
"github.com/mymmrac/telego/telegoutil"
)
func Start(message *telegram.NewMessage) error {
if err := dao.CreateUser(message.ChatID()); err != nil {
logger.L.Errorf("Failed to create user: %s", err)
return err
}
return Help(message)
}
func Help(message *telegram.NewMessage) error {
helpText := `
SaveAny Bot - 转存你的 Telegram 文件
命令:
/start - 开始使用
/help - 显示帮助
/silent - 静默模式
/storage - 设置默认存储位置
/save - 保存文件
静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问
`
if _, err := message.Reply(helpText); err != nil {
logger.L.Errorf("Failed to send help message: %s", err)
return err
}
return nil
}
func ChangeSilentMode(message *telegram.NewMessage) error {
user, err := dao.GetUserByUserID(message.ChatID())
if err != nil {
logger.L.Error(err)
return err
}
user.Silent = !user.Silent
err = dao.UpdateUser(user)
if err != nil {
logger.L.Error(err)
return err
}
if _, err := message.Reply(fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])); err != nil {
return err
}
return nil
}
func SetDefaultStorage(message *telegram.NewMessage) error {
if len(storage.Storages) == 0 {
message.Reply("当前无可用存储端, 请检查配置.")
return nil
}
_, _, args := telegoutil.ParseCommand(message.Text())
availableStorages := maputil.Keys(storage.Storages)
if len(args) == 0 {
text := "请提供存储位置名称, 可用项:"
for _, name := range availableStorages {
text += fmt.Sprintf("\n`%s`", name)
}
text += fmt.Sprintf("\n`all`")
message.Reply(text, telegram.SendOptions{ParseMode: telegram.MarkDown})
return nil
}
storageName := args[0]
if !slice.Contain(availableStorages, storageName) {
message.Reply("参数错误")
return nil
}
user, err := dao.GetUserByUserID(message.ChatID())
if err != nil {
logger.L.Error(err)
return err
}
user.DefaultStorage = storageName
err = dao.UpdateUser(user)
if err != nil {
logger.L.Error(err)
return err
}
if _, err := message.Reply(fmt.Sprintf("已设置默认存储位置为: %s", storageName)); err != nil {
return err
}
return nil
}
func SaveCmd(message *telegram.NewMessage) error {
targetMessage, err := message.GetReplyMessage()
if err != nil {
message.Reply("请回复要保存的文件")
return nil
}
if !targetMessage.IsMedia() {
message.Reply("回复的消息不包含文件")
return nil
}
msg, err := targetMessage.Reply("正在获取文件信息...")
if err != nil {
logger.L.Error(err)
message.Reply("获取文件信息失败")
return err
}
_, _, _, fileName, err := telegram.GetFileLocation(targetMessage.Media())
if err != nil {
logger.L.Error(err)
targetMessage.Reply("获取文件信息失败")
return err
}
if fileName == "" {
logger.L.Error("Empty file name")
targetMessage.Reply("文件名为空")
return nil
}
if err := dao.AddReceivedFile(&model.ReceivedFile{
Processing: false,
FileName: fileName,
ChatID: targetMessage.ChatID(),
MessageID: targetMessage.Message.ID,
ReplyMessageID: msg.ID,
}); err != nil {
logger.L.Error(err)
msg.Edit("保存文件信息失败")
return err
}
user, err := dao.GetUserByUserID(message.ChatID())
if err != nil {
logger.L.Error(err)
msg.Edit("获取用户信息失败")
return err
}
if !user.Silent {
msg.Edit("请选择要保存的位置:", telegram.SendOptions{
ReplyMarkup: AddTaskReplyMarkup(targetMessage.Message.ID),
})
return nil
}
if user.DefaultStorage == "" {
msg.Edit("请先使用 /storage 命令设置默认存储位置, 或者关闭静默模式")
return nil
}
queue.AddTask(types.Task{
Ctx: context.TODO(),
Status: types.Pending,
FileName: fileName,
Storage: types.StorageType(user.DefaultStorage),
ChatID: targetMessage.ChatID(),
MessageID: targetMessage.Message.ID,
ReplyMessageID: msg.ID,
})
msg.Edit(fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", fileName, queue.Len()))
return nil
}
func HandleFileMessage(message *telegram.NewMessage) error {
if !message.IsMedia() {
return nil
}
user, err := dao.GetUserByUserID(message.ChatID())
if err != nil {
logger.L.Error(err)
return nil
}
msg, err := message.Reply("正在获取文件信息...")
if err != nil {
logger.L.Error(err)
return err
}
_, _, _, fileName, err := telegram.GetFileLocation(message.Media())
if err != nil {
logger.L.Error(err)
message.Reply("获取文件信息失败")
return err
}
if fileName == "" {
logger.L.Error("Empty file name")
message.Reply("文件名为空")
return nil
}
if err := dao.AddReceivedFile(&model.ReceivedFile{
Processing: false,
FileName: fileName,
ChatID: message.ChatID(),
MessageID: message.Message.ID,
ReplyMessageID: msg.ID,
}); err != nil {
logger.L.Error(err)
msg.Edit("保存文件信息失败")
return err
}
if !user.Silent {
msg.Edit("请选择要保存的位置:", telegram.SendOptions{
ReplyMarkup: AddTaskReplyMarkup(message.Message.ID),
})
return nil
}
if user.DefaultStorage == "" {
msg.Edit("请先使用 /storage 命令设置默认存储位置, 或者关闭静默模式")
return nil
}
queue.AddTask(types.Task{
Ctx: context.TODO(),
Status: types.Pending,
FileName: fileName,
Storage: types.StorageType(user.DefaultStorage),
ChatID: message.ChatID(),
MessageID: message.Message.ID,
ReplyMessageID: msg.ID,
})
msg.Edit(fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", fileName, queue.Len()))
return nil
}
func AddToQueue(query *telegram.CallbackQuery) error {
args := strings.Split(query.DataString(), " ")
messageID, _ := strconv.Atoi(args[1])
logger.L.Debug(query.ChatID, messageID)
receivedFile, err := dao.GetReceivedFileByChatAndMessageID(query.ChatID, int32(messageID))
if err != nil {
logger.L.Error(err)
query.Answer("获取文件信息失败", &telegram.CallbackOptions{
Alert: true,
CacheTime: 5,
})
return err
}
queue.AddTask(types.Task{
Ctx: context.TODO(),
Status: types.Pending,
FileName: receivedFile.FileName,
Storage: types.StorageType(args[2]),
ChatID: receivedFile.ChatID,
MessageID: receivedFile.MessageID,
ReplyMessageID: receivedFile.ReplyMessageID,
})
query.Edit(fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", receivedFile.FileName, queue.Len()))
return nil
}

View File

@@ -1,62 +0,0 @@
package bot
import (
"fmt"
"regexp"
"github.com/amarnathcjd/gogram/telegram"
"github.com/krau/SaveAny-Bot/storage"
)
var StorageDisplayNames = map[string]string{
"all": "全部",
"local": "服务器磁盘",
"alist": "Alist",
"webdav": "WebDAV",
}
func AddTaskReplyMarkup(messageID int32) telegram.ReplyMarkup {
// TODO: sort storage buttons
storageButtons := make([]telegram.KeyboardButton, 0)
for name := range storage.Storages {
storageButtons = append(storageButtons, &telegram.KeyboardButtonCallback{
Text: StorageDisplayNames[string(name)],
Data: []byte(fmt.Sprintf("add %d %s", messageID, name)),
})
}
if len(storageButtons) > 1 {
return &telegram.ReplyInlineMarkup{
Rows: []*telegram.KeyboardButtonRow{
{
Buttons: storageButtons,
},
{
Buttons: []telegram.KeyboardButton{
&telegram.KeyboardButtonCallback{
Text: "全部",
Data: []byte(fmt.Sprintf("add %d all", messageID)),
},
},
},
},
}
}
if len(storageButtons) == 1 {
return &telegram.ReplyInlineMarkup{
Rows: []*telegram.KeyboardButtonRow{
{
Buttons: storageButtons,
},
},
}
}
return nil
}
var markdownRe = regexp.MustCompile("([" + regexp.QuoteMeta(`\_*[]()~`+"`"+`>#+-=|{}.!`) + "])")
func EscapeMarkdown(text string) string {
return markdownRe.ReplaceAllString(text, "\\$1")
}

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

@@ -0,0 +1,103 @@
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/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy"
)
func Init(ctx context.Context) <-chan struct{} {
log.FromContext(ctx).Info("初始化 Bot...")
resultChan := make(chan struct {
client *gotgproto.Client
err error
})
shouldRestart := make(chan struct{})
go func() {
var resolver dcs.Resolver
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
} else {
resolver = dcs.DefaultResolver()
}
client, err := gotgproto.NewClient(
config.C().Telegram.AppID,
config.C().Telegram.AppHash,
gotgproto.ClientTypeBot(config.C().Telegram.Token),
&gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(gormlite.Open(config.C().DB.Session)),
DisableCopyright: true,
Middlewares: middleware.NewDefaultMiddlewares(ctx, 5*time.Minute),
Resolver: resolver,
Context: ctx,
MaxRetries: config.C().Telegram.RpcRetry,
AutoFetchReply: true,
ErrorHandler: func(ctx *ext.Context, u *ext.Update, s string) error {
if s == "SAVEANTBOT-RESTART" {
shouldRestart <- struct{}{}
return dispatcher.EndGroups
}
log.FromContext(ctx).Errorf("unhandled error: %s", s)
return dispatcher.EndGroups
},
},
)
if err != nil {
resultChan <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{
Scope: &tg.BotCommandScopeDefault{},
})
commands := make([]tg.BotCommand, 0, len(handlers.CommandHandlers))
for _, info := range handlers.CommandHandlers {
commands = append(commands, tg.BotCommand{Command: info.Cmd, Description: 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 初始化: %s", ctx.Err())
case result := <-resultChan:
if result.err != nil {
log.FromContext(ctx).Fatalf("初始化 Bot 失败: %s", result.err)
}
handlers.Register(result.client.Dispatcher)
log.FromContext(ctx).Info("Bot 初始化完成")
}
return shouldRestart
}

View File

@@ -0,0 +1,87 @@
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/utils/fsutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/storage"
"gorm.io/gorm"
)
func handleAddCallback(ctx *ext.Context, update *ext.Update) error {
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
data, err := shortcut.GetCallbackDataWithAnswer[tcbdata.Add](ctx, update, dataid)
if err != nil {
return err
}
queryID := update.CallbackQuery.GetQueryID()
msgID := update.CallbackQuery.GetMsgID()
userID := update.CallbackQuery.GetUserID()
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, data.SelectedStorName)
if err != nil {
log.FromContext(ctx).Errorf("Failed to get storage: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "存储获取失败: "+err.Error()))
return dispatcher.EndGroups
}
dirs, err := database.GetDirsByUserChatIDAndStorageName(ctx, userID, data.SelectedStorName)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("获取用户目录失败: %w", err)
}
if !data.SettedDir && len(dirs) != 0 {
// ask for directory selection
markup, err := msgelem.BuildSetDirKeyboard(dirs, dataid)
if err != nil {
log.FromContext(ctx).Errorf("Failed to build directory keyboard: %s", err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(queryID, "目录键盘构建失败: "+err.Error()))
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "请选择要存储到的目录",
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, "获取目录失败: "+err.Error()))
return dispatcher.EndGroups
}
dirPath = dir.Path
}
switch data.TaskType {
case tasktype.TaskTypeTgfiles:
if data.AsBatch {
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files, msgID)
}
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userID, selectedStorage, dirPath, data.Files[0], msgID)
case tasktype.TaskTypeTphpics:
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, data.TphPageNode, data.TphDirPath, data.TphPics, selectedStorage, msgID)
case tasktype.TaskTypeParseditem:
if len(data.ParsedItem.Resources) > 1 {
dirPath = path.Join(dirPath, fsutil.NormalizePathname(data.ParsedItem.Title))
}
shortcut.CreateAndAddParsedTaskWithEdit(ctx, selectedStorage, dirPath, data.ParsedItem, msgID, userID)
default:
log.FromContext(ctx).Errorf("Unsupported task type: %s", data.TaskType)
}
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,28 @@
package handlers
import (
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/core"
)
func handleCancelCallback(ctx *ext.Context, update *ext.Update) error {
taskid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
if err := core.CancelTask(ctx, taskid); err != nil {
log.FromContext(ctx).Errorf("error cancelling task %s: %v", taskid, err)
ctx.AnswerCallback(msgelem.AlertCallbackAnswer(update.CallbackQuery.GetQueryID(), "取消任务失败: "+err.Error()))
return dispatcher.EndGroups
}
ctx.EditMessage(update.CallbackQuery.GetUserID(), &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "正在取消任务...",
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,144 @@
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/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
func handleConfigCmd(ctx *ext.Context, update *ext.Update) error {
ctx.Reply(update, ext.ReplyTextString("请选择要配置的选项"), &ext.ReplyOpts{
Markup: &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: "文件名策略",
Data: fmt.Appendf(nil, "%s %s", tcbdata.TypeConfig, "fnamest"),
},
},
},
},
},
})
return dispatcher.EndGroups
}
func handleConfigCallback(ctx *ext.Context, update *ext.Update) error {
args := strings.Fields(string(update.CallbackQuery.Data))
invaildDataAnswer := func() error {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "无效的回调数据",
CacheTime: 5,
})
return dispatcher.EndGroups
}
if len(args) < 2 {
return invaildDataAnswer()
}
switch args[1] {
case "fnamest":
return handleConfigFnameSTCallback(ctx, update)
default:
return invaildDataAnswer()
}
}
func handleConfigFnameSTCallback(ctx *ext.Context, update *ext.Update) error {
userID := update.CallbackQuery.GetUserID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
args := strings.Fields(string(update.CallbackQuery.Data))
if len(args) == 3 {
selected := args[2]
st, err := fnamest.ParseFnameST(selected)
if err != nil {
return err
}
user.FilenameStrategy = st.String()
if err := database.UpdateUser(ctx, user); err != nil {
return err
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("已将文件名策略设置为: %s", fnamest.FnameSTDisplay[st]),
})
return dispatcher.EndGroups
}
opts := fnamest.FnameSTValues()
buttons := make([]tg.KeyboardButtonClass, 0, len(opts))
for _, opt := range opts {
buttons = append(buttons, &tg.KeyboardButtonCallback{
Text: fnamest.FnameSTDisplay[opt],
Data: fmt.Appendf(nil, "%s %s %s", tcbdata.TypeConfig, "fnamest", opt),
})
}
markup := &tg.ReplyInlineMarkup{Rows: []tg.KeyboardButtonRow{
{Buttons: buttons},
}}
currentStStr := user.FilenameStrategy
if currentStStr == "" {
currentStStr = fnamest.Default.String()
}
currentSt, err := fnamest.ParseFnameST(currentStStr)
if err != nil {
currentSt = fnamest.Default
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("请选择文件名策略, 当前策略: %s", fnamest.FnameSTDisplay[currentSt]),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
func handleConfigFnameTmpl(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
return err
}
args := strings.Fields(string(update.EffectiveMessage.Text))
if len(args) <= 1 {
text := `使用该命令设置文件名模板, 示例:
/fnametmpl 图片_{{.msgid}}_{{.msgdate}}.jpg
可用变量:
- {{.msgid}}: 消息ID
- {{.msgtags}}: 消息中的标签, 将以下划线分隔输出
- {{.msggen}}: 根据消息生成的文件名
- {{.msgdate}}: 消息日期, 格式 YYYY-MM-DD_HH-MM-SS
- {{.origname}}: 媒体的原始文件名 (如果有)
- {{.chatid}}: 消息的聊天ID
`
if user.FilenameTemplate != "" {
text += fmt.Sprintf("\n\n当前模板: %s", user.FilenameTemplate)
}
text += "\n\n模板仅在文件名策略设置为 '自定义模板' 时生效, 且模板解析错误时会回退到默认文件名"
ctx.Reply(update, ext.ReplyTextString(text), nil)
return dispatcher.EndGroups
}
newTmpl := strings.Join(args[1:], " ")
_, err = template.New("filename").Parse(newTmpl)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的模板, 请检查语法\n"+err.Error()), nil)
return dispatcher.EndGroups
}
user.FilenameTemplate = newTmpl
if err := database.UpdateUser(ctx, user); err != nil {
return err
}
ctx.Reply(update, ext.ReplyTextString("已更新文件名模板"), nil)
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,74 @@
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/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("获取用户文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户文件夹失败"), 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("获取用户失败: %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 {
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildDirHelpStyling(dirs)), nil)
return dispatcher.EndGroups
}
if _, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, args[2]); err != nil {
ctx.Reply(update, ext.ReplyTextString(err.Error()), nil)
return dispatcher.EndGroups
}
if err := database.CreateDirForUser(ctx, user.ID, args[2], args[3]); err != nil {
logger.Errorf("创建文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建文件夹失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("文件夹添加成功"), 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("文件夹ID无效"), nil)
return dispatcher.EndGroups
}
if err := database.DeleteDirByID(ctx, uint(dirID)); err != nil {
logger.Errorf("删除文件夹失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除文件夹失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("文件夹删除成功"), nil)
default:
ctx.Reply(update, ext.ReplyTextString("未知操作"), nil)
}
return dispatcher.EndGroups
}

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,62 @@
package handlers
import (
"fmt"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"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("构建存储选择消息失败: %s", err)
editReplied("构建存储选择消息失败: "+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("构建存储选择键盘失败: %s", err)
editReplied("构建存储选择键盘失败: "+err.Error(), nil)
return dispatcher.EndGroups
}
editReplied(fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)), markup)
return dispatcher.EndGroups
}
func handleSilentSaveLink(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
replied, files, _, err := shortcut.GetFilesFromUpdateLinkMessageWithReplyEdit(ctx, update)
if err != nil {
return err
}
userId := update.GetUserChat().GetID()
if len(files) == 1 {
return shortcut.CreateAndAddTGFileTaskWithEdit(ctx, userId, stor, "", files[0], replied.ID)
}
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, userId, stor, "", files, replied.ID)
}

View File

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

View File

@@ -0,0 +1,49 @@
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/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) {
const noPermissionText string = `
您不在白名单中, 无法使用此 Bot.
您可以部署自己的实例: https://github.com/krau/SaveAny-Bot
`
ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil)
return dispatcher.EndGroups
}
return dispatcher.ContinueGroups
}
func handleSilentMode(next func(*ext.Context, *ext.Update) error, handler func(*ext.Context, *ext.Update) error) func(*ext.Context, *ext.Update) error {
return func(ctx *ext.Context, update *ext.Update) error {
userID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取用户信息失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
if !user.Silent {
return next(ctx, update)
}
if user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString("您已开启静默模式, 但未设置默认存储端, 请先使用 /storage 设置"), nil)
return next(ctx, update)
}
stor, err := storage.GetStorageByUserIDAndName(ctx, userID, user.DefaultStorage)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取默认存储失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
ctx.Context = storage.WithContext(ctx.Context, stor)
return handler(ctx, update)
}
}

View File

@@ -0,0 +1,121 @@
// 处理任意文本消息, 用于通用地从外部源下载文件
package handlers
import (
"errors"
"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/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")
}
ok, pser := parsers.CanHandle(text)
if !ok {
return dispatcher.EndGroups
}
msg, err := ctx.Reply(u, ext.ReplyTextString("正在解析..."), nil)
if err != nil {
return err
}
item, err := pser.Parse(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("Failed to parse text: "+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("Failed to build storage selection keyboard: "+err.Error()), nil)
return dispatcher.EndGroups
}
text, entities, err := msgelem.BuildParsedTextEntity(*item)
if err != nil {
logger.Errorf("Failed to build parsed text entity: %s", err)
ctx.Reply(u, ext.ReplyTextString("Failed to build parsed text entity: "+err.Error()), nil)
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)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(u, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
text := u.EffectiveMessage.Text
if text == "" {
return dispatcher.EndGroups
}
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("Failed to parse text: "+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("Failed to build parsed text entity: "+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)
}
return shortcut.CreateAndAddParsedTaskWithEdit(ctx, stor, dirPath, item, msg.ID, userID)
}

View File

@@ -0,0 +1,62 @@
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/config"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
)
type DescCommandHandler struct {
Cmd string
Desc string
handler func(ctx *ext.Context, u *ext.Update) error
}
var CommandHandlers = []DescCommandHandler{
{"start", "开始使用", handleHelpCmd},
{"silent", "切换静默模式", handleSilentCmd},
{"storage", "设置默认存储端", handleStorageCmd},
{"dir", "管理存储文件夹", handleDirCmd},
{"rule", "管理自动存储规则", handleRuleCmd},
{"watch", "监听聊天(UserBot)", handleWatchCmd},
{"unwatch", "取消监听聊天(UserBot)", handleUnwatchCmd},
{"save", "保存文件", handleSilentMode(handleSaveCmd, handleSilentSaveReplied)},
{"config", "修改配置", handleConfigCmd},
{"fnametmpl", "设置文件命名模板", handleConfigFnameTmpl},
{"update", "检查更新", handleUpdateCmd},
{"help", "显示帮助", handleHelpCmd},
}
func Register(disp dispatcher.Dispatcher) {
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChannel), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.ChatType(filters.ChatTypeChat), func(ctx *ext.Context, u *ext.Update) error {
return dispatcher.EndGroups
}))
disp.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission))
for _, info := range CommandHandlers {
disp.AddHandler(handlers.NewCommand(info.Cmd, info.handler))
}
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("update"), handleUpdateCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeAdd), handleAddCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeSetDefault), handleSetDefaultCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeCancel), handleCancelCallback))
disp.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix(tcbdata.TypeConfig), handleConfigCallback))
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TgMessageLinkRegexString)), handleSilentMode(handleMessageLink, handleSilentSaveLink)))
disp.AddHandler(handlers.NewMessage(sabotfilters.RegexUrl(regexp.MustCompile(re.TelegraphUrlRegexString)), handleSilentMode(handleTelegraphUrlMessage, handleSilentSaveTelegraph)))
disp.AddHandler(handlers.NewMessage(filters.Message.Media, handleSilentMode(handleMediaMessage, handleSilentSaveMedia)))
disp.AddHandler(handlers.NewMessage(filters.Message.Text, handleSilentMode(handleTextMessage, handleSilentSaveText)))
if config.C().Telegram.Userbot.Enable {
go listenMediaMessageEvent(userclient.GetMediaMessageCh())
}
}

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

@@ -0,0 +1,101 @@
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/database"
"github.com/krau/SaveAny-Bot/pkg/rule"
)
func handleRuleCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("获取用户规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), 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 {
logger.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: "禁用"}[applyRule])), 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("无效的规则类型: %s\n可用: %v", ruleTypeArg, slice.Join(rule.Values(), ", "))
}()
if err != nil {
ctx.Reply(update, ext.ReplyTextString(err.Error()), 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("创建规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("创建规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("创建规则成功"), nil)
case "del":
// /rule del <id>
if len(args) < 3 {
ctx.Reply(update, ext.ReplyTextString("请提供规则ID"), nil)
return dispatcher.EndGroups
}
ruleID := args[2]
id, err := strconv.Atoi(ruleID)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil)
return dispatcher.EndGroups
}
if err := database.DeleteRule(ctx, uint(id)); err != nil {
logger.Errorf("删除规则失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil)
default:
ctx.Reply(update, ext.ReplyTextStyledTextArray(msgelem.BuildRuleHelpStyling(user.ApplyRule, user.Rules)), nil)
return dispatcher.EndGroups
}
return dispatcher.EndGroups
}

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

@@ -0,0 +1,216 @@
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/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/strutil"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
)
func handleSaveCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(string(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
}
// genFilename := func() string {
// if len(args) > 1 {
// return args[1]
// }
// filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
// return filename
// }()
// option := tfile.WithNameIfEmpty(genFilename)
// if len(args) > 1 {
// option = tfile.WithName(genFilename)
// }
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
return err
}
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("构建存储选择消息失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("构建存储选择消息失败: "+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:])
}
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
replyTo := update.EffectiveMessage.ReplyToMessage
if replyTo == nil || replyTo.Message == nil {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgSaveHelpText)), nil)
return dispatcher.EndGroups
}
// genFilename := func() string {
// if len(args) > 1 {
// return args[1]
// }
// filename := tgutil.GenFileNameFromMessage(*replyTo.Message)
// return filename
// }()
// option := tfile.WithNameIfEmpty(genFilename)
// if len(args) > 1 {
// option = tfile.WithName(genFilename)
// }
userDB, err := database.GetUserByChatID(ctx, update.GetUserChat().GetID())
if err != nil {
return err
}
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, "", 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("无效的正则表达式: "+err.Error()), nil)
return dispatcher.EndGroups
}
}
startID, endID, err := strutil.ParseIntStrRange(msgIdRangeArg, "-")
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围: "+err.Error()), nil)
return dispatcher.EndGroups
}
chatID, err := tgutil.ParseChatID(ctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
return dispatcher.EndGroups
}
replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取消息..."), nil)
if err != nil {
log.FromContext(ctx).Errorf("回复失败: %s", err)
return dispatcher.EndGroups
}
// [TODO]: generator istead of get all messages
msgs, err := tgutil.GetMessagesRange(ctx, chatID, int(startID), int(endID))
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取消息失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
if len(msgs) == 0 {
ctx.Reply(update, ext.ReplyTextString("没有找到指定范围内的消息"), nil)
return dispatcher.EndGroups
}
files := make([]tfile.TGFileMessage, 0, len(msgs))
sb := strings.Builder{}
for _, msg := range msgs {
if msg == nil {
continue
}
media, ok := msg.GetMedia()
if !ok {
continue
}
supported := mediautil.IsSupported(media)
if !supported {
continue
}
file, err := tfile.FromMediaMessage(media, ctx.Raw, msg, tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg)))
if err != nil {
log.FromContext(ctx).Errorf("获取文件失败: %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("没有找到指定范围内的可保存消息"), 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("构建存储选择键盘失败: %s", err)
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: "构建存储选择键盘失败: " + err.Error(),
})
return dispatcher.EndGroups
}
ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{
ID: replied.ID,
Message: fmt.Sprintf("找到 %d 个文件, 请选择存储位置", len(files)),
ReplyMarkup: markup,
})
return dispatcher.EndGroups
}
return shortcut.CreateAndAddBatchTGFileTaskWithEdit(ctx, update.GetUserChat().GetID(), stor, "", files, replied.ID)
}

View File

@@ -0,0 +1,104 @@
package handlers
import (
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/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("获取用户信息失败: "+err.Error()), nil)
return nil
}
if !user.Silent && user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil)
return nil
}
user.Silent = !user.Silent
if err := database.UpdateUser(ctx, user); err != nil {
ctx.Reply(update, ext.ReplyTextString("更新用户信息失败: "+err.Error()), nil)
return nil
}
responseText := "已" + map[bool]string{true: "开启", false: "关闭"}[user.Silent] + "静默模式"
ctx.Reply(update, ext.ReplyTextString(responseText), nil)
return dispatcher.EndGroups
}
func handleSetDefaultCallback(ctx *ext.Context, update *ext.Update) error {
dataid := strings.Split(string(update.CallbackQuery.Data), " ")[1]
data, ok := cache.Get[tcbdata.SetDefaultStorage](dataid)
if !ok {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "数据已过期",
CacheTime: 5,
})
return dispatcher.EndGroups
}
userID := update.CallbackQuery.GetUserID()
storageName := data.StorageName
selectedStorage, err := storage.GetStorageByUserIDAndName(ctx, userID, storageName)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "存储获取失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
}
user, err := database.GetUserByChatID(ctx, userID)
if err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "获取用户信息失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
}
user.DefaultStorage = selectedStorage.Name()
if err := database.UpdateUser(ctx, user); err != nil {
ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{
QueryID: update.CallbackQuery.GetQueryID(),
Alert: true,
Message: "更新用户信息失败: " + err.Error(),
CacheTime: 5,
})
return dispatcher.EndGroups
}
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: update.CallbackQuery.GetMsgID(),
Message: "已将默认存储位置设置为: " + selectedStorage.Name(),
})
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("无可用的存储"), nil)
return nil
}
markup, err := msgelem.BuildSetDefaultStorageMarkup(ctx, userID, storages)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("获取存储失败: "+err.Error()), nil)
return nil
}
ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{
Markup: markup,
})
return dispatcher.EndGroups
}

View File

@@ -0,0 +1,76 @@
package handlers
import (
"fmt"
"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/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/shortcut"
"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("构建存储选择键盘失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("构建存储选择键盘失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
eb := entity.Builder{}
if err := styling.Perform(&eb,
styling.Plain("标题: "),
styling.Code(result.Page.Title),
styling.Plain("\n图片数量: "),
styling.Code(fmt.Sprintf("%d", len(result.Pics))),
styling.Plain("\n请选择存储位置"),
); 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 {
logger := log.FromContext(ctx)
stor := storage.FromContext(ctx)
if stor == nil {
logger.Warn("Context storage is nil")
ctx.Reply(update, ext.ReplyTextString("未找到存储"), nil)
return dispatcher.EndGroups
}
msg, result, err := shortcut.GetTphPicsFromMessageWithReply(ctx, update)
if err != nil {
return err
}
userID := update.GetUserChat().GetID()
return shortcut.CreateAndAddtelegraphWithEdit(ctx, userID, result.Page, result.TphDir, result.Pics, stor, msg.ID)
}

View File

@@ -0,0 +1,102 @@
package handlers
import (
"errors"
"fmt"
"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/config"
"github.com/rhysd/go-github-selfupdate/selfupdate"
)
func handleUpdateCmd(ctx *ext.Context, u *ext.Update) error {
currentV, err := semver.Parse(config.Version)
if err != nil {
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("You are in dev or the version var failed to inject: %v", err)), nil)
return dispatcher.EndGroups
}
latest, ok, err := selfupdate.DetectLatest(config.GitRepo)
if err != nil {
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("检测最新版本失败: %v", err)), nil)
return dispatcher.EndGroups
}
if !ok {
ctx.Reply(u, ext.ReplyTextString("没有找到版本信息"), nil)
return dispatcher.EndGroups
}
if latest.Version.LT(currentV) || latest.Version.Equals(currentV) {
ctx.Reply(u, ext.ReplyTextString(fmt.Sprintf("当前已经是最新版本: %s", config.Version)), nil)
return dispatcher.EndGroups
}
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>`
}()))
text := fmt.Sprintf(`发现新版本: %s
当前版本: %s
文件大小: %.2f MB
下载链接: %s
发布时间: %s
升级将重启 Bot , 是否升级?`, latest.Version, config.Version,
float64(latest.AssetByteSize)/(1024*1024), latest.AssetURL,
latest.PublishedAt.Format("2006-01-02 15:04:05"),
)
ctx.Reply(u, ext.ReplyTextString(text), &ext.ReplyOpts{
Markup: &tg.ReplyInlineMarkup{
Rows: []tg.KeyboardButtonRow{
{
Buttons: []tg.KeyboardButtonClass{
&tg.KeyboardButtonCallback{
Text: "升级",
Data: []byte("update"),
},
},
},
},
},
})
return dispatcher.EndGroups
}
func handleUpdateCallback(ctx *ext.Context, u *ext.Update) error {
currentV, err := semver.Parse(config.Version)
if err != nil {
return err
}
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
ID: u.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("正在升级中, 当前版本: %s", config.Version),
})
latest, err := selfupdate.UpdateSelf(currentV, config.GitRepo)
if err != nil {
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
ID: u.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("升级失败: %v", err),
})
return dispatcher.EndGroups
}
ctx.EditMessage(u.GetUserChat().GetID(), &tg.MessagesEditMessageRequest{
ID: u.CallbackQuery.GetMsgID(),
Message: fmt.Sprintf("已升级至版本 %s\n若 Bot 未自动重启请手动启动", latest.Version),
})
return errors.New("SAVEANTBOT-RESTART")
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,36 @@
package msgelem
import (
"fmt"
"strings"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/database"
)
func BuildDirHelpStyling(dirs []database.Dir) []styling.StyledTextOption {
return []styling.StyledTextOption{
styling.Bold("使用方法: /dir <操作> <参数...>"),
styling.Plain("\n\n可用操作:\n"),
styling.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),
}
}

View File

@@ -0,0 +1,39 @@
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/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("\n链接: "),
styling.Code(item.URL),
styling.Plain("\n作者: "),
styling.Code(item.Author),
styling.Plain("\n描述: "),
styling.Code(strutil.Ellipsis(item.Description, 233)),
styling.Plain("\n文件数量: "),
styling.Code(fmt.Sprintf("%d", len(item.Resources))),
styling.Plain("\n预计总大小: "),
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("\n请选择存储位置"),
); err != nil {
return "", nil, fmt.Errorf("构建消息失败: %w", err)
}
text, entities := eb.Complete()
return text, entities, nil
}

View File

@@ -0,0 +1,32 @@
package msgelem
import (
"fmt"
"strings"
"github.com/gotd/td/telegram/message/styling"
"github.com/krau/SaveAny-Bot/database"
)
func BuildRuleHelpStyling(enabled bool, rules []database.Rule) []styling.StyledTextOption {
return []styling.StyledTextOption{
styling.Bold("使用方法: /rule <操作> <参数...>"),
styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[enabled])),
styling.Plain("\n\n可用操作:\n"),
styling.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 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,165 @@
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/database"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/tcbdata"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func BuildAddSelectStorageKeyboard(stors []storage.Storage, adddata tcbdata.Add) (*tg.ReplyInlineMarkup, error) {
taskType := adddata.TaskType
if taskType == "" {
if len(adddata.Files) > 0 {
taskType = tasktype.TaskTypeTgfiles
} else if adddata.TphPageNode != nil {
taskType = tasktype.TaskTypeTphpics
} else if adddata.ParsedItem != nil {
taskType = tasktype.TaskTypeParseditem
} else {
return nil, fmt.Errorf("unknown task type: %s", taskType)
}
}
buttons := make([]tg.KeyboardButtonClass, 0)
for _, storage := range stors {
data := tcbdata.Add{
TaskType: taskType,
SelectedStorName: storage.Name(),
Files: adddata.Files,
AsBatch: len(adddata.Files) > 1,
TphPageNode: adddata.TphPageNode,
TphPics: adddata.TphPics,
TphDirPath: adddata.TphDirPath,
ParsedItem: adddata.ParsedItem,
}
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 := fmt.Sprintf("文件名: %s\n请选择存储位置", file.Name())
if err := styling.Perform(&eb,
styling.Plain("文件名: "),
styling.Code(file.Name()),
styling.Plain("\n请选择存储位置"),
); 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 BuildSetDefaultStorageMarkup(ctx context.Context, userID int64, 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 BuildSetDirKeyboard(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: "默认",
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,33 @@
package msgelem
import (
"context"
"fmt"
"strconv"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/message/entity"
"github.com/gotd/td/telegram/message/styling"
"github.com/gotd/td/tg"
)
func BuildTaskAddedEntities(
ctx context.Context,
filename string,
queueLength int,
) (string, []tg.MessageEntityClass) {
entityBuilder := entity.Builder{}
var entities []tg.MessageEntityClass
text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", filename, queueLength)
if err := styling.Perform(&entityBuilder,
styling.Plain("已添加到任务队列\n文件名: "),
styling.Code(filename),
styling.Plain("\n当前排队任务数: "),
styling.Bold(strconv.Itoa(queueLength)),
); err != nil {
log.FromContext(ctx).Errorf("Failed to build entity: %s", err)
} else {
text, entities = entityBuilder.Complete()
}
return text, entities
}

View File

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

View File

@@ -0,0 +1,113 @@
package ruleutil
import (
"context"
"github.com/duke-git/lancet/v2/convertor"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/rule"
"github.com/krau/SaveAny-Bot/pkg/tfile"
)
type ruleInput struct {
File tfile.TGFileMessage
}
type ruleInputOption func(*ruleInput)
func NewInput(file tfile.TGFileMessage, opts ...ruleInputOption) *ruleInput {
input := &ruleInput{
File: file,
}
for _, opt := range opts {
opt(input)
}
return input
}
type matchedStorName string
func (m matchedStorName) String() string {
return string(m)
}
// can we use this storage name directly?
func (m matchedStorName) IsUsable() 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,240 @@
// Some shortcuts for duplicate code in handlers, they should return dispatcher errors
package shortcut
import (
"encoding/json"
"net/url"
"strings"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/types"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/downloader"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/msgelem"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/re"
uc "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/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("正在获取文件信息..."), 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("获取文件失败: "+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("正在获取消息..."), 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("获取用户信息失败: "+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
}
// var opt tfile.TGFileOption
// switch user.FilenameStrategy {
// case fnamest.Message.String():
// opt = tfile.WithName(tgutil.GenFileNameFromMessage(*msg))
// default:
// opt = tfile.WithNameIfEmpty(tgutil.GenFileNameFromMessage(*msg))
// }
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 {
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.Errorf("failed to get message by ID: %s", err)
continue
}
groupID, isGroup := msg.GetGroupedID()
if isGroup && groupID != 0 && !linkUrl.Query().Has("single") {
gmsgs, err := tgutil.GetGroupedMessages(ctx, 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("没有找到可保存的文件", 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, "数据已过期或无效"))
var zero DataType
return zero, dispatcher.EndGroups
}
return data, nil
}
type TelegraphResult struct {
Pics []string `json:"pics"` // image urls
TphDir string `json:"tph_dir"` // telegraph path, unescaped
Page *telegraph.Page `json:"page"` // telegraph page node
}
// return replied message, image urls, telegraph path(unescaped), error
func GetTphPicsFromMessageWithReply(ctx *ext.Context, update *ext.Update) (*types.Message, *TelegraphResult, error) {
logger := log.FromContext(ctx)
tphurl := re.TelegraphUrlRegexp.FindString(tgutil.ExtractMessageEntityUrlsText(update.EffectiveMessage.Message))
if tphurl == "" {
logger.Warnf("No telegraph url found but called handleTelegraph")
return nil, nil, dispatcher.ContinueGroups
}
pagepath := strings.Split(tphurl, "/")[len(strings.Split(tphurl, "/"))-1]
tphdir, err := url.PathUnescape(pagepath)
if err != nil {
logger.Errorf("Failed to unescape telegraph path: %s", err)
ctx.Reply(update, ext.ReplyTextString("解析 telegraph 路径失败: "+err.Error()), nil)
return nil, nil, dispatcher.EndGroups
}
tphdir = strings.TrimSpace(tphdir)
msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取 telegraph 页面..."), nil)
if err != nil {
logger.Errorf("Failed to reply to update: %s", err)
return nil, nil, dispatcher.EndGroups
}
page, err := tphutil.DefaultClient().GetPage(ctx, pagepath)
if err != nil {
logger.Errorf("Failed to get telegraph page: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取 telegraph 页面失败: "+err.Error()), nil)
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 {
imgs = append(imgs, src)
}
}
}
if len(imgs) == 0 {
logger.Warn("No images found in telegraph page")
ctx.Reply(update, ext.ReplyTextString("在 telegraph 页面中未找到图片"), nil)
return nil, nil, dispatcher.EndGroups
}
return msg, &TelegraphResult{
Pics: imgs,
TphDir: tphdir,
Page: page,
}, nil
}

View File

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

View File

@@ -0,0 +1,52 @@
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/utils/tgutil"
"github.com/krau/SaveAny-Bot/common/utils/tphutil"
"github.com/krau/SaveAny-Bot/core"
tphtask "github.com/krau/SaveAny-Bot/core/tasks/telegraph"
"github.com/krau/SaveAny-Bot/pkg/telegraph"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func CreateAndAddtelegraphWithEdit(
ctx *ext.Context,
userID int64,
tphpage *telegraph.Page,
dirPath string, // unescaped ph path for file storage
pics []string,
stor storage.Storage,
trackMsgID int) error {
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
task := tphtask.NewTask(xid.New().String(),
injectCtx,
tphpage.Path,
pics,
stor,
stor.JoinStoragePath(dirPath),
tphutil.DefaultClient(),
tphtask.NewProgress(trackMsgID, userID),
)
if err := core.AddTask(injectCtx, task); err != nil {
log.FromContext(ctx).Errorf("Failed to add task: %s", err)
ctx.EditMessage(userID, &tg.MessagesEditMessageRequest{
ID: trackMsgID,
Message: "任务添加失败: " + 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,224 @@
package handlers
import (
"path"
"regexp"
"strings"
"text/template"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/ext"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/mediautil"
"github.com/krau/SaveAny-Bot/client/bot/handlers/utils/ruleutil"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/common/utils/tgutil"
"github.com/krau/SaveAny-Bot/core"
"github.com/krau/SaveAny-Bot/core/tasks/tfile"
"github.com/krau/SaveAny-Bot/database"
"github.com/krau/SaveAny-Bot/pkg/enums/fnamest"
"github.com/krau/SaveAny-Bot/storage"
"github.com/rs/xid"
)
func handleWatchCmd(ctx *ext.Context, update *ext.Update) error {
logger := log.FromContext(ctx)
args := strings.Split(update.EffectiveMessage.Text, " ")
if len(args) < 2 {
ctx.Reply(update, ext.ReplyTextString(i18n.T(i18nk.BotMsgWatchHelpText)), nil)
return dispatcher.EndGroups
}
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
if user.DefaultStorage == "" {
ctx.Reply(update, ext.ReplyTextString("请先设置默认存储, 使用 /storage 命令"), nil)
return dispatcher.EndGroups
}
chatArg := args[1]
chatID, err := tgutil.ParseChatID(ctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
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("已经在监听此聊天"), 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("过滤器格式错误, 请使用 <过滤器类型>:<表达式>"), nil)
return dispatcher.EndGroups
}
switch filterType {
case "msgre":
_, err := regexp.Compile(filterData)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("正则表达式格式错误: "+err.Error()), nil)
return dispatcher.EndGroups
}
filter = filterType + ":" + filterData
default:
ctx.Reply(update, ext.ReplyTextString("不支持的过滤器类型, 请参阅文档"), 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("监听聊天失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("已开始监听聊天: "+chatArg), 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("请提供要取消监听的聊天ID或用户名"), nil)
return dispatcher.EndGroups
}
userChatID := update.GetUserChat().GetID()
user, err := database.GetUserByChatID(ctx, userChatID)
if err != nil {
logger.Errorf("获取用户失败: %s", err)
ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil)
return dispatcher.EndGroups
}
chatArg := args[1]
chatID, err := tgutil.ParseChatID(ctx, chatArg)
if err != nil {
ctx.Reply(update, ext.ReplyTextString("无效的ID或用户名: "+err.Error()), nil)
return dispatcher.EndGroups
}
if err := user.UnwatchChat(ctx, chatID); err != nil {
logger.Errorf("Failed to unwatch chat %d: %s", chatID, err)
ctx.Reply(update, ext.ReplyTextString("取消监听聊天失败: "+err.Error()), nil)
return dispatcher.EndGroups
}
ctx.Reply(update, ext.ReplyTextString("已取消监听聊天: "+chatArg), nil)
return dispatcher.EndGroups
}
func listenMediaMessageEvent(ch chan userclient.MediaMessageEvent) {
logger := log.FromContext(userclient.GetCtx())
for event := range ch {
logger.Debug("Received media message event", "chat_id", event.ChatID, "file_name", event.File.Name())
ctx := event.Ctx
file := event.File
chats, err := database.GetWatchChatsByChatID(ctx, event.ChatID)
if err != nil {
logger.Errorf("Failed to get watch chats for chat ID %d: %v", event.ChatID, err)
continue
}
msgText := event.File.Message().GetMessage()
for _, chat := range chats {
if chat.Filter != "" {
filter := strings.Split(chat.Filter, ":")
if len(filter) != 2 {
logger.Warnf("Invalid filter format in chat %d, skipping", chat.ChatID)
continue
}
filterType := filter[0]
filterData := filter[1]
switch filterType {
case "msgre": // [TODO] enums for filter types
if ok, err := regexp.MatchString(filterData, msgText); err != nil {
continue
} else if !ok {
continue
}
default:
logger.Warnf("Unsupported filter type %s in chat %d, skipping", filterType, chat.ChatID)
continue
}
}
user, err := database.GetUserByID(ctx, chat.UserID)
if err != nil {
logger.Errorf("Failed to get user by ID %d: %v", chat.UserID, err)
continue
}
if user.DefaultStorage == "" {
logger.Warnf("User %d has no default storage set, skipping media message handling", chat.UserID)
continue
}
stor, err := storage.GetStorageByUserIDAndName(ctx, user.ChatID, user.DefaultStorage)
if err != nil {
logger.Errorf("Failed to get storage by user ID %d and name %s: %v", user.ChatID, user.DefaultStorage, err)
continue
}
switch user.FilenameStrategy {
case fnamest.Message.String():
file.SetName(tgutil.GenFileNameFromMessage(*file.Message()))
case fnamest.Template.String():
if user.FilenameTemplate == "" {
logger.Warnf("Empty filename template for user %d, using default filename", user.ChatID)
break
}
message := file.Message()
tmpl, err := template.New("filename").Parse(user.FilenameTemplate)
if err != nil {
logger.Errorf("Failed to parse filename template for user %d: %s", user.ChatID, err)
break
}
data := mediautil.BuildFilenameTemplateData(message)
var sb strings.Builder
err = tmpl.Execute(&sb, data)
if err != nil {
log.FromContext(ctx).Errorf("failed to execute filename template: %s", err)
break
}
file.SetName(sb.String())
}
var dirPath string
if user.ApplyRule && user.Rules != nil {
matched, matchedStorageName, matchedDirPath := ruleutil.ApplyRule(ctx, user.Rules, ruleutil.NewInput(file))
if !matched {
goto startCreateTask
}
dirPath = matchedDirPath.String()
if matchedStorageName.IsUsable() {
stor, err = storage.GetStorageByUserIDAndName(ctx, user.ChatID, matchedStorageName.String())
if err != nil {
logger.Errorf("Failed to get storage by user ID and name: %s", err)
continue
}
}
}
startCreateTask:
storagePath := stor.JoinStoragePath(path.Join(dirPath, file.Name()))
injectCtx := tgutil.ExtWithContext(ctx.Context, ctx)
taskid := xid.New().String()
task, err := tfile.NewTGFileTask(taskid, injectCtx, file, stor, storagePath, nil)
if err != nil {
logger.Errorf("create task failed: %s", err)
continue
}
if err := core.AddTask(injectCtx, task); err != nil {
logger.Errorf("add task failed: %s", err)
continue
}
logger.Infof("Added media message task for user %d in chat %d: %s", chat.UserID, event.ChatID, file.Name())
}
}
}

View File

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

View File

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

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

View File

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

View File

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

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

@@ -0,0 +1,130 @@
package user
import (
"context"
"time"
"github.com/celestix/gotgproto"
"github.com/celestix/gotgproto/dispatcher"
"github.com/celestix/gotgproto/dispatcher/handlers"
"github.com/celestix/gotgproto/dispatcher/handlers/filters"
"github.com/celestix/gotgproto/ext"
"github.com/celestix/gotgproto/sessionMaker"
"github.com/charmbracelet/log"
"github.com/gotd/td/telegram/dcs"
"github.com/gotd/td/tg"
"github.com/krau/SaveAny-Bot/client/middleware"
"github.com/krau/SaveAny-Bot/common/utils/netutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/database"
"github.com/ncruces/go-sqlite3/gormlite"
"golang.org/x/net/proxy"
)
var uc *gotgproto.Client
var ectx *ext.Context
func GetCtx() *ext.Context {
if uc == nil {
panic("User client is not initialized, please call Login first")
}
if ectx != nil {
return ectx
}
ectx = uc.CreateContext()
return ectx
}
func GetClient() *gotgproto.Client {
if uc == nil {
panic("User client is not initialized, please call Login first")
}
return uc
}
func Login(ctx context.Context) (*gotgproto.Client, error) {
log.FromContext(ctx).Debug("Logging in user client")
if uc != nil {
return uc, nil
}
res := make(chan struct {
client *gotgproto.Client
err error
})
go func() {
var resolver dcs.Resolver
if config.C().Telegram.Proxy.Enable && config.C().Telegram.Proxy.URL != "" {
dialer, err := netutil.NewProxyDialer(config.C().Telegram.Proxy.URL)
if err != nil {
res <- struct {
client *gotgproto.Client
err error
}{nil, err}
return
}
resolver = dcs.Plain(dcs.PlainOptions{
Dial: dialer.(proxy.ContextDialer).DialContext,
})
} else {
resolver = dcs.DefaultResolver()
}
tclient, err := gotgproto.NewClient(
config.C().Telegram.AppID,
config.C().Telegram.AppHash,
gotgproto.ClientTypePhone(""),
&gotgproto.ClientOpts{
Session: sessionMaker.SqlSession(gormlite.Open(config.C().Telegram.Userbot.Session)),
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
}

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

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

View File

@@ -1,6 +1,7 @@
package cmd
import (
"context"
"fmt"
"github.com/spf13/cobra"
@@ -9,13 +10,11 @@ import (
var rootCmd = &cobra.Command{
Use: "saveany-bot",
Short: "saveany-bot",
Run: func(cmd *cobra.Command, args []string) {
Run()
},
Run: Run,
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
func Execute(ctx context.Context) {
if err := rootCmd.ExecuteContext(ctx); err != nil {
fmt.Println(err)
}
}

View File

@@ -1,13 +1,119 @@
package cmd
import (
"github.com/krau/SaveAny-Bot/bootstrap"
"github.com/krau/SaveAny-Bot/bot"
"context"
"fmt"
"os"
"path/filepath"
"time"
"slices"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/client/bot"
userclient "github.com/krau/SaveAny-Bot/client/user"
"github.com/krau/SaveAny-Bot/common/cache"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"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/database"
"github.com/krau/SaveAny-Bot/parsers"
"github.com/krau/SaveAny-Bot/storage"
"github.com/spf13/cobra"
)
func Run() {
bootstrap.InitAll()
go core.Run()
bot.Run()
func Run(cmd *cobra.Command, _ []string) {
ctx, cancel := context.WithCancel(cmd.Context())
logger := log.NewWithOptions(os.Stdout, log.Options{
Level: log.DebugLevel,
ReportTimestamp: true,
TimeFormat: time.TimeOnly,
ReportCaller: true,
})
ctx = log.WithContext(ctx, logger)
exitChan, err := initAll(ctx)
if err != nil {
logger.Fatal("Init failed", "error", err)
}
go func() {
<-exitChan
cancel()
}()
core.Run(ctx)
<-ctx.Done()
logger.Info(i18n.T(i18nk.LifetimeExiting))
defer logger.Info(i18n.T(i18nk.LifetimeBye))
cleanCache()
}
func initAll(ctx context.Context) (<-chan struct{}, error) {
if err := config.Init(ctx); err != nil {
return nil, fmt.Errorf("failed to load config: %w", err)
}
cache.Init()
logger := log.FromContext(ctx)
i18n.Init(config.C().Lang)
logger.Info(i18n.T(i18nk.LifetimeIniting))
database.Init(ctx)
storage.LoadStorages(ctx)
if config.C().Parser.PluginEnable {
for _, dir := range config.C().Parser.PluginDirs {
if err := parsers.LoadPlugins(ctx, dir); err != nil {
logger.Error(i18n.T(i18nk.ParserPluginLoadFailed), "dir", dir, "error", err)
} else {
logger.Debug(i18n.T(i18nk.ParserPluginLoadedDir), "dir", dir)
}
}
}
if config.C().Telegram.Userbot.Enable {
_, err := userclient.Login(ctx)
if err != nil {
logger.Fatal(i18n.T(i18nk.LifetimeUserLoginFailed, map[string]any{
"Error": err,
}))
}
}
return bot.Init(ctx), nil
}
func cleanCache() {
if config.C().NoCleanCache {
return
}
if config.C().Temp.BasePath != "" && !config.C().Stream {
if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.C().Temp.BasePath)) {
log.Error(i18n.T(i18nk.ConfigErrInvalidCacheDir, map[string]any{
"Path": config.C().Temp.BasePath,
}))
return
}
currentDir, err := os.Getwd()
if err != nil {
log.Error(i18n.T(i18nk.ErrGetWorkdirFailed, map[string]any{
"Error": err,
}))
return
}
cachePath := filepath.Join(currentDir, config.C().Temp.BasePath)
cachePath, err = filepath.Abs(cachePath)
if err != nil {
log.Error(i18n.T(i18nk.ErrGetCacheAbsPathFailed, map[string]any{
"Error": err,
}))
return
}
log.Info(i18n.T(i18nk.LifetimeCleaningCache, map[string]any{
"Path": cachePath,
}))
if err := fsutil.RemoveAllInDir(cachePath); err != nil {
log.Error(i18n.T(i18nk.ErrCleanCacheFailed, map[string]any{
"Error": err,
}))
}
}
}

View File

@@ -4,7 +4,7 @@ import (
"fmt"
"runtime"
"github.com/krau/SaveAny-Bot/common"
"github.com/krau/SaveAny-Bot/config"
"github.com/rhysd/go-github-selfupdate/selfupdate"
"github.com/blang/semver"
@@ -16,7 +16,7 @@ var VersionCmd = &cobra.Command{
Aliases: []string{"v"},
Short: "Print the version number of saveany-bot",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", common.Version, runtime.GOOS, runtime.GOARCH, common.BuildTime, common.GitCommit)
fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", config.Version, runtime.GOOS, runtime.GOARCH, config.BuildTime, config.GitCommit)
},
}
@@ -25,14 +25,14 @@ var upgradeCmd = &cobra.Command{
Aliases: []string{"up"},
Short: "Upgrade saveany-bot to the latest version",
Run: func(cmd *cobra.Command, args []string) {
v := semver.MustParse(common.Version)
latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot")
v := semver.MustParse(config.Version)
latest, err := selfupdate.UpdateSelf(v, config.GitRepo)
if err != nil {
fmt.Println("Binary update failed:", err)
fmt.Println("Update failed:", err)
return
}
if latest.Version.Equals(v) {
fmt.Println("Current binary is the latest version", common.Version)
fmt.Println("Current binary is the latest version", config.Version)
} else {
fmt.Println("Successfully updated to version", latest.Version)
fmt.Println("Release note:\n", latest.ReleaseNotes)

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

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

View File

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

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

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

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

@@ -0,0 +1,24 @@
// Code generated by cmd/geni18n. DO NOT EDIT.
package i18nk
type Key string
const (
BotMsgHelpTextFmt Key = "bot.msg.help_text_fmt"
BotMsgSaveHelpText Key = "bot.msg.save_help_text"
BotMsgWatchHelpText Key = "bot.msg.watch_help_text"
ConfigErrDuplicateStorageName Key = "config.err.duplicate_storage_name"
ConfigErrInvalidCacheDir Key = "config.err.invalid_cache_dir"
ConfigLoadedStorages Key = "config.loaded_storages"
ErrCleanCacheFailed Key = "err.clean_cache_failed"
ErrGetCacheAbsPathFailed Key = "err.get_cache_abs_path_failed"
ErrGetWorkdirFailed Key = "err.get_workdir_failed"
LifetimeBye Key = "lifetime.bye"
LifetimeCleaningCache Key = "lifetime.cleaning_cache"
LifetimeExiting Key = "lifetime.exiting"
LifetimeInitfailed Key = "lifetime.initfailed"
LifetimeIniting Key = "lifetime.initing"
LifetimeUserLoginFailed Key = "lifetime.user_login_failed"
ParserPluginLoadFailed Key = "parser.plugin.load_failed"
ParserPluginLoadedDir Key = "parser.plugin.loaded_dir"
)

View File

@@ -0,0 +1,62 @@
lifetime:
initing: 正在启动
initfailed: 初始化失败
exiting: 正在退出
user_login_failed: "用户登录失败: {{.Error}}"
cleaning_cache: "正在清理缓存 {{.Path}}"
bye: 已退出
config:
loaded_storages: "已加载 {{.Count}} 个存储后端"
err:
invalid_cache_dir: "无效的缓存目录: {{.Path}},请检查配置文件"
duplicate_storage_name: "存储名称 '{{.Name}}' 重复,请检查配置文件"
err:
get_workdir_failed: "获取工作目录失败: {{.Error}}"
get_cache_abs_path_failed: "获取缓存绝对路径失败: {{.Error}}"
clean_cache_failed: "清理缓存失败: {{.Error}}"
parser:
plugin:
load_failed: 加载解析器插件失败
loaded_dir: 解析器插件已加载
bot:
msg:
help_text_fmt: |
Save Any Bot - 转存你的 Telegram 文件
版本: %s , 提交: %s
命令:
/start - 开始使用
/help - 显示帮助
/silent - 开关静默模式
/storage - 设置默认存储位置
/save [自定义文件名] - 保存文件
/dir - 管理存储目录
/rule - 管理规则
/update - 检查更新并升级
使用帮助: https://sabot.unv.app/usage
反馈群组: https://t.me/ProjectSaveAny
save_help_text: |
使用方法:
1. 使用该命令回复要保存的文件, 可选文件名参数.
示例:
/save custom_file_name.mp4
2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储.
示例:
/save @acherkrau 114-514
watch_help_text: |
使用 /watch 命令监听一个聊天的消息, 并自动保存到默认存储中, 遵从存储规则.
命令语法:
/watch <chat_id> [filter]
参数:
- <chat_id>: 聊天的 ID 或用户名
- [filter]: 可选, 格式为 过滤器类型:表达式 , 所有支持类型的过滤器请查看文档
命令示例:
/watch 2229835658 msgre:.*plana.*
这将监听 ID 为 2229835658 的聊天, 并转存所有包含 "plana" 的媒体消息

View File

@@ -1,83 +0,0 @@
package common
import (
"errors"
"os"
"path/filepath"
"time"
"github.com/krau/SaveAny-Bot/logger"
)
// 创建文件, 自动创建目录
func MkFile(path string, data []byte) error {
err := os.MkdirAll(filepath.Dir(path), os.ModePerm)
if err != nil {
return err
}
return os.WriteFile(path, data, os.ModePerm)
}
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// 删除文件, 并清理空目录. 如果文件不存在则返回 nil
func PurgeFile(path string) error {
if err := os.Remove(path); err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
}
return RemoveEmptyDirectories(filepath.Dir(path))
}
func RmFileAfter(path string, td time.Duration) {
_, err := os.Stat(path)
if err != nil {
logger.L.Errorf("Failed to create timer for %s: %s", path, err)
return
}
logger.L.Debugf("Remove file after %s: %s", td, path)
time.AfterFunc(td, func() {
PurgeFile(path)
})
}
// 递归删除空目录
func RemoveEmptyDirectories(dirPath string) error {
entries, err := os.ReadDir(dirPath)
if err != nil {
return err
}
if len(entries) == 0 {
err := os.Remove(dirPath)
if err != nil {
return err
}
return RemoveEmptyDirectories(filepath.Dir(dirPath))
}
return nil
}
// 在指定时间后删除和清理文件 (定时器)
func PurgeFileAfter(path string, td time.Duration) {
_, err := os.Stat(path)
if err != nil {
logger.L.Errorf("Failed to create timer for %s: %s", path, err)
return
}
logger.L.Debugf("Purge file after %s: %s", td, path)
time.AfterFunc(td, func() {
PurgeFile(path)
})
}
func MkCache(path string, data []byte, td time.Duration) {
if err := MkFile(path, data); err != nil {
logger.L.Errorf("failed to save cache file: %s", err)
} else {
go PurgeFileAfter(path, td)
}
}

View File

@@ -1,19 +0,0 @@
package common
import (
"path/filepath"
"time"
"github.com/imroc/req/v3"
"github.com/krau/SaveAny-Bot/config"
)
var ReqClient *req.Client
func initClient() {
ReqClient = req.NewClient().SetOutputDirectory(config.Cfg.Temp.BasePath).SetTimeout(86400 * time.Second)
}
func GetCacheFilePath(filename string) string {
return filepath.Join(config.Cfg.Temp.BasePath, filename)
}

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

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

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

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

View File

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

View File

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

View File

@@ -0,0 +1,78 @@
package netutil
import (
"context"
"fmt"
"net"
"net/http"
"net/url"
"sync"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"golang.org/x/net/proxy"
)
func NewProxyDialer(proxyUrl string) (proxy.Dialer, error) {
url, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
return proxy.FromURL(url, proxy.Direct)
}
func NewProxyHTTPClient(proxyUrl string) (*http.Client, error) {
if proxyUrl == "" {
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
}, nil
}
u, err := url.Parse(proxyUrl)
if err != nil {
return nil, err
}
switch u.Scheme {
case "http", "https":
return &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyURL(u),
},
}, nil
case "socks5":
dialer, err := proxy.FromURL(u, proxy.Direct)
if err != nil {
return nil, err
}
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
},
},
}, nil
default:
return nil, fmt.Errorf("unsupported proxy scheme: %s", u.Scheme)
}
}
var (
defaultProxyHttpClient *http.Client
onceLoadDefaultProxyHttpClient sync.Once
)
func DefaultParserHTTPClient() *http.Client {
onceLoadDefaultProxyHttpClient.Do(func() {
client, err := NewProxyHTTPClient(config.C().Parser.Proxy)
if err != nil {
log.Warn("Failed to create default proxy HTTP client, using http.DefaultClient", "error", err)
defaultProxyHttpClient = http.DefaultClient
} else {
defaultProxyHttpClient = client
}
})
return defaultProxyHttpClient
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

7
config/cache.go Normal file
View File

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

6
config/db.go Normal file
View File

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

22
config/hook.go Normal file
View File

@@ -0,0 +1,22 @@
package config
type hookConfig struct {
Exec hookExecConfig `toml:"exec" mapstructure:"exec" json:"exec"`
}
type hookExecConfig struct {
// command to execute, for all task types
TaskBeforeStart string `toml:"task_before_start" mapstructure:"task_before_start" json:"task_before_start"`
TaskSuccess string `toml:"task_success" mapstructure:"task_success" json:"task_success"`
TaskFail string `toml:"task_fail" mapstructure:"task_fail" json:"task_fail"`
TaskCancel string `toml:"task_cancel" mapstructure:"task_cancel" json:"task_cancel"`
// TaskTypes map[string]hookExecOnTypeConfig `toml:"task_types" mapstructure:"task_types" json:"task_types"` // [TODO]
}
// type hookExecOnTypeConfig struct {
// TaskBeforeStart string `toml:"task_before_start" mapstructure:"task_before_start" json:"task_before_start"`
// TaskSuccess string `toml:"task_success" mapstructure:"task_success" json:"task_success"`
// TaskFail string `toml:"task_fail" mapstructure:"task_fail" json:"task_fail"`
// TaskCancel string `toml:"task_cancel" mapstructure:"task_cancel" json:"task_cancel"`
// }

15
config/parser.go Normal file
View File

@@ -0,0 +1,15 @@
package config
type parserConfig struct {
PluginEnable bool `toml:"plugin_enable" mapstructure:"plugin_enable" json:"plugin_enable"`
PluginDirs []string `toml:"plugin_dirs" mapstructure:"plugin_dirs" json:"plugin_dirs"`
Proxy string `toml:"proxy" mapstructure:"proxy" json:"proxy"`
ParserCfgs map[string]map[string]any `mapstructure:",remain"`
}
func (c Config) GetParserConfigByName(name string) map[string]any {
if c.Parser.ParserCfgs == nil {
return nil
}
return c.Parser.ParserCfgs[name]
}

38
config/storage/alist.go Normal file
View File

@@ -0,0 +1,38 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type AlistStorageConfig struct {
BaseConfig
URL string `toml:"url" mapstructure:"url" json:"url"`
Username string `toml:"username" mapstructure:"username" json:"username"`
Password string `toml:"password" mapstructure:"password" json:"password"`
Token string `toml:"token" mapstructure:"token" json:"token"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
TokenExp int64 `toml:"token_exp" mapstructure:"token_exp" json:"token_exp"`
}
func (a *AlistStorageConfig) Validate() error {
if a.URL == "" {
return fmt.Errorf("url is required for alist storage")
}
if a.Token == "" && (a.Username == "" || a.Password == "") {
return fmt.Errorf("username and password or token is required for alist storage")
}
if a.BasePath == "" {
return fmt.Errorf("base_path is required for alist storage")
}
return nil
}
func (a *AlistStorageConfig) GetType() storenum.StorageType {
return storenum.Alist
}
func (a *AlistStorageConfig) GetName() string {
return a.Name
}

68
config/storage/factory.go Normal file
View File

@@ -0,0 +1,68 @@
package storage
import (
"fmt"
"reflect"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
"github.com/mitchellh/mapstructure"
"github.com/spf13/viper"
)
var storageFactories = map[storenum.StorageType]func(cfg *BaseConfig) (StorageConfig, error){
storenum.Local: createStorageConfig(&LocalStorageConfig{}),
storenum.Alist: createStorageConfig(&AlistStorageConfig{}),
storenum.Webdav: createStorageConfig(&WebdavStorageConfig{}),
storenum.Minio: createStorageConfig(&MinioStorageConfig{}),
storenum.Telegram: createStorageConfig(&TelegramStorageConfig{}),
}
func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) {
return func(cfg *BaseConfig) (StorageConfig, error) {
configValue := reflect.New(reflect.TypeOf(configType).Elem()).Interface().(StorageConfig)
reflect.ValueOf(configValue).Elem().FieldByName("BaseConfig").Set(reflect.ValueOf(*cfg))
if err := mapstructure.Decode(cfg.RawConfig, configValue); err != nil {
return nil, fmt.Errorf("failed to decode %s storage config: %w", cfg.Type, err)
}
return configValue, nil
}
}
func LoadStorageConfigs(v *viper.Viper) ([]StorageConfig, error) {
var baseConfigs []BaseConfig
if err := v.UnmarshalKey("storages", &baseConfigs); err != nil {
return nil, fmt.Errorf("failed to unmarshal storage configs: %w", err)
}
var configs []StorageConfig
for _, baseCfg := range baseConfigs {
if !baseCfg.Enable {
continue
}
st, err := storenum.ParseStorageType(baseCfg.Type)
if err != nil {
return nil, fmt.Errorf("invalid storage type %s for %s: %w", baseCfg.Type, baseCfg.Name, err)
}
factory, ok := storageFactories[st]
if !ok {
return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type)
}
cfg, err := factory(&baseCfg)
if err != nil {
return nil, fmt.Errorf("failed to create storage config for %s: %w", baseCfg.Name, err)
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid storage config for %s: %w", baseCfg.Name, err)
}
configs = append(configs, cfg)
}
return configs, nil
}

27
config/storage/local.go Normal file
View File

@@ -0,0 +1,27 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type LocalStorageConfig struct {
BaseConfig
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (l *LocalStorageConfig) Validate() error {
if l.BasePath == "" {
return fmt.Errorf("path is required for local storage")
}
return nil
}
func (l *LocalStorageConfig) GetType() storenum.StorageType {
return storenum.Local
}
func (l *LocalStorageConfig) GetName() string {
return l.Name
}

41
config/storage/minio.go Normal file
View File

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

View File

@@ -0,0 +1,33 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type TelegramStorageConfig struct {
BaseConfig
ChatID int64 `toml:"chat_id" mapstructure:"chat_id" json:"chat_id"`
ForceFile bool `toml:"force_file" mapstructure:"force_file" json:"force_file"`
RateLimit int `toml:"rate_limit" mapstructure:"rate_limit" json:"rate_limit"`
RateBurst int `toml:"rate_burst" mapstructure:"rate_burst" json:"rate_burst"`
}
func (m *TelegramStorageConfig) Validate() error {
if m.ChatID == 0 {
return fmt.Errorf("chat_id is required for telegram storage")
}
if m.RateLimit < 0 || m.RateBurst < 0 {
return fmt.Errorf("rate_limit and rate_burst must be greater than 0 for telegram storage")
}
return nil
}
func (m *TelegramStorageConfig) GetType() storenum.StorageType {
return storenum.Telegram
}
func (m *TelegramStorageConfig) GetName() string {
return m.Name
}

18
config/storage/types.go Normal file
View File

@@ -0,0 +1,18 @@
package storage
import (
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type StorageConfig interface {
Validate() error
GetType() storenum.StorageType
GetName() string
}
type BaseConfig struct {
Name string `toml:"name" mapstructure:"name" json:"name"`
Type string `toml:"type" mapstructure:"type" json:"type"`
Enable bool `toml:"enable" mapstructure:"enable" json:"enable"`
RawConfig map[string]any `toml:"-" mapstructure:",remain"`
}

36
config/storage/webdav.go Normal file
View File

@@ -0,0 +1,36 @@
package storage
import (
"fmt"
storenum "github.com/krau/SaveAny-Bot/pkg/enums/storage"
)
type WebdavStorageConfig struct {
BaseConfig
URL string `toml:"url" mapstructure:"url" json:"url"`
Username string `toml:"username" mapstructure:"username" json:"username"`
Password string `toml:"password" mapstructure:"password" json:"password"`
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}
func (w *WebdavStorageConfig) Validate() error {
if w.URL == "" {
return fmt.Errorf("url is required for webdav storage")
}
if w.Username == "" || w.Password == "" {
return fmt.Errorf("username and password is required for webdav storage")
}
if w.BasePath == "" {
return fmt.Errorf("base_path is required for webdav storage")
}
return nil
}
func (w *WebdavStorageConfig) GetType() storenum.StorageType {
return storenum.Webdav
}
func (w *WebdavStorageConfig) GetName() string {
return w.Name
}

5
config/temp.go Normal file
View File

@@ -0,0 +1,5 @@
package config
type tempConfig struct {
BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"`
}

20
config/tg.go Normal file
View File

@@ -0,0 +1,20 @@
package config
type telegramConfig struct {
Token string `toml:"token" mapstructure:"token"`
AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"`
AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"`
Proxy tgProxyConfig `toml:"proxy" mapstructure:"proxy"`
RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"`
Userbot userbotConfig `toml:"userbot" mapstructure:"userbot" json:"userbot"`
}
type userbotConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
Session string `toml:"session" mapstructure:"session"`
}
type tgProxyConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
URL string `toml:"url" mapstructure:"url"`
}

35
config/user.go Normal file
View File

@@ -0,0 +1,35 @@
package config
import (
"github.com/duke-git/lancet/v2/slice"
)
type userConfig struct {
ID int64 `toml:"id" mapstructure:"id" json:"id"` // telegram user id
Storages []string `toml:"storages" mapstructure:"storages" json:"storages"` // storage names
Blacklist bool `toml:"blacklist" mapstructure:"blacklist" json:"blacklist"` // 黑名单模式, storage names 中的存储将不会被使用, 默认为白名单模式
}
var userIDs []int64
var storages []string
var userStorages = make(map[int64][]string)
func (c Config) GetStorageNamesByUserID(userID int64) []string {
us, ok := userStorages[userID]
if ok {
return us
}
return nil
}
func (c Config) GetUsersID() []int64 {
return userIDs
}
func (c Config) HasStorage(userID int64, storageName string) bool {
us, ok := userStorages[userID]
if !ok {
return false
}
return slice.Contain(us, storageName)
}

13
config/version.go Normal file
View File

@@ -0,0 +1,13 @@
package config
// inject version by '-X' flag
// go build -ldflags "-X github.com/krau/SaveAny-Bot/config.Version=${{ env.VERSION }}"
var (
Version string = "dev"
BuildTime string = "unknown"
GitCommit string = "unknown"
)
const (
GitRepo = "krau/SaveAny-Bot"
)

View File

@@ -1,107 +1,151 @@
package config
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/duke-git/lancet/v2/slice"
"github.com/krau/SaveAny-Bot/common/i18n"
"github.com/krau/SaveAny-Bot/common/i18n/i18nk"
"github.com/krau/SaveAny-Bot/config/storage"
"github.com/spf13/viper"
)
type Config struct {
Threads int `toml:"threads" mapstructure:"threads"`
Workers int `toml:"workers" mapstructure:"workers"`
ChunkSize int32 `toml:"chunk_size" mapstructure:"chunk_size"`
Lang string `toml:"lang" mapstructure:"lang" json:"lang"`
Workers int `toml:"workers" mapstructure:"workers"`
Retry int `toml:"retry" mapstructure:"retry"`
NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"`
Threads int `toml:"threads" mapstructure:"threads" json:"threads"`
Stream bool `toml:"stream" mapstructure:"stream" json:"stream"`
Temp tempConfig `toml:"temp" mapstructure:"temp"`
Log logConfig `toml:"log" mapstructure:"log"`
DB dbConfig `toml:"db" mapstructure:"db"`
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
Storage storageConfig `toml:"storage" mapstructure:"storage"`
Cache cacheConfig `toml:"cache" mapstructure:"cache" json:"cache"`
Users []userConfig `toml:"users" mapstructure:"users" json:"users"`
Temp tempConfig `toml:"temp" mapstructure:"temp"`
DB dbConfig `toml:"db" mapstructure:"db"`
Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"`
Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"`
Parser parserConfig `toml:"parser" mapstructure:"parser" json:"parser"`
Hook hookConfig `toml:"hook" mapstructure:"hook" json:"hook"`
}
type tempConfig struct {
BasePath string `toml:"base_path" mapstructure:"base_path"`
CacheTTL int64 `toml:"cache_ttl" mapstructure:"cache_ttl"`
var cfg = &Config{}
func C() Config {
return *cfg
}
type logConfig struct {
Level string `toml:"level" mapstructure:"level"`
File string `toml:"file" mapstructure:"file"`
BackupCount uint `toml:"backup_count" mapstructure:"backup_count"`
func (c Config) GetStorageByName(name string) storage.StorageConfig {
for _, storage := range c.Storages {
if storage.GetName() == name {
return storage
}
}
return nil
}
type dbConfig struct {
Path string `toml:"path" mapstructure:"path"`
}
type telegramConfig struct {
Token string `toml:"token" mapstructure:"token"`
AppID int32 `toml:"app_id" mapstructure:"app_id"`
AppHash string `toml:"app_hash" mapstructure:"app_hash"`
Admins []int64 `toml:"admins" mapstructure:"admins"`
}
type storageConfig struct {
Alist alistConfig `toml:"alist" mapstructure:"alist"`
Local localConfig `toml:"local" mapstructure:"local"`
Webdav webdavConfig `toml:"webdav" mapstructure:"webdav"`
}
type alistConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
URL string `toml:"url" mapstructure:"url"`
Username string `toml:"username" mapstructure:"username"`
Password string `toml:"password" mapstructure:"password"`
BasePath string `toml:"base_path" mapstructure:"base_path"`
TokenExp int64 `toml:"token_exp" mapstructure:"token_exp"`
}
type localConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
BasePath string `toml:"base_path" mapstructure:"base_path"`
}
type webdavConfig struct {
Enable bool `toml:"enable" mapstructure:"enable"`
URL string `toml:"url" mapstructure:"url"`
Username string `toml:"username" mapstructure:"username"`
Password string `toml:"password" mapstructure:"password"`
BasePath string `toml:"base_path" mapstructure:"base_path"`
}
var Cfg *Config
func Init() {
func Init(ctx context.Context) error {
viper.SetConfigName("config")
viper.AddConfigPath(".")
viper.AddConfigPath("/etc/saveany/")
viper.SetConfigType("toml")
viper.SetEnvPrefix("SAVEANY")
viper.AutomaticEnv()
replacer := strings.NewReplacer(".", "_")
viper.SetEnvKeyReplacer(replacer)
viper.SetDefault("threads", 3)
viper.SetDefault("workers", 3)
viper.SetDefault("chunk_size", 1024*1024)
defaultConfigs := map[string]any{
// 基础配置
"lang": "zh-Hans",
"workers": 3,
"retry": 3,
"threads": 4,
viper.SetDefault("temp.base_path", "cache/")
viper.SetDefault("temp.cache_ttl", 3600)
// 缓存配置
"cache.ttl": 86400,
"cache.num_counters": 1e5,
"cache.max_cost": 1e6,
viper.SetDefault("log.level", "INFO")
viper.SetDefault("log.file", "logs/saveany.log")
viper.SetDefault("log.backup_count", 7)
// Telegram
"telegram.app_id": 1025907,
"telegram.app_hash": "452b0359b988148995f22ff0f4229750",
"telegram.rpc_retry": 5,
"telegram.userbot.enable": false,
"telegram.userbot.session": "data/usersession.db",
viper.SetDefault("db.path", "data/saveany.db")
// 临时目录
"temp.base_path": "cache/",
viper.SetDefault("telegram.api", "https://api.telegram.org")
// 数据库
"db.path": "data/saveany.db",
"db.session": "data/session.db",
}
viper.SetDefault("storage.alist.base_path", "/")
viper.SetDefault("storage.alist.token_exp", 3600)
for key, value := range defaultConfigs {
viper.SetDefault(key, value)
}
if err := viper.SafeWriteConfigAs("config.toml"); err != nil {
if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok {
return fmt.Errorf("error saving default config: %w", err)
}
}
if err := viper.ReadInConfig(); err != nil {
fmt.Println("Error reading config file, ", err)
os.Exit(1)
return err
}
Cfg = &Config{}
if err := viper.Unmarshal(Cfg); err != nil {
if err := viper.Unmarshal(cfg); err != nil {
fmt.Println("Error unmarshalling config file, ", err)
os.Exit(1)
return err
}
storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper())
if err != nil {
return fmt.Errorf("error loading storage configs: %w", err)
}
cfg.Storages = storagesConfig
storageNames := make(map[string]struct{})
for _, storage := range cfg.Storages {
if _, ok := storageNames[storage.GetName()]; ok {
return errors.New(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigErrDuplicateStorageName, map[string]any{
"Name": storage.GetName(),
}))
}
storageNames[storage.GetName()] = struct{}{}
}
fmt.Println(i18n.TWithoutInit(cfg.Lang, i18nk.ConfigLoadedStorages, map[string]any{
"Count": len(cfg.Storages),
}))
for _, storage := range cfg.Storages {
fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType())
}
if cfg.Workers < 1 {
cfg.Workers = 1
}
if cfg.Threads < 1 {
cfg.Threads = 1
}
if cfg.Retry < 1 {
cfg.Retry = 1
}
for _, storage := range cfg.Storages {
storages = append(storages, storage.GetName())
}
for _, user := range cfg.Users {
userIDs = append(userIDs, user.ID)
if user.Blacklist {
userStorages[user.ID] = slice.Compact(slice.Difference(storages, user.Storages))
} else {
userStorages[user.ID] = user.Storages
}
}
return nil
}

View File

@@ -3,100 +3,83 @@ package core
import (
"context"
"errors"
"os"
"time"
"github.com/amarnathcjd/gogram/telegram"
"github.com/krau/SaveAny-Bot/bot"
"github.com/krau/SaveAny-Bot/common"
"github.com/charmbracelet/log"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/logger"
"github.com/krau/SaveAny-Bot/queue"
"github.com/krau/SaveAny-Bot/storage"
"github.com/krau/SaveAny-Bot/types"
"github.com/krau/SaveAny-Bot/pkg/enums/tasktype"
"github.com/krau/SaveAny-Bot/pkg/queue"
)
func processPendingTask(task types.Task) error {
os.MkdirAll(config.Cfg.Temp.BasePath, os.ModePerm)
var queueInstance *queue.TaskQueue[Exectable]
message, err := bot.Client.GetMessageByID(task.ChatID, task.MessageID)
if err != nil {
return err
}
logger.L.Debugf("Start downloading file: %s", task.FileName)
bot.Client.EditMessage(task.ChatID, task.ReplyMessageID, "正在下载文件...")
dest, err := message.Download(&telegram.DownloadOptions{
FileName: common.GetCacheFilePath(task.FileName),
Threads: config.Cfg.Threads,
ChunkSize: config.Cfg.ChunkSize,
// ProgressCallback: func(totalBytes, downloadedBytes int64) {},
})
if err != nil {
return err
}
defer func() {
if config.Cfg.Temp.CacheTTL > 0 {
common.RmFileAfter(dest, time.Duration(config.Cfg.Temp.CacheTTL)*time.Second)
} else {
if err := os.Remove(dest); err != nil {
logger.L.Errorf("Failed to purge file: %s", err)
}
}
}()
if task.StoragePath == "" {
task.StoragePath = task.FileName
}
bot.Client.EditMessage(task.ChatID, task.ReplyMessageID, "下载完成, 正在转存文件...")
if err := storage.Save(task.Storage, task.Ctx, dest, task.StoragePath); err != nil {
return err
}
return nil
type Exectable interface {
Type() tasktype.TaskType
TaskID() string
Execute(ctx context.Context) error
}
func worker(queue *queue.TaskQueue, semaphore chan struct{}) {
func worker(ctx context.Context, qe *queue.TaskQueue[Exectable], semaphore chan struct{}) {
logger := log.FromContext(ctx)
execHooks := config.C().Hook.Exec
for {
semaphore <- struct{}{}
task := queue.GetTask()
logger.L.Debugf("Got task: %s", task.FileName)
switch task.Status {
case types.Pending:
logger.L.Infof("Processing task: %s", task.String())
if err := processPendingTask(task); err != nil {
logger.L.Errorf("Failed to do task: %s", err)
task.Error = err
if errors.Is(err, context.Canceled) {
task.Status = types.Canceled
} else {
task.Status = types.Failed
qtask, err := qe.Get()
if err != nil {
logger.Error("Failed to get task from queue:", err)
break // queue closed and empty
}
task := qtask.Data
logger.Infof("Processing task: %s", task.TaskID())
if err := ExecCommandString(qtask.Context(), execHooks.TaskBeforeStart); err != nil {
logger.Errorf("Failed to execute before start hook for task %s: %v", task.TaskID(), err)
}
if err := task.Execute(qtask.Context()); err != nil {
if errors.Is(err, context.Canceled) {
logger.Infof("Task %s was canceled", task.TaskID())
if err := ExecCommandString(ctx, execHooks.TaskCancel); err != nil {
logger.Errorf("Failed to execute cancel hook for task %s: %v", task.TaskID(), err)
}
} else {
task.Status = types.Succeeded
logger.Errorf("Failed to execute task %s: %v", task.TaskID(), err)
if err := ExecCommandString(ctx, execHooks.TaskFail); err != nil {
logger.Errorf("Failed to execute fail hook for task %s: %v", task.TaskID(), err)
}
}
} else {
logger.Infof("Task %s completed successfully", task.TaskID())
if err := ExecCommandString(ctx, execHooks.TaskSuccess); err != nil {
logger.Errorf("Failed to execute success hook for task %s: %v", task.TaskID(), err)
}
queue.AddTask(task)
case types.Succeeded:
logger.L.Infof("Task succeeded: %s", task.String())
bot.Client.EditMessage(task.ChatID, task.ReplyMessageID, "文件保存成功")
case types.Failed:
logger.L.Errorf("Task failed: %s", task.String())
bot.Client.EditMessage(task.ChatID, task.ReplyMessageID, "文件保存失败")
case types.Canceled:
logger.L.Infof("Task canceled: %s", task.String())
default:
logger.L.Errorf("Unknown task status: %s", task.Status)
}
qe.Done(qtask.ID)
<-semaphore
logger.L.Debugf("Task done: %s", task.FileName)
}
}
func Run() {
logger.L.Info("Start processing tasks...")
semaphore := make(chan struct{}, config.Cfg.Workers)
for i := 0; i < config.Cfg.Workers; i++ {
go worker(queue.Queue, semaphore)
func Run(ctx context.Context) {
log.FromContext(ctx).Info("Start processing tasks...")
semaphore := make(chan struct{}, config.C().Workers)
if queueInstance == nil {
queueInstance = queue.NewTaskQueue[Exectable]()
}
for range config.C().Workers {
go worker(ctx, queueInstance, semaphore)
}
}
func AddTask(ctx context.Context, task Exectable) error {
return queueInstance.Add(queue.NewTask(ctx, task.TaskID(), task))
}
func CancelTask(ctx context.Context, id string) error {
err := queueInstance.CancelTask(id)
return err
}
func GetLength(ctx context.Context) int {
if queueInstance == nil {
return 0
}
return queueInstance.ActiveLength()
}

23
core/hookutil.go Normal file
View File

@@ -0,0 +1,23 @@
package core
import (
"context"
"os"
"os/exec"
"runtime"
)
func ExecCommandString(ctx context.Context, cmd string) error {
if cmd == "" {
return nil
}
var execCmd *exec.Cmd
if runtime.GOOS == "windows" {
execCmd = exec.CommandContext(ctx, "cmd.exe", "/C", cmd)
} else {
execCmd = exec.CommandContext(ctx, "sh", "-c", cmd)
}
execCmd.Stdout = os.Stdout
execCmd.Stderr = os.Stderr
return execCmd.Run()
}

View File

@@ -0,0 +1,129 @@
package batchtfile
import (
"context"
"fmt"
"io"
"os"
"path"
"github.com/charmbracelet/log"
"github.com/duke-git/lancet/v2/retry"
"github.com/krau/SaveAny-Bot/common/utils/fsutil"
"github.com/krau/SaveAny-Bot/common/utils/ioutil"
"github.com/krau/SaveAny-Bot/config"
"github.com/krau/SaveAny-Bot/pkg/enums/ctxkey"
"github.com/krau/SaveAny-Bot/pkg/tfile"
"golang.org/x/sync/errgroup"
)
func (t *Task) Execute(ctx context.Context) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("batch_file[%s]", t.ID))
logger.Info("Starting batch file task")
t.Progress.OnStart(ctx, t)
workers := config.C().Workers
eg, gctx := errgroup.WithContext(ctx)
eg.SetLimit(workers)
for _, elem := range t.Elems {
eg.Go(func() error {
t.processingMu.RLock()
if t.processing[elem.ID] != nil {
return fmt.Errorf("element with ID %s is already being processed", elem.ID)
}
t.processingMu.RUnlock()
t.processingMu.Lock()
t.processing[elem.ID] = &elem
t.processingMu.Unlock()
defer func() {
t.processingMu.Lock()
delete(t.processing, elem.ID)
t.processingMu.Unlock()
}()
return t.processElement(gctx, elem)
})
}
err := eg.Wait()
if err != nil {
logger.Errorf("Error during batch file processing: %v", err)
} else {
logger.Info("Batch file task completed successfully")
}
t.Progress.OnDone(ctx, t, err)
return err
}
func (t *Task) processElement(ctx context.Context, elem TaskElement) error {
logger := log.FromContext(ctx).WithPrefix(fmt.Sprintf("file[%s]", elem.File.Name()))
if elem.stream {
pr, pw := io.Pipe()
defer pr.Close()
errg, uploadCtx := errgroup.WithContext(ctx)
errg.Go(func() error {
return elem.Storage.Save(uploadCtx, pr, elem.Path)
})
wr := ioutil.NewProgressWriter(pw, func(n int) {
t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
})
errg.Go(func() error {
defer pw.Close()
logger.Info("Starting file download in stream mode")
_, err := tfile.NewDownloader(elem.File).Stream(uploadCtx, wr)
if err != nil {
logger.Errorf("Failed to download file: %v", err)
pw.CloseWithError(err)
}
return err
})
if err := errg.Wait(); err != nil {
return fmt.Errorf("failed to download file in stream mode: %w", err)
}
logger.Info("File downloaded successfully in stream mode")
return nil
}
logger.Info("Starting file download")
localFile, err := fsutil.CreateFile(elem.localPath)
if err != nil {
return fmt.Errorf("failed to create local file: %w", err)
}
defer func() {
if err := localFile.CloseAndRemove(); err != nil {
logger.Errorf("Failed to close local file: %v", err)
}
}()
wrAt := ioutil.NewProgressWriterAt(localFile, func(n int) {
t.downloaded.Add(int64(n))
t.Progress.OnProgress(ctx, t)
})
_, err = tfile.NewDownloader(elem.File).Parallel(ctx, wrAt)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
logger.Info("File downloaded successfully")
if path.Ext(elem.FileName()) == "" {
ext := fsutil.DetectFileExt(elem.localPath)
if ext != "" {
elem.Path = elem.Path + ext
}
}
var fileStat os.FileInfo
fileStat, err = os.Stat(elem.localPath)
if err != nil {
return fmt.Errorf("failed to get file stat: %w", err)
}
vctx := context.WithValue(ctx, ctxkey.ContentLength, fileStat.Size())
err = retry.Retry(func() error {
var file *os.File
file, err = os.Open(elem.localPath)
if err != nil {
return fmt.Errorf("failed to open cache file: %w", err)
}
defer file.Close()
if err = elem.Storage.Save(vctx, file, elem.Path); err != nil {
logger.Errorf("Failed to save file: %s, retrying...", err)
return err
}
return nil
}, retry.Context(vctx), retry.RetryTimes(uint(config.C().Retry)))
return err
}

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