├── .dockerignore ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug.yml │ ├── config.yml │ └── feature.yml └── workflows │ ├── build-docker.yml │ ├── build-release.yml │ ├── docs.yml │ └── update-contributors.yml ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── README_EN.md ├── bot ├── bot.go ├── handle_add_task.go ├── handle_cancel_task.go ├── handle_dir.go ├── handle_file.go ├── handle_link.go ├── handle_rule.go ├── handle_save.go ├── handle_send.go ├── handle_silent.go ├── handle_start.go ├── handle_storage.go ├── handle_telegraph.go ├── handlers.go ├── middlewares.go └── utils.go ├── cmd ├── root.go ├── run.go └── version.go ├── common ├── cache.go ├── common.go ├── logger.go ├── os.go ├── utils.go └── version.go ├── config.example.toml ├── config ├── storage │ ├── alist.go │ ├── factory.go │ ├── local.go │ ├── minio.go │ ├── types.go │ └── webdav.go ├── user.go └── viper.go ├── core ├── core.go ├── download.go ├── download_test.go ├── rule.go └── utils.go ├── dao ├── callback_data.go ├── db.go ├── dir.go ├── file.go ├── model.go ├── rule.go └── user.go ├── docker-compose.local.yml ├── docker-compose.yml ├── docs ├── docs │ ├── contribute.md │ ├── deploy.md │ ├── experimental.md │ ├── faq.md │ ├── help.md │ └── index.md ├── logo.jpg ├── mkdocs.yml ├── sabot └── saveanybot ├── go.mod ├── go.sum ├── main.go ├── queue └── queue.go ├── storage ├── alist │ ├── alist.go │ ├── token.go │ ├── types.go │ └── utils.go ├── errs.go ├── local │ └── local.go ├── minio │ └── client.go ├── storage.go └── webdav │ ├── client.go │ ├── client_test.go │ ├── errs.go │ └── webdav.go └── types ├── task.go ├── types.go └── utils.go /.dockerignore: -------------------------------------------------------------------------------- 1 | *.md 2 | .git 3 | .github/ 4 | .gitignore 5 | .vscode/ 6 | downloads/ 7 | data/ 8 | cache/ 9 | docs 10 | config.example.toml 11 | docker-compose.* 12 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | custom: [ 4 | "https://afdian.com/a/acherkrau" 5 | ] -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug.yml: -------------------------------------------------------------------------------- 1 | name: "👾 报告 bug" 2 | description: "报告 bug" 3 | labels: 4 | - "bug" 5 | assignees: 6 | - krau 7 | body: 8 | - type: textarea 9 | attributes: 10 | label: "👾 问题描述" 11 | description: "What happened?" 12 | placeholder: "When called ... happens ..." 13 | validations: 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: "⚡️ 预期行为" 18 | description: "What was expected?" 19 | placeholder: "It should be ..." 20 | - type: textarea 21 | attributes: 22 | label: "📄 配置文件" 23 | description: "Please provide your config file" 24 | placeholder: "请自行隐去密钥信息" 25 | render: toml 26 | - type: textarea 27 | attributes: 28 | label: "🔍 日志" 29 | description: "Please provide logs" 30 | placeholder: "可删除隐私信息" 31 | render: shell 32 | - type: markdown 33 | attributes: 34 | value: | 35 | ## Thank you for contributing to the project :slightly_smiling_face: -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | contact_links: 3 | - name: 💬 不知道如何正确使用? 4 | url: https://github.com/krau/SaveAny-Bot/discussions 5 | about: "前往讨论区提问" 6 | - name: 📄 文档 7 | url: https://sabot.unv.app 8 | about: "查看文档" -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature.yml: -------------------------------------------------------------------------------- 1 | name: "⭐️ 功能请求" 2 | description: "功能请求" 3 | labels: 4 | - "enhancement" 5 | assignees: 6 | - krau 7 | body: 8 | - type: markdown 9 | attributes: 10 | value: | 11 | # 请详细描述你想要的功能 12 | - type: textarea 13 | attributes: 14 | label: "⭐️ Feature description" 15 | description: "What new feature you want to see?" 16 | placeholder: "Add ... in order to ..." 17 | validations: 18 | required: true 19 | - type: textarea 20 | attributes: 21 | label: "🌈 Your view" 22 | description: "How do you see this feature will be used and/or implemented?" 23 | placeholder: "It should be like ..." 24 | - type: textarea 25 | attributes: 26 | label: "🧐 Code example" 27 | description: "You can provide code (or pseudocode) example" 28 | placeholder: "Cool code that will work ..." 29 | render: Go 30 | - type: markdown 31 | attributes: 32 | value: | 33 | ## Thank you for contributing to the project :slightly_smiling_face: -------------------------------------------------------------------------------- /.github/workflows/build-docker.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: ${{ github.repository }} 11 | 12 | jobs: 13 | build-and-push: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: read 17 | packages: write 18 | 19 | steps: 20 | - name: Checkout repository 21 | uses: actions/checkout@v4 22 | 23 | - name: Extract metadata for Docker 24 | id: meta 25 | uses: docker/metadata-action@v5 26 | with: 27 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 28 | tags: | 29 | type=semver,pattern={{version}} 30 | type=semver,pattern={{major}}.{{minor}} 31 | type=sha 32 | type=raw,value=latest 33 | type=ref,event=branch 34 | type=ref,event=tag 35 | labels: | 36 | org.opencontainers.image.title=${{ env.IMAGE_NAME }} 37 | org.opencontainers.image.source=https://github.com/krau/SaveAny-Bot 38 | org.opencontainers.image.url=https://github.com/krau/SaveAny-Bot 39 | 40 | - name: Set up QEMU 41 | uses: docker/setup-qemu-action@v3 42 | 43 | - name: Set up Docker Buildx 44 | uses: docker/setup-buildx-action@v3 45 | 46 | - name: Login to GitHub Container Registry 47 | uses: docker/login-action@v3 48 | with: 49 | registry: ${{ env.REGISTRY }} 50 | username: ${{ github.actor }} 51 | password: ${{ secrets.GITHUB_TOKEN }} 52 | 53 | - name: Extract version from Git Ref 54 | id: extract_version 55 | run: | 56 | VERSION=$(echo "${{ github.ref }}" | sed 's/refs\/tags\/v//') 57 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 58 | 59 | - name: Build and push Docker image 60 | uses: docker/build-push-action@v6 61 | with: 62 | context: . 63 | platforms: linux/amd64,linux/arm64 64 | cache-from: type=gha 65 | cache-to: type=gha,mode=max 66 | build-args: | 67 | VERSION=${{ steps.meta.outputs.version }} 68 | GitCommit=${{ github.sha }} 69 | BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }} 70 | push: true 71 | tags: ${{ steps.meta.outputs.tags }} 72 | labels: ${{ steps.meta.outputs.labels }} 73 | -------------------------------------------------------------------------------- /.github/workflows/build-release.yml: -------------------------------------------------------------------------------- 1 | name: Build Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | permissions: 9 | contents: write 10 | packages: write 11 | 12 | jobs: 13 | create-release: 14 | name: Create Release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Create Release 23 | uses: softprops/action-gh-release@v2 24 | 25 | - name: Setup node 26 | uses: actions/setup-node@v4 27 | with: 28 | node-version: 22 29 | 30 | - run: npx changelogithub 31 | env: 32 | GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} 33 | 34 | build-matrix: 35 | name: Release Go Binary 36 | runs-on: ubuntu-latest 37 | strategy: 38 | matrix: 39 | goos: [linux, darwin, windows] 40 | goarch: [amd64, arm64] 41 | exclude: 42 | - goos: windows 43 | goarch: arm64 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v4 47 | 48 | - name: Extract version from Git Ref 49 | id: extract_version 50 | run: | 51 | VERSION=$(echo "${{ github.ref }}" | sed 's/refs\/tags\/v//') 52 | echo "VERSION=${VERSION}" >> $GITHUB_ENV 53 | 54 | - name: Release Go Binary 55 | uses: wangyoucao577/go-release-action@v1 56 | with: 57 | pre_command: export CGO_ENABLED=0 58 | goos: ${{ matrix.goos }} 59 | goarch: ${{ matrix.goarch }} 60 | github_token: ${{ secrets.GITHUB_TOKEN }} 61 | extra_files: | 62 | LICENSE 63 | README.md 64 | ldflags: >- 65 | -s -w 66 | -X "github.com/krau/SaveAny-Bot/common.Version=${{ env.VERSION }}" 67 | -X "github.com/krau/SaveAny-Bot/common.BuildTime=${{ format(github.event.repository.updated_at, 'yyyy-MM-dd HH:mm:ss') }}" 68 | -X "github.com/krau/SaveAny-Bot/common.GitCommit=${{ github.sha }}" 69 | binary_name: saveany-bot 70 | env: 71 | VERSION: ${{ env.VERSION }} 72 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Deploy Docs 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - "docs/**" 8 | workflow_dispatch: 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-python@v5 15 | with: 16 | python-version: "3.11" 17 | - uses: actions/cache@v4 18 | with: 19 | key: ${{ github.ref }} 20 | path: .cache 21 | - run: pip install mkdocs-material 22 | - run: cd docs && mkdocs gh-deploy --force 23 | -------------------------------------------------------------------------------- /.github/workflows/update-contributors.yml: -------------------------------------------------------------------------------- 1 | name: Update Contributors 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | contrib-readme-job: 8 | runs-on: ubuntu-latest 9 | name: A job to automate contrib in readme 10 | permissions: 11 | contents: write 12 | pull-requests: write 13 | steps: 14 | - name: Contribute List 15 | uses: akhilmhdh/contributors-readme-action@v2.3.10 16 | env: 17 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.toml 2 | logs/ 3 | tmp/ 4 | data/ 5 | downloads/ 6 | cache/ 7 | session.* 8 | cache.db 9 | .vscode/ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine AS builder 2 | 3 | ARG VERSION="dev" 4 | ARG GitCommit="Unknown" 5 | ARG BuildTime="Unknown" 6 | 7 | WORKDIR /app 8 | 9 | COPY . . 10 | 11 | RUN --mount=type=cache,target=/root/.cache/go-build \ 12 | --mount=type=cache,target=/go/pkg \ 13 | CGO_ENABLED=0 \ 14 | go build -trimpath \ 15 | -ldflags "-s -w \ 16 | -X github.com/krau/SaveAny-Bot/common.Version=${VERSION} \ 17 | -X github.com/krau/SaveAny-Bot/common.GitCommit=${GiTCommit} \ 18 | -X github.com/krau/SaveAny-Bot/common.BuildTime=${BuildTime}" \ 19 | -o saveany-bot . 20 | 21 | FROM alpine:latest 22 | 23 | WORKDIR /app 24 | 25 | COPY --from=builder /app/saveany-bot . 26 | 27 | ENTRYPOINT ["/app/saveany-bot"] 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published 637 | by the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Save Any Bot 4 | 5 | **简体中文** | [English](README_EN.md) 6 | 7 | 把 Telegram 的文件保存到各类存储端. 8 | 9 | > _就像 PikPak Bot 一样_ 10 | 11 |
12 | 13 | ## [部署](https://sabot.unv.app/deploy/) 14 | 15 | ## [参与开发](https://sabot.unv.app/contribute/) 16 | 17 | --- 18 | 19 | ## 赞助 20 | 21 | 本项目受到 [YxVM](https://yxvm.com/) 与 [NodeSupport](https://github.com/NodeSeekDev/NodeSupport) 的支持. 22 | 23 | 如果这个项目对你有帮助, 你可以考虑通过以下方式赞助我: 24 | 25 | - [爱发电](https://afdian.com/a/acherkrau) 26 | 27 | ## Contributors 28 | 29 | 30 | 31 | 32 | 33 | 40 | 47 | 54 | 61 | 62 | 63 |
34 | 35 | krau 36 |
37 | Krau 38 |
39 |
41 | 42 | TG-Twilight 43 |
44 | Simon Twilight 45 |
46 |
48 | 49 | ysicing 50 |
51 | 缘生 52 |
53 |
55 | 56 | ahcorn 57 |
58 | 安和 59 |
60 |
64 | 65 | 66 | ## Thanks 67 | 68 | - [gotd](https://github.com/gotd/td) 69 | - [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot) 70 | - [gotgproto](https://github.com/celestix/gotgproto) 71 | - All the dependencies 72 | -------------------------------------------------------------------------------- /README_EN.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | # Save Any Bot 4 | 5 | [简体中文](README.md) | **English** 6 | 7 | Save Telegram files to various storage endpoints. 8 | 9 | > _Just like PikPak Bot_ 10 | 11 |
12 | 13 | ## Deployment 14 | 15 | ### Deploy from Binary 16 | 17 | Download the binary file for your platform from the [Release](https://github.com/krau/SaveAny-Bot/releases) page. 18 | 19 | Create a `config.toml` file in the extracted directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration. 20 | 21 | Run: 22 | 23 | ```bash 24 | chmod +x saveany-bot 25 | ./saveany-bot 26 | ``` 27 | 28 | #### Add as systemd Service 29 | 30 | Create file `/etc/systemd/system/saveany-bot.service` and write the following content: 31 | 32 | ``` 33 | [Unit] 34 | Description=SaveAnyBot 35 | After=systemd-user-sessions.service 36 | 37 | [Service] 38 | Type=simple 39 | WorkingDirectory=/yourpath/ 40 | ExecStart=/yourpath/saveany-bot 41 | Restart=on-failure 42 | 43 | [Install] 44 | WantedBy=multi-user.target 45 | ``` 46 | 47 | Enable auto-start and start the service: 48 | 49 | ```bash 50 | systemctl enable --now saveany-bot 51 | ``` 52 | 53 | ### Deploy with Docker 54 | 55 | #### Docker Compose 56 | 57 | Download [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) file and create a `config.toml` file in the same directory, refer to [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) for configuration. 58 | 59 | Run: 60 | 61 | ```bash 62 | docker compose up -d 63 | ``` 64 | 65 | #### Docker 66 | 67 | ```shell 68 | docker run -d --name saveany-bot \ 69 | -v /path/to/config.toml:/app/config.toml \ 70 | -v /path/to/downloads:/app/downloads \ 71 | ghcr.io/krau/saveany-bot:latest 72 | ``` 73 | 74 | ## Update 75 | 76 | Use `upgrade` or `up` command to upgrade to the latest version: 77 | 78 | ```bash 79 | ./saveany-bot upgrade 80 | ``` 81 | 82 | If deployed with Docker, use the following commands to update: 83 | 84 | ```bash 85 | docker pull ghcr.io/krau/saveany-bot:latest 86 | docker restart saveany-bot 87 | ``` 88 | 89 | ## Usage 90 | 91 | Send (forward) files to the Bot and follow the prompts. 92 | 93 | --- 94 | 95 | ## Sponsors 96 | 97 | This project is supported by [YxVM](https://yxvm.com/) and [NodeSupport](https://github.com/NodeSeekDev/NodeSupport). 98 | 99 | You can consider sponsoring me if this project helps you: 100 | 101 | - [Afdian](https://afdian.com/a/acherkrau) 102 | 103 | ## Thanks 104 | 105 | - [gotd](https://github.com/gotd/td) 106 | - [TG-FileStreamBot](https://github.com/EverythingSuckz/TG-FileStreamBot) 107 | - [gotgproto](https://github.com/celestix/gotgproto) 108 | - All the dependencies -------------------------------------------------------------------------------- /bot/bot.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "context" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/celestix/gotgproto" 9 | "github.com/celestix/gotgproto/sessionMaker" 10 | "github.com/glebarez/sqlite" 11 | "github.com/gotd/td/telegram/dcs" 12 | "github.com/gotd/td/tg" 13 | "github.com/krau/SaveAny-Bot/common" 14 | "github.com/krau/SaveAny-Bot/config" 15 | "golang.org/x/net/proxy" 16 | ) 17 | 18 | var Client *gotgproto.Client 19 | 20 | func newProxyDialer(proxyUrl string) (proxy.Dialer, error) { 21 | url, err := url.Parse(proxyUrl) 22 | if err != nil { 23 | return nil, err 24 | } 25 | return proxy.FromURL(url, proxy.Direct) 26 | } 27 | 28 | func Init() { 29 | common.Log.Info("初始化 Telegram 客户端...") 30 | ctx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Cfg.Telegram.Timeout)*time.Second) 31 | defer cancel() 32 | go InitTelegraphClient() 33 | resultChan := make(chan struct { 34 | client *gotgproto.Client 35 | err error 36 | }) 37 | go func() { 38 | var resolver dcs.Resolver 39 | if config.Cfg.Telegram.Proxy.Enable && config.Cfg.Telegram.Proxy.URL != "" { 40 | dialer, err := newProxyDialer(config.Cfg.Telegram.Proxy.URL) 41 | if err != nil { 42 | resultChan <- struct { 43 | client *gotgproto.Client 44 | err error 45 | }{nil, err} 46 | return 47 | } 48 | resolver = dcs.Plain(dcs.PlainOptions{ 49 | Dial: dialer.(proxy.ContextDialer).DialContext, 50 | }) 51 | } else { 52 | resolver = dcs.DefaultResolver() 53 | } 54 | client, err := gotgproto.NewClient(config.Cfg.Telegram.AppID, 55 | config.Cfg.Telegram.AppHash, 56 | gotgproto.ClientTypeBot(config.Cfg.Telegram.Token), 57 | &gotgproto.ClientOpts{ 58 | Session: sessionMaker.SqlSession(sqlite.Open(config.Cfg.DB.Session)), 59 | DisableCopyright: true, 60 | Middlewares: FloodWaitMiddleware(), 61 | Resolver: resolver, 62 | MaxRetries: config.Cfg.Telegram.RpcRetry, 63 | }, 64 | ) 65 | if err != nil { 66 | resultChan <- struct { 67 | client *gotgproto.Client 68 | err error 69 | }{nil, err} 70 | return 71 | } 72 | _, err = client.API().BotsSetBotCommands(ctx, &tg.BotsSetBotCommandsRequest{ 73 | Scope: &tg.BotCommandScopeDefault{}, 74 | Commands: []tg.BotCommand{ 75 | {Command: "start", Description: "开始使用"}, 76 | {Command: "help", Description: "显示帮助"}, 77 | {Command: "silent", Description: "开启/关闭静默模式"}, 78 | {Command: "storage", Description: "设置默认存储端"}, 79 | {Command: "save", Description: "保存所回复的文件"}, 80 | {Command: "dir", Description: "管理存储文件夹"}, 81 | {Command: "rule", Description: "管理规则"}, 82 | }, 83 | }) 84 | resultChan <- struct { 85 | client *gotgproto.Client 86 | err error 87 | }{client, err} 88 | }() 89 | 90 | select { 91 | case <-ctx.Done(): 92 | common.Log.Panic("初始化客户端失败: 超时") 93 | case result := <-resultChan: 94 | if result.err != nil { 95 | common.Log.Panicf("初始化客户端失败: %s", result.err) 96 | } 97 | Client = result.client 98 | RegisterHandlers(Client.Dispatcher) 99 | common.Log.Info("客户端初始化完成") 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /bot/handle_add_task.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "path" 7 | "strconv" 8 | "strings" 9 | 10 | "github.com/celestix/gotgproto/dispatcher" 11 | "github.com/celestix/gotgproto/ext" 12 | "github.com/duke-git/lancet/v2/slice" 13 | "github.com/gotd/td/telegram/message/entity" 14 | "github.com/gotd/td/telegram/message/styling" 15 | "github.com/gotd/td/tg" 16 | "github.com/krau/SaveAny-Bot/common" 17 | "github.com/krau/SaveAny-Bot/config" 18 | "github.com/krau/SaveAny-Bot/dao" 19 | "github.com/krau/SaveAny-Bot/queue" 20 | "github.com/krau/SaveAny-Bot/types" 21 | "gorm.io/gorm" 22 | ) 23 | 24 | func AddToQueue(ctx *ext.Context, update *ext.Update) error { 25 | if !slice.Contain(config.Cfg.GetUsersID(), update.CallbackQuery.UserID) { 26 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 27 | QueryID: update.CallbackQuery.QueryID, 28 | Alert: true, 29 | Message: "你没有权限", 30 | CacheTime: 5, 31 | }) 32 | return dispatcher.EndGroups 33 | } 34 | args := strings.Split(string(update.CallbackQuery.Data), " ") 35 | addToDir := args[0] == "add_to_dir" // 已经选择了路径 36 | cbDataId, _ := strconv.Atoi(args[1]) 37 | cbData, err := dao.GetCallbackData(uint(cbDataId)) 38 | if err != nil { 39 | common.Log.Errorf("获取回调数据失败: %s", err) 40 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 41 | QueryID: update.CallbackQuery.QueryID, 42 | Alert: true, 43 | Message: "获取回调数据失败", 44 | CacheTime: 5, 45 | }) 46 | return dispatcher.EndGroups 47 | } 48 | 49 | data := strings.Split(cbData, " ") 50 | fileChatID, _ := strconv.Atoi(data[0]) 51 | fileMessageID, _ := strconv.Atoi(data[1]) 52 | storageName := data[2] 53 | dirIdInt, _ := strconv.Atoi(data[3]) 54 | dirId := uint(dirIdInt) 55 | 56 | user, err := dao.GetUserByChatID(update.CallbackQuery.UserID) 57 | if err != nil { 58 | common.Log.Errorf("获取用户失败: %s", err) 59 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 60 | QueryID: update.CallbackQuery.QueryID, 61 | Alert: true, 62 | Message: "获取用户失败", 63 | CacheTime: 5, 64 | }) 65 | return dispatcher.EndGroups 66 | } 67 | 68 | if !addToDir { 69 | dirs, err := dao.GetDirsByUserIDAndStorageName(user.ID, storageName) 70 | if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { 71 | common.Log.Errorf("获取路径失败: %s", err) 72 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 73 | QueryID: update.CallbackQuery.QueryID, 74 | Alert: true, 75 | Message: "获取路径失败", 76 | CacheTime: 5, 77 | }) 78 | return dispatcher.EndGroups 79 | } 80 | if len(dirs) != 0 { 81 | markup, err := getSelectDirMarkup(fileChatID, fileMessageID, storageName, dirs) 82 | if err != nil { 83 | common.Log.Errorf("获取路径失败: %s", err) 84 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 85 | QueryID: update.CallbackQuery.QueryID, 86 | Alert: true, 87 | Message: "获取路径失败", 88 | CacheTime: 5, 89 | }) 90 | return dispatcher.EndGroups 91 | } 92 | _, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 93 | ID: update.CallbackQuery.GetMsgID(), 94 | Message: "请选择要保存到的路径", 95 | ReplyMarkup: markup, 96 | }) 97 | if err != nil { 98 | common.Log.Errorf("编辑消息失败: %s", err) 99 | } 100 | return dispatcher.EndGroups 101 | } 102 | } 103 | 104 | common.Log.Tracef("Got add to queue: chatID: %d, messageID: %d, storage: %s", fileChatID, fileMessageID, storageName) 105 | record, err := dao.GetReceivedFileByChatAndMessageID(int64(fileChatID), fileMessageID) 106 | if err != nil { 107 | common.Log.Errorf("获取记录失败: %s", err) 108 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 109 | QueryID: update.CallbackQuery.QueryID, 110 | Alert: true, 111 | Message: "查询记录失败", 112 | CacheTime: 5, 113 | }) 114 | return dispatcher.EndGroups 115 | } 116 | if update.CallbackQuery.MsgID != record.ReplyMessageID { 117 | record.ReplyMessageID = update.CallbackQuery.MsgID 118 | if _, err := dao.SaveReceivedFile(record); err != nil { 119 | common.Log.Errorf("更新记录失败: %s", err) 120 | } 121 | } 122 | 123 | var dir *dao.Dir 124 | if addToDir && dirId != 0 { 125 | dir, err = dao.GetDirByID(dirId) 126 | if err != nil { 127 | common.Log.Errorf("获取路径失败: %s", err) 128 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 129 | QueryID: update.CallbackQuery.QueryID, 130 | Alert: true, 131 | Message: "获取路径失败", 132 | CacheTime: 5, 133 | }) 134 | return dispatcher.EndGroups 135 | } 136 | } 137 | 138 | var task types.Task 139 | if record.IsTelegraph { 140 | task = types.Task{ 141 | Ctx: ctx, 142 | Status: types.Pending, 143 | IsTelegraph: true, 144 | TelegraphURL: record.TelegraphURL, 145 | StorageName: storageName, 146 | FileChatID: record.ChatID, 147 | FileMessageID: record.MessageID, 148 | ReplyMessageID: record.ReplyMessageID, 149 | ReplyChatID: record.ReplyChatID, 150 | UserID: update.GetUserChat().GetID(), 151 | } 152 | if dir != nil { 153 | task.StoragePath = path.Join(dir.Path, record.FileName) 154 | } 155 | } else { 156 | file, err := FileFromMessage(ctx, record.ChatID, record.MessageID, record.FileName) 157 | if err != nil { 158 | common.Log.Errorf("获取消息中的文件失败: %s", err) 159 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 160 | QueryID: update.CallbackQuery.QueryID, 161 | Alert: true, 162 | Message: fmt.Sprintf("获取消息中的文件失败: %s", err), 163 | CacheTime: 5, 164 | }) 165 | return dispatcher.EndGroups 166 | } 167 | 168 | task = types.Task{ 169 | Ctx: ctx, 170 | Status: types.Pending, 171 | FileDBID: record.ID, 172 | File: file, 173 | StorageName: storageName, 174 | FileChatID: record.ChatID, 175 | ReplyMessageID: record.ReplyMessageID, 176 | FileMessageID: record.MessageID, 177 | ReplyChatID: record.ReplyChatID, 178 | UserID: update.GetUserChat().GetID(), 179 | } 180 | if dir != nil { 181 | task.StoragePath = path.Join(dir.Path, file.FileName) 182 | } 183 | } 184 | 185 | queue.AddTask(&task) 186 | 187 | entityBuilder := entity.Builder{} 188 | var entities []tg.MessageEntityClass 189 | text := fmt.Sprintf("已添加到任务队列\n文件名: %s\n当前排队任务数: %d", record.FileName, queue.Len()) 190 | if err := styling.Perform(&entityBuilder, 191 | styling.Plain("已添加到任务队列\n文件名: "), 192 | styling.Code(record.FileName), 193 | styling.Plain("\n当前排队任务数: "), 194 | styling.Bold(strconv.Itoa(queue.Len())), 195 | ); err != nil { 196 | common.Log.Errorf("Failed to build entity: %s", err) 197 | } else { 198 | text, entities = entityBuilder.Complete() 199 | } 200 | 201 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 202 | Message: text, 203 | Entities: entities, 204 | ID: record.ReplyMessageID, 205 | }) 206 | return dispatcher.EndGroups 207 | } 208 | -------------------------------------------------------------------------------- /bot/handle_cancel_task.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/celestix/gotgproto/dispatcher" 7 | "github.com/celestix/gotgproto/ext" 8 | "github.com/gotd/td/tg" 9 | "github.com/krau/SaveAny-Bot/queue" 10 | ) 11 | 12 | func cancelTask(ctx *ext.Context, update *ext.Update) error { 13 | key := strings.Split(string(update.CallbackQuery.Data), " ")[1] 14 | ok := queue.CancelTask(key) 15 | if ok { 16 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 17 | QueryID: update.CallbackQuery.QueryID, 18 | Message: "任务已取消", 19 | }) 20 | return dispatcher.EndGroups 21 | } 22 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 23 | QueryID: update.CallbackQuery.QueryID, 24 | Message: "任务取消失败", 25 | }) 26 | return dispatcher.EndGroups 27 | } 28 | -------------------------------------------------------------------------------- /bot/handle_dir.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/celestix/gotgproto/dispatcher" 9 | "github.com/celestix/gotgproto/ext" 10 | "github.com/gotd/td/telegram/message/styling" 11 | "github.com/krau/SaveAny-Bot/common" 12 | "github.com/krau/SaveAny-Bot/dao" 13 | "github.com/krau/SaveAny-Bot/storage" 14 | ) 15 | 16 | func sendDirHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error { 17 | dirs, err := dao.GetUserDirsByChatID(userChatID) 18 | if err != nil { 19 | common.Log.Errorf("获取用户路径失败: %s", err) 20 | ctx.Reply(update, ext.ReplyTextString("获取用户路径失败"), nil) 21 | return dispatcher.EndGroups 22 | } 23 | ctx.Reply(update, ext.ReplyTextStyledTextArray( 24 | []styling.StyledTextOption{ 25 | styling.Bold("使用方法: /dir <操作> <参数...>"), 26 | styling.Plain("\n\n可用操作:\n"), 27 | styling.Code("add"), 28 | styling.Plain(" <存储名> <路径> - 添加路径\n"), 29 | styling.Code("del"), 30 | styling.Plain(" <路径ID> - 删除路径\n"), 31 | styling.Plain("\n添加路径示例:\n"), 32 | styling.Code("/dir add local1 path/to/dir"), 33 | styling.Plain("\n\n删除路径示例:\n"), 34 | styling.Code("/dir del 3"), 35 | styling.Plain("\n\n当前已添加的路径:\n"), 36 | styling.Blockquote(func() string { 37 | var sb strings.Builder 38 | for _, dir := range dirs { 39 | sb.WriteString(fmt.Sprintf("%d: ", dir.ID)) 40 | sb.WriteString(dir.StorageName) 41 | sb.WriteString(" - ") 42 | sb.WriteString(dir.Path) 43 | sb.WriteString("\n") 44 | } 45 | return sb.String() 46 | }(), true), 47 | }, 48 | ), nil) 49 | return dispatcher.EndGroups 50 | } 51 | 52 | func dirCmd(ctx *ext.Context, update *ext.Update) error { 53 | args := strings.Split(update.EffectiveMessage.Text, " ") 54 | if len(args) < 2 { 55 | return sendDirHelp(ctx, update, update.GetUserChat().GetID()) 56 | } 57 | user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) 58 | if err != nil { 59 | common.Log.Errorf("获取用户失败: %s", err) 60 | ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) 61 | return dispatcher.EndGroups 62 | } 63 | switch args[1] { 64 | case "add": 65 | // /dir add local1 path/to/dir 66 | if len(args) < 4 { 67 | return sendDirHelp(ctx, update, update.GetUserChat().GetID()) 68 | } 69 | return addDir(ctx, update, user, args[2], args[3]) 70 | case "del": 71 | // /dir del 3 72 | if len(args) < 3 { 73 | return sendDirHelp(ctx, update, update.GetUserChat().GetID()) 74 | } 75 | dirID, err := strconv.Atoi(args[2]) 76 | if err != nil { 77 | ctx.Reply(update, ext.ReplyTextString("路径ID无效"), nil) 78 | return dispatcher.EndGroups 79 | } 80 | return delDir(ctx, update, dirID) 81 | default: 82 | ctx.Reply(update, ext.ReplyTextString("未知操作"), nil) 83 | return dispatcher.EndGroups 84 | } 85 | } 86 | 87 | func addDir(ctx *ext.Context, update *ext.Update, user *dao.User, storageName, path string) error { 88 | if _, err := storage.GetStorageByUserIDAndName(user.ChatID, storageName); err != nil { 89 | ctx.Reply(update, ext.ReplyTextString(err.Error()), nil) 90 | return dispatcher.EndGroups 91 | } 92 | 93 | if err := dao.CreateDirForUser(user.ID, storageName, path); err != nil { 94 | common.Log.Errorf("创建路径失败: %s", err) 95 | ctx.Reply(update, ext.ReplyTextString("创建路径失败"), nil) 96 | return dispatcher.EndGroups 97 | } 98 | ctx.Reply(update, ext.ReplyTextString("路径添加成功"), nil) 99 | return dispatcher.EndGroups 100 | } 101 | 102 | func delDir(ctx *ext.Context, update *ext.Update, dirID int) error { 103 | if err := dao.DeleteDirByID(uint(dirID)); err != nil { 104 | common.Log.Errorf("删除路径失败: %s", err) 105 | ctx.Reply(update, ext.ReplyTextString("删除路径失败"), nil) 106 | return dispatcher.EndGroups 107 | } 108 | ctx.Reply(update, ext.ReplyTextString("路径删除成功"), nil) 109 | return dispatcher.EndGroups 110 | } 111 | -------------------------------------------------------------------------------- /bot/handle_file.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/celestix/gotgproto/dispatcher" 7 | "github.com/celestix/gotgproto/ext" 8 | "github.com/gotd/td/tg" 9 | "github.com/krau/SaveAny-Bot/common" 10 | "github.com/krau/SaveAny-Bot/dao" 11 | "github.com/krau/SaveAny-Bot/types" 12 | ) 13 | 14 | func handleFileMessage(ctx *ext.Context, update *ext.Update) error { 15 | common.Log.Trace("Got media: ", update.EffectiveMessage.Media.TypeName()) 16 | supported, err := supportedMediaFilter(update.EffectiveMessage.Message) 17 | if err != nil { 18 | return err 19 | } 20 | if !supported { 21 | return dispatcher.EndGroups 22 | } 23 | 24 | user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) 25 | if err != nil { 26 | common.Log.Errorf("获取用户失败: %s", err) 27 | ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) 28 | return dispatcher.EndGroups 29 | } 30 | // storages := storage.GetUserStorages(user.ChatID) 31 | // if len(storages) == 0 { 32 | // ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil) 33 | // return dispatcher.EndGroups 34 | // } 35 | 36 | msg, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil) 37 | if err != nil { 38 | common.Log.Errorf("回复失败: %s", err) 39 | return dispatcher.EndGroups 40 | } 41 | media := update.EffectiveMessage.Media 42 | file, err := FileFromMedia(media, "") 43 | if err != nil { 44 | common.Log.Errorf("获取文件失败: %s", err) 45 | ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("获取文件失败: %s", err)), nil) 46 | return dispatcher.EndGroups 47 | } 48 | if file.FileName == "" { 49 | file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file) 50 | } 51 | 52 | record, err := dao.SaveReceivedFile(&dao.ReceivedFile{ 53 | Processing: false, 54 | FileName: file.FileName, 55 | ChatID: update.EffectiveChat().GetID(), 56 | MessageID: update.EffectiveMessage.ID, 57 | ReplyMessageID: msg.ID, 58 | ReplyChatID: update.GetUserChat().GetID(), 59 | }) 60 | if err != nil { 61 | common.Log.Errorf("添加接收的文件失败: %s", err) 62 | if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 63 | Message: fmt.Sprintf("添加接收的文件失败: %s", err), 64 | ID: msg.ID, 65 | }); err != nil { 66 | common.Log.Errorf("编辑消息失败: %s", err) 67 | } 68 | return dispatcher.EndGroups 69 | } 70 | 71 | if !user.Silent || user.DefaultStorage == "" { 72 | return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), update.EffectiveMessage.ID, msg.ID) 73 | } 74 | return HandleSilentAddTask(ctx, update, user, &types.Task{ 75 | Ctx: ctx, 76 | Status: types.Pending, 77 | FileDBID: record.ID, 78 | File: file, 79 | StorageName: user.DefaultStorage, 80 | FileChatID: update.EffectiveChat().GetID(), 81 | ReplyMessageID: msg.ID, 82 | ReplyChatID: update.GetUserChat().GetID(), 83 | FileMessageID: update.EffectiveMessage.ID, 84 | UserID: user.ChatID, 85 | }) 86 | } 87 | -------------------------------------------------------------------------------- /bot/handle_link.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/celestix/gotgproto/dispatcher" 10 | "github.com/celestix/gotgproto/ext" 11 | "github.com/gotd/td/tg" 12 | "github.com/krau/SaveAny-Bot/common" 13 | "github.com/krau/SaveAny-Bot/dao" 14 | "github.com/krau/SaveAny-Bot/types" 15 | ) 16 | 17 | var ( 18 | linkRegexString = `t.me/.*/\d+` 19 | linkRegex = regexp.MustCompile(linkRegexString) 20 | ) 21 | 22 | func parseLink(ctx *ext.Context, link string) (chatID int64, messageID int, err error) { 23 | strSlice := strings.Split(link, "/") 24 | if len(strSlice) < 3 { 25 | return 0, 0, fmt.Errorf("链接格式错误: %s", link) 26 | } 27 | messageID, err = strconv.Atoi(strSlice[len(strSlice)-1]) 28 | if err != nil { 29 | return 0, 0, fmt.Errorf("无法解析消息 ID: %s", err) 30 | } 31 | if len(strSlice) == 3 { 32 | chatUsername := strSlice[1] 33 | linkChat, err := ctx.ResolveUsername(chatUsername) 34 | if err != nil { 35 | return 0, 0, fmt.Errorf("解析用户名失败: %s", err) 36 | } 37 | if linkChat == nil { 38 | return 0, 0, fmt.Errorf("找不到该聊天: %s", chatUsername) 39 | } 40 | chatID = linkChat.GetID() 41 | } else if len(strSlice) == 4 { 42 | chatIDInt, err := strconv.Atoi(strSlice[2]) 43 | if err != nil { 44 | return 0, 0, fmt.Errorf("无法解析 Chat ID: %s", err) 45 | } 46 | chatID = int64(chatIDInt) 47 | } else { 48 | return 0, 0, fmt.Errorf("无效的链接: %s", link) 49 | } 50 | return chatID, messageID, nil 51 | } 52 | 53 | func handleLinkMessage(ctx *ext.Context, update *ext.Update) error { 54 | common.Log.Trace("Got link message") 55 | link := linkRegex.FindString(update.EffectiveMessage.Text) 56 | if link == "" { 57 | return dispatcher.ContinueGroups 58 | } 59 | linkChatID, messageID, err := parseLink(ctx, link) 60 | if err != nil { 61 | common.Log.Errorf("解析链接失败: %s", err) 62 | ctx.Reply(update, ext.ReplyTextString("解析链接失败: "+err.Error()), nil) 63 | return dispatcher.EndGroups 64 | } 65 | 66 | user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) 67 | if err != nil { 68 | common.Log.Errorf("获取用户失败: %s", err) 69 | ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) 70 | return dispatcher.EndGroups 71 | } 72 | 73 | // storages := storage.GetUserStorages(user.ChatID) 74 | // if len(storages) == 0 { 75 | // ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil) 76 | // return dispatcher.EndGroups 77 | // } 78 | 79 | replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil) 80 | if err != nil { 81 | common.Log.Errorf("回复失败: %s", err) 82 | return dispatcher.EndGroups 83 | } 84 | 85 | file, err := FileFromMessage(ctx, linkChatID, messageID, "") 86 | if err != nil { 87 | common.Log.Errorf("获取文件失败: %s", err) 88 | ctx.Reply(update, ext.ReplyTextString("获取文件失败: "+err.Error()), nil) 89 | return dispatcher.EndGroups 90 | } 91 | if file.FileName == "" { 92 | file.FileName = GenFileNameFromMessage(*update.EffectiveMessage.Message, file) 93 | } 94 | 95 | receivedFile := &dao.ReceivedFile{ 96 | Processing: false, 97 | FileName: file.FileName, 98 | ChatID: linkChatID, 99 | MessageID: messageID, 100 | ReplyMessageID: replied.ID, 101 | ReplyChatID: update.GetUserChat().GetID(), 102 | } 103 | record, err := dao.SaveReceivedFile(receivedFile) 104 | if err != nil { 105 | common.Log.Errorf("保存接收的文件失败: %s", err) 106 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 107 | Message: "无法保存文件: " + err.Error(), 108 | ID: replied.ID, 109 | }) 110 | return dispatcher.EndGroups 111 | } 112 | if !user.Silent || user.DefaultStorage == "" { 113 | return ProvideSelectMessage(ctx, update, file.FileName, linkChatID, messageID, replied.ID) 114 | } 115 | return HandleSilentAddTask(ctx, update, user, &types.Task{ 116 | Ctx: ctx, 117 | Status: types.Pending, 118 | FileDBID: record.ID, 119 | File: file, 120 | StorageName: user.DefaultStorage, 121 | UserID: user.ChatID, 122 | FileChatID: linkChatID, 123 | FileMessageID: messageID, 124 | ReplyMessageID: replied.ID, 125 | ReplyChatID: update.GetUserChat().GetID(), 126 | }) 127 | } 128 | -------------------------------------------------------------------------------- /bot/handle_rule.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/celestix/gotgproto/dispatcher" 9 | "github.com/celestix/gotgproto/ext" 10 | "github.com/duke-git/lancet/v2/slice" 11 | "github.com/gotd/td/telegram/message/styling" 12 | "github.com/krau/SaveAny-Bot/common" 13 | "github.com/krau/SaveAny-Bot/dao" 14 | "github.com/krau/SaveAny-Bot/types" 15 | ) 16 | 17 | func sendRuleHelp(ctx *ext.Context, update *ext.Update, userChatID int64) error { 18 | user, err := dao.GetUserByChatID(userChatID) 19 | if err != nil { 20 | common.Log.Errorf("获取用户规则失败: %s", err) 21 | ctx.Reply(update, ext.ReplyTextString("获取用户规则失败"), nil) 22 | return dispatcher.EndGroups 23 | } 24 | ctx.Reply(update, ext.ReplyTextStyledTextArray( 25 | []styling.StyledTextOption{ 26 | styling.Bold("使用方法: /rule <操作> <参数...>"), 27 | styling.Bold(fmt.Sprintf("\n当前已%s规则模式", map[bool]string{true: "启用", false: "禁用"}[user.ApplyRule])), 28 | styling.Plain("\n\n可用操作:\n"), 29 | styling.Code("switch"), 30 | styling.Plain(" - 开关规则模式\n"), 31 | styling.Code("add"), 32 | styling.Plain(" <类型> <数据> <存储名> <路径> - 添加规则\n"), 33 | styling.Code("del"), 34 | styling.Plain(" <规则ID> - 删除规则\n"), 35 | styling.Plain("\n当前已添加的规则:\n"), 36 | styling.Blockquote(func() string { 37 | var sb strings.Builder 38 | for _, rule := range user.Rules { 39 | ruleText := fmt.Sprintf("%s %s %s %s", rule.Type, rule.Data, rule.StorageName, rule.DirPath) 40 | sb.WriteString(fmt.Sprintf("%d: %s\n", rule.ID, ruleText)) 41 | } 42 | return sb.String() 43 | }(), true), 44 | }, 45 | ), nil) 46 | return dispatcher.EndGroups 47 | } 48 | 49 | func ruleCmd(ctx *ext.Context, update *ext.Update) error { 50 | args := strings.Split(update.EffectiveMessage.Text, " ") 51 | if len(args) < 2 { 52 | return sendRuleHelp(ctx, update, update.GetUserChat().GetID()) 53 | } 54 | user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) 55 | if err != nil { 56 | common.Log.Errorf("获取用户失败: %s", err) 57 | ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) 58 | return dispatcher.EndGroups 59 | } 60 | switch args[1] { 61 | case "switch": 62 | // /rule switch 63 | return switchApplyRule(ctx, update, user) 64 | case "add": 65 | // /rule add 66 | if len(args) < 6 { 67 | return sendRuleHelp(ctx, update, user.ChatID) 68 | } 69 | return addRule(ctx, update, user, args) 70 | case "del": 71 | // /rule del 72 | if len(args) < 3 { 73 | return sendRuleHelp(ctx, update, user.ChatID) 74 | } 75 | ruleID := args[2] 76 | id, err := strconv.Atoi(ruleID) 77 | if err != nil { 78 | ctx.Reply(update, ext.ReplyTextString("无效的规则ID"), nil) 79 | return dispatcher.EndGroups 80 | } 81 | if err := dao.DeleteRule(uint(id)); err != nil { 82 | common.Log.Errorf("删除规则失败: %s", err) 83 | ctx.Reply(update, ext.ReplyTextString("删除规则失败"), nil) 84 | return dispatcher.EndGroups 85 | } 86 | ctx.Reply(update, ext.ReplyTextString("删除规则成功"), nil) 87 | return dispatcher.EndGroups 88 | default: 89 | return sendRuleHelp(ctx, update, user.ChatID) 90 | } 91 | } 92 | 93 | func switchApplyRule(ctx *ext.Context, update *ext.Update, user *dao.User) error { 94 | applyRule := !user.ApplyRule 95 | if err := dao.UpdateUserApplyRule(user.ChatID, applyRule); err != nil { 96 | common.Log.Errorf("更新用户失败: %s", err) 97 | ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil) 98 | return dispatcher.EndGroups 99 | } 100 | if applyRule { 101 | ctx.Reply(update, ext.ReplyTextString("已启用规则模式"), nil) 102 | } else { 103 | ctx.Reply(update, ext.ReplyTextString("已禁用规则模式"), nil) 104 | } 105 | return dispatcher.EndGroups 106 | } 107 | 108 | func addRule(ctx *ext.Context, update *ext.Update, user *dao.User, args []string) error { 109 | // /rule add 110 | ruleType := args[2] 111 | ruleData := args[3] 112 | storageName := args[4] 113 | dirPath := args[5] 114 | 115 | if !slice.Contain(types.RuleTypes, types.RuleType(ruleType)) { 116 | var ruleTypesStylingArray []styling.StyledTextOption 117 | ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Bold("无效的规则类型, 可用类型:\n")) 118 | for i, ruleType := range types.RuleTypes { 119 | ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Code(string(ruleType))) 120 | if i != len(types.RuleTypes)-1 { 121 | ruleTypesStylingArray = append(ruleTypesStylingArray, styling.Plain(", ")) 122 | } 123 | } 124 | ctx.Reply(update, ext.ReplyTextStyledTextArray(ruleTypesStylingArray), nil) 125 | return dispatcher.EndGroups 126 | } 127 | rule := &dao.Rule{ 128 | Type: ruleType, 129 | Data: ruleData, 130 | StorageName: storageName, 131 | DirPath: dirPath, 132 | UserID: user.ID, 133 | } 134 | if err := dao.CreateRule(rule); err != nil { 135 | common.Log.Errorf("添加规则失败: %s", err) 136 | ctx.Reply(update, ext.ReplyTextString("添加规则失败"), nil) 137 | return dispatcher.EndGroups 138 | } 139 | ctx.Reply(update, ext.ReplyTextString("添加规则成功"), nil) 140 | return dispatcher.EndGroups 141 | } 142 | -------------------------------------------------------------------------------- /bot/handle_save.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/celestix/gotgproto/dispatcher" 9 | "github.com/celestix/gotgproto/ext" 10 | "github.com/gotd/td/tg" 11 | "github.com/krau/SaveAny-Bot/common" 12 | "github.com/krau/SaveAny-Bot/dao" 13 | "github.com/krau/SaveAny-Bot/queue" 14 | "github.com/krau/SaveAny-Bot/storage" 15 | "github.com/krau/SaveAny-Bot/types" 16 | ) 17 | 18 | func sendSaveHelp(ctx *ext.Context, update *ext.Update) error { 19 | helpText := ` 20 | 使用方法: 21 | 22 | 1. 使用该命令回复要保存的文件, 可选文件名参数. 23 | 示例: 24 | /save custom_file_name.mp4 25 | 26 | 2. 设置默认存储后, 发送 /save <频道ID/用户名> <消息ID范围> 来批量保存文件. 遵从存储规则, 若未匹配到任何规则则使用默认存储. 27 | 示例: 28 | /save @moreacg 114-514 29 | ` 30 | ctx.Reply(update, ext.ReplyTextString(helpText), nil) 31 | return dispatcher.EndGroups 32 | } 33 | 34 | func saveCmd(ctx *ext.Context, update *ext.Update) error { 35 | args := strings.Split(update.EffectiveMessage.Text, " ") 36 | if len(args) >= 3 { 37 | return handleBatchSave(ctx, update, args[1:]) 38 | } 39 | 40 | replyToMsgID := func() int { 41 | res, ok := update.EffectiveMessage.GetReplyTo() 42 | if !ok || res == nil { 43 | return 0 44 | } 45 | replyHeader, ok := res.(*tg.MessageReplyHeader) 46 | if !ok { 47 | return 0 48 | } 49 | replyToMsgID, ok := replyHeader.GetReplyToMsgID() 50 | if !ok { 51 | return 0 52 | } 53 | return replyToMsgID 54 | }() 55 | if replyToMsgID == 0 { 56 | return sendSaveHelp(ctx, update) 57 | } 58 | 59 | user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) 60 | if err != nil { 61 | common.Log.Errorf("获取用户失败: %s", err) 62 | ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) 63 | return dispatcher.EndGroups 64 | } 65 | 66 | // storages := storage.GetUserStorages(user.ChatID) 67 | // if len(storages) == 0 { 68 | // ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil) 69 | // return dispatcher.EndGroups 70 | // } 71 | 72 | msg, err := GetTGMessage(ctx, update.EffectiveChat().GetID(), replyToMsgID) 73 | if err != nil { 74 | common.Log.Errorf("获取消息失败: %s", err) 75 | ctx.Reply(update, ext.ReplyTextString("无法获取消息"), nil) 76 | return dispatcher.EndGroups 77 | } 78 | 79 | supported, _ := supportedMediaFilter(msg) 80 | if !supported { 81 | ctx.Reply(update, ext.ReplyTextString("不支持的消息类型或消息中没有文件"), nil) 82 | return dispatcher.EndGroups 83 | } 84 | 85 | replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件信息..."), nil) 86 | if err != nil { 87 | common.Log.Errorf("回复失败: %s", err) 88 | return dispatcher.EndGroups 89 | } 90 | 91 | cmdText := update.EffectiveMessage.Text 92 | customFileName := strings.TrimSpace(strings.TrimPrefix(cmdText, "/save")) 93 | 94 | file, err := FileFromMessage(ctx, update.EffectiveChat().GetID(), msg.ID, customFileName) 95 | if err != nil { 96 | common.Log.Errorf("获取文件失败: %s", err) 97 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 98 | Message: fmt.Sprintf("获取文件失败: %s", err), 99 | ID: replied.ID, 100 | }) 101 | return dispatcher.EndGroups 102 | } 103 | 104 | if file.FileName == "" { 105 | file.FileName = GenFileNameFromMessage(*msg, file) 106 | } 107 | receivedFile := &dao.ReceivedFile{ 108 | Processing: false, 109 | FileName: file.FileName, 110 | ChatID: update.EffectiveChat().GetID(), 111 | MessageID: replyToMsgID, 112 | ReplyMessageID: replied.ID, 113 | ReplyChatID: update.GetUserChat().GetID(), 114 | } 115 | 116 | record, err := dao.SaveReceivedFile(receivedFile) 117 | if err != nil { 118 | common.Log.Errorf("保存接收的文件失败: %s", err) 119 | if _, err := ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 120 | Message: fmt.Sprintf("保存接收的文件失败: %s", err), 121 | ID: replied.ID, 122 | }); err != nil { 123 | common.Log.Errorf("编辑消息失败: %s", err) 124 | } 125 | return dispatcher.EndGroups 126 | } 127 | if !user.Silent || user.DefaultStorage == "" { 128 | return ProvideSelectMessage(ctx, update, file.FileName, update.EffectiveChat().GetID(), msg.ID, replied.ID) 129 | } 130 | return HandleSilentAddTask(ctx, update, user, &types.Task{ 131 | Ctx: ctx, 132 | Status: types.Pending, 133 | FileDBID: record.ID, 134 | File: file, 135 | StorageName: user.DefaultStorage, 136 | FileChatID: update.EffectiveChat().GetID(), 137 | ReplyMessageID: replied.ID, 138 | ReplyChatID: update.GetUserChat().GetID(), 139 | FileMessageID: msg.ID, 140 | UserID: user.ChatID, 141 | }) 142 | } 143 | 144 | func handleBatchSave(ctx *ext.Context, update *ext.Update, args []string) error { 145 | // args: [0] = @channel, [1] = 114-514 146 | chatArg := args[0] 147 | var chatID int64 148 | var err error 149 | msgIdSlice := strings.Split(args[1], "-") 150 | if len(msgIdSlice) != 2 { 151 | ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil) 152 | return dispatcher.EndGroups 153 | } 154 | minMsgID, minerr := strconv.ParseInt(msgIdSlice[0], 10, 64) 155 | maxMsgID, maxerr := strconv.ParseInt(msgIdSlice[1], 10, 64) 156 | if minerr != nil || maxerr != nil { 157 | ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil) 158 | return dispatcher.EndGroups 159 | } 160 | if minMsgID > maxMsgID || minMsgID <= 0 || maxMsgID <= 0 { 161 | ctx.Reply(update, ext.ReplyTextString("无效的消息ID范围"), nil) 162 | return dispatcher.EndGroups 163 | } 164 | user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) 165 | if err != nil { 166 | common.Log.Errorf("获取用户失败: %s", err) 167 | ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) 168 | return dispatcher.EndGroups 169 | } 170 | if user.DefaultStorage == "" { 171 | ctx.Reply(update, ext.ReplyTextString("请先设置默认存储"), nil) 172 | return dispatcher.EndGroups 173 | } 174 | storages := storage.GetUserStorages(user.ChatID) 175 | if len(storages) == 0 { 176 | ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil) 177 | return dispatcher.EndGroups 178 | } 179 | 180 | if strings.HasPrefix(chatArg, "@") { 181 | chatUsername := strings.TrimPrefix(chatArg, "@") 182 | chat, err := ctx.ResolveUsername(chatUsername) 183 | if err != nil { 184 | common.Log.Errorf("解析频道用户名失败: %s", err) 185 | ctx.Reply(update, ext.ReplyTextString("解析频道用户名失败"), nil) 186 | return dispatcher.EndGroups 187 | } 188 | if chat == nil { 189 | ctx.Reply(update, ext.ReplyTextString("无法找到聊天"), nil) 190 | return dispatcher.EndGroups 191 | } 192 | chatID = chat.GetID() 193 | } else { 194 | chatID, err = strconv.ParseInt(chatArg, 10, 64) 195 | if err != nil { 196 | ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil) 197 | return dispatcher.EndGroups 198 | } 199 | } 200 | if chatID == 0 { 201 | ctx.Reply(update, ext.ReplyTextString("无效的频道ID或用户名"), nil) 202 | return dispatcher.EndGroups 203 | } 204 | 205 | replied, err := ctx.Reply(update, ext.ReplyTextString("正在批量保存..."), nil) 206 | if err != nil { 207 | common.Log.Errorf("回复失败: %s", err) 208 | return dispatcher.EndGroups 209 | } 210 | 211 | total := maxMsgID - minMsgID + 1 212 | successadd := 0 213 | failedGetFile := 0 214 | failedGetMsg := 0 215 | failedSaveDB := 0 216 | for i := minMsgID; i <= maxMsgID; i++ { 217 | file, err := FileFromMessage(ctx, chatID, int(i), "") 218 | if err != nil { 219 | common.Log.Errorf("获取文件失败: %s", err) 220 | failedGetFile++ 221 | continue 222 | } 223 | if file.FileName == "" { 224 | message, err := GetTGMessage(ctx, chatID, int(i)) 225 | if err != nil { 226 | common.Log.Errorf("获取消息失败: %s", err) 227 | failedGetMsg++ 228 | continue 229 | } 230 | file.FileName = GenFileNameFromMessage(*message, file) 231 | } 232 | receivedFile := &dao.ReceivedFile{ 233 | Processing: false, 234 | FileName: file.FileName, 235 | ChatID: chatID, 236 | MessageID: int(i), 237 | ReplyChatID: update.GetUserChat().GetID(), 238 | ReplyMessageID: 0, 239 | } 240 | record, err := dao.SaveReceivedFile(receivedFile) 241 | if err != nil { 242 | common.Log.Errorf("保存接收的文件失败: %s", err) 243 | failedSaveDB++ 244 | continue 245 | } 246 | task := &types.Task{ 247 | Ctx: ctx, 248 | Status: types.Pending, 249 | FileDBID: record.ID, 250 | File: file, 251 | StorageName: user.DefaultStorage, 252 | FileChatID: chatID, 253 | FileMessageID: int(i), 254 | UserID: user.ChatID, 255 | ReplyMessageID: 0, 256 | ReplyChatID: update.GetUserChat().GetID(), 257 | } 258 | queue.AddTask(task) 259 | successadd++ 260 | } 261 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 262 | Message: fmt.Sprintf("批量添加任务完成\n成功添加: %d/%d\n获取文件失败: %d\n获取消息失败: %d\n保存数据库失败: %d", successadd, total, failedGetFile, failedGetMsg, failedSaveDB), 263 | ID: replied.ID, 264 | }) 265 | return dispatcher.EndGroups 266 | } 267 | -------------------------------------------------------------------------------- /bot/handle_send.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/celestix/gotgproto/dispatcher" 9 | "github.com/celestix/gotgproto/ext" 10 | tgtypes "github.com/celestix/gotgproto/types" 11 | "github.com/gotd/td/tg" 12 | ) 13 | 14 | func copyMediaToChat(ctx *ext.Context, msg *tg.Message, chatID int64) (*tgtypes.Message, error) { 15 | media, ok := msg.GetMedia() 16 | if !ok { 17 | return nil, fmt.Errorf("获取媒体失败") 18 | } 19 | 20 | req := &tg.MessagesSendMediaRequest{ 21 | InvertMedia: msg.InvertMedia, 22 | Message: msg.Message, 23 | } 24 | 25 | switch m := media.(type) { 26 | case *tg.MessageMediaDocument: 27 | document, ok := m.Document.AsNotEmpty() 28 | if !ok { 29 | return nil, ErrEmptyDocument 30 | } 31 | inputMedia := &tg.InputMediaDocument{ 32 | ID: document.AsInput(), 33 | } 34 | inputMedia.SetFlags() 35 | req.Media = inputMedia 36 | 37 | case *tg.MessageMediaPhoto: 38 | photo, ok := m.Photo.AsNotEmpty() 39 | if !ok { 40 | return nil, ErrEmptyPhoto 41 | } 42 | inputMedia := &tg.InputMediaPhoto{ 43 | ID: photo.AsInput(), 44 | } 45 | inputMedia.SetFlags() 46 | req.Media = inputMedia 47 | 48 | default: 49 | return nil, fmt.Errorf("不支持的媒体类型: %T", media) 50 | } 51 | 52 | req.SetEntities(msg.Entities) 53 | req.SetFlags() 54 | 55 | return ctx.SendMedia(chatID, req) 56 | } 57 | 58 | func sendFileToTelegram(ctx *ext.Context, update *ext.Update) error { 59 | args := strings.Split(string(update.CallbackQuery.Data), " ") 60 | if len(args) < 3 { 61 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 62 | QueryID: update.CallbackQuery.QueryID, 63 | Alert: true, 64 | Message: "参数错误", 65 | CacheTime: 5, 66 | }) 67 | return dispatcher.EndGroups 68 | } 69 | fileChatID, _ := strconv.Atoi(args[1]) 70 | fileMessageID, _ := strconv.Atoi(args[2]) 71 | fileMessage, err := GetTGMessage(ctx, int64(fileChatID), fileMessageID) 72 | if err != nil { 73 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 74 | QueryID: update.CallbackQuery.QueryID, 75 | Alert: true, 76 | Message: "无法获取文件消息", 77 | CacheTime: 5, 78 | }) 79 | return dispatcher.EndGroups 80 | } 81 | _, err = copyMediaToChat(ctx, fileMessage, update.EffectiveChat().GetID()) 82 | if err != nil { 83 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 84 | QueryID: update.CallbackQuery.QueryID, 85 | Alert: true, 86 | Message: fmt.Sprintf("发送文件失败: %s", err), 87 | CacheTime: 5, 88 | }) 89 | } else { 90 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 91 | QueryID: update.CallbackQuery.QueryID, 92 | }) 93 | } 94 | return dispatcher.EndGroups 95 | } 96 | -------------------------------------------------------------------------------- /bot/handle_silent.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/celestix/gotgproto/dispatcher" 7 | "github.com/celestix/gotgproto/ext" 8 | "github.com/krau/SaveAny-Bot/common" 9 | "github.com/krau/SaveAny-Bot/dao" 10 | ) 11 | 12 | func silent(ctx *ext.Context, update *ext.Update) error { 13 | user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) 14 | if err != nil { 15 | common.Log.Errorf("获取用户失败: %s", err) 16 | return dispatcher.EndGroups 17 | } 18 | if !user.Silent && user.DefaultStorage == "" { 19 | ctx.Reply(update, ext.ReplyTextString("请先使用 /storage 设置默认存储位置"), nil) 20 | return dispatcher.EndGroups 21 | } 22 | user.Silent = !user.Silent 23 | if err := dao.UpdateUser(user); err != nil { 24 | common.Log.Errorf("更新用户失败: %s", err) 25 | ctx.Reply(update, ext.ReplyTextString("更新用户失败"), nil) 26 | return dispatcher.EndGroups 27 | } 28 | ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf("已%s静默模式", map[bool]string{true: "开启", false: "关闭"}[user.Silent])), nil) 29 | return dispatcher.EndGroups 30 | } 31 | -------------------------------------------------------------------------------- /bot/handle_start.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/celestix/gotgproto/dispatcher" 7 | "github.com/celestix/gotgproto/ext" 8 | "github.com/krau/SaveAny-Bot/common" 9 | "github.com/krau/SaveAny-Bot/dao" 10 | ) 11 | 12 | func start(ctx *ext.Context, update *ext.Update) error { 13 | if err := dao.CreateUser(update.GetUserChat().GetID()); err != nil { 14 | common.Log.Errorf("创建用户失败: %s", err) 15 | return dispatcher.EndGroups 16 | } 17 | return help(ctx, update) 18 | } 19 | 20 | const helpText string = ` 21 | Save Any Bot - 转存你的 Telegram 文件 22 | 版本: %s , 提交: %s 23 | 命令: 24 | /start - 开始使用 25 | /help - 显示帮助 26 | /silent - 开关静默模式 27 | /storage - 设置默认存储位置 28 | /save [自定义文件名] - 保存文件 29 | 30 | 静默模式: 开启后 Bot 直接保存到收到的文件到默认位置, 不再询问 31 | 32 | 默认存储位置: 在静默模式下保存到的位置 33 | 34 | 向 Bot 发送(转发)文件, 或发送一个公开频道的消息链接以保存文件 35 | ` 36 | 37 | func help(ctx *ext.Context, update *ext.Update) error { 38 | ctx.Reply(update, ext.ReplyTextString(fmt.Sprintf(helpText, common.Version, common.GitCommit[:7])), nil) 39 | return dispatcher.EndGroups 40 | } 41 | -------------------------------------------------------------------------------- /bot/handle_storage.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | 8 | "github.com/celestix/gotgproto/dispatcher" 9 | "github.com/celestix/gotgproto/ext" 10 | "github.com/gotd/td/tg" 11 | "github.com/krau/SaveAny-Bot/common" 12 | "github.com/krau/SaveAny-Bot/dao" 13 | "github.com/krau/SaveAny-Bot/storage" 14 | ) 15 | 16 | func storageCmd(ctx *ext.Context, update *ext.Update) error { 17 | userChatID := update.GetUserChat().GetID() 18 | storages := storage.GetUserStorages(userChatID) 19 | if len(storages) == 0 { 20 | ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil) 21 | return dispatcher.EndGroups 22 | } 23 | markup, err := getSetDefaultStorageMarkup(userChatID, storages) 24 | if err != nil { 25 | common.Log.Errorf("Failed to get markup: %s", err) 26 | ctx.Reply(update, ext.ReplyTextString("获取存储位置失败"), nil) 27 | return dispatcher.EndGroups 28 | } 29 | ctx.Reply(update, ext.ReplyTextString("请选择要设为默认的存储位置"), &ext.ReplyOpts{ 30 | Markup: markup, 31 | }) 32 | return dispatcher.EndGroups 33 | } 34 | 35 | func setDefaultStorage(ctx *ext.Context, update *ext.Update) error { 36 | args := strings.Split(string(update.CallbackQuery.Data), " ") 37 | userID, _ := strconv.Atoi(args[1]) 38 | if userID != int(update.CallbackQuery.GetUserID()) { 39 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 40 | QueryID: update.CallbackQuery.QueryID, 41 | Alert: true, 42 | Message: "你没有权限", 43 | CacheTime: 5, 44 | }) 45 | return dispatcher.EndGroups 46 | } 47 | cbDataId, _ := strconv.Atoi(args[2]) 48 | storageName, err := dao.GetCallbackData(uint(cbDataId)) 49 | if err != nil { 50 | common.Log.Errorf("获取回调数据失败: %s", err) 51 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 52 | QueryID: update.CallbackQuery.QueryID, 53 | Alert: true, 54 | Message: "获取回调数据失败", 55 | CacheTime: 5, 56 | }) 57 | return dispatcher.EndGroups 58 | } 59 | 60 | selectedStorage, err := storage.GetStorageByName(storageName) 61 | 62 | if err != nil { 63 | common.Log.Errorf("获取指定存储失败: %s", err) 64 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 65 | QueryID: update.CallbackQuery.QueryID, 66 | Alert: true, 67 | Message: "获取指定存储失败", 68 | CacheTime: 5, 69 | }) 70 | return dispatcher.EndGroups 71 | } 72 | user, err := dao.GetUserByChatID(int64(userID)) 73 | if err != nil { 74 | common.Log.Errorf("Failed to get user: %s", err) 75 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 76 | QueryID: update.CallbackQuery.QueryID, 77 | Alert: true, 78 | Message: "获取用户失败", 79 | CacheTime: 5, 80 | }) 81 | return dispatcher.EndGroups 82 | } 83 | user.DefaultStorage = storageName 84 | if err := dao.UpdateUser(user); err != nil { 85 | common.Log.Errorf("Failed to update user: %s", err) 86 | ctx.AnswerCallback(&tg.MessagesSetBotCallbackAnswerRequest{ 87 | QueryID: update.CallbackQuery.QueryID, 88 | Alert: true, 89 | Message: "更新用户失败", 90 | CacheTime: 5, 91 | }) 92 | return dispatcher.EndGroups 93 | } 94 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 95 | Message: fmt.Sprintf("已将 %s (%s) 设为默认存储位置", selectedStorage.Name(), selectedStorage.Type()), 96 | ID: update.CallbackQuery.GetMsgID(), 97 | }) 98 | return dispatcher.EndGroups 99 | } 100 | -------------------------------------------------------------------------------- /bot/handle_telegraph.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "regexp" 8 | "strings" 9 | "time" 10 | 11 | "github.com/celestix/gotgproto/dispatcher" 12 | "github.com/celestix/gotgproto/ext" 13 | "github.com/celestix/telegraph-go/v2" 14 | "github.com/gotd/td/tg" 15 | "github.com/krau/SaveAny-Bot/common" 16 | "github.com/krau/SaveAny-Bot/config" 17 | "github.com/krau/SaveAny-Bot/dao" 18 | "github.com/krau/SaveAny-Bot/storage" 19 | "github.com/krau/SaveAny-Bot/types" 20 | ) 21 | 22 | var ( 23 | TelegraphClient *telegraph.TelegraphClient 24 | TelegraphUrlRegexString = `https://telegra.ph/.*` 25 | TelegraphUrlRegex = regexp.MustCompile(TelegraphUrlRegexString) 26 | ) 27 | 28 | func InitTelegraphClient() { 29 | var httpClient *http.Client 30 | if config.Cfg.Telegram.Proxy.Enable { 31 | proxyUrl, err := url.Parse(config.Cfg.Telegram.Proxy.URL) 32 | if err != nil { 33 | fmt.Println("Error parsing proxy URL:", err) 34 | return 35 | } 36 | proxy := http.ProxyURL(proxyUrl) 37 | httpClient = &http.Client{ 38 | Transport: &http.Transport{ 39 | Proxy: proxy, 40 | }, 41 | Timeout: 30 * time.Second, 42 | } 43 | } else { 44 | httpClient = &http.Client{ 45 | Timeout: 30 * time.Second, 46 | } 47 | } 48 | TelegraphClient = telegraph.GetTelegraphClient(&telegraph.ClientOpt{HttpClient: httpClient}) 49 | } 50 | 51 | func handleTelegraph(ctx *ext.Context, update *ext.Update) error { 52 | common.Log.Trace("Got telegraph link") 53 | tgphUrl := TelegraphUrlRegex.FindString(update.EffectiveMessage.Text) 54 | if tgphUrl == "" { 55 | return dispatcher.ContinueGroups 56 | } 57 | replied, err := ctx.Reply(update, ext.ReplyTextString("正在获取文件..."), nil) 58 | if err != nil { 59 | common.Log.Errorf("回复失败: %s", err) 60 | return dispatcher.EndGroups 61 | } 62 | user, err := dao.GetUserByChatID(update.GetUserChat().GetID()) 63 | if err != nil { 64 | common.Log.Errorf("获取用户失败: %s", err) 65 | ctx.Reply(update, ext.ReplyTextString("获取用户失败"), nil) 66 | return dispatcher.EndGroups 67 | } 68 | storages := storage.GetUserStorages(user.ChatID) 69 | 70 | if len(storages) == 0 { 71 | ctx.Reply(update, ext.ReplyTextString("无可用的存储"), nil) 72 | return dispatcher.EndGroups 73 | } 74 | 75 | tgphPath := strings.Split(tgphUrl, "/")[len(strings.Split(tgphUrl, "/"))-1] 76 | fileName, err := url.PathUnescape(tgphPath) 77 | if err != nil { 78 | common.Log.Errorf("解析 Telegraph 路径失败: %s", err) 79 | fileName = tgphPath 80 | } 81 | 82 | record := &dao.ReceivedFile{ 83 | Processing: false, 84 | FileName: fileName, 85 | ChatID: update.EffectiveChat().GetID(), 86 | MessageID: update.EffectiveMessage.GetID(), 87 | ReplyMessageID: replied.ID, 88 | ReplyChatID: update.EffectiveChat().GetID(), 89 | IsTelegraph: true, 90 | TelegraphURL: tgphUrl, 91 | } 92 | if _, err := dao.SaveReceivedFile(record); err != nil { 93 | common.Log.Errorf("保存接收的文件失败: %s", err) 94 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 95 | Message: "无法保存文件: " + err.Error(), 96 | ID: replied.ID, 97 | }) 98 | return dispatcher.EndGroups 99 | } 100 | 101 | if !user.Silent || user.DefaultStorage == "" { 102 | return ProvideSelectMessage(ctx, update, fileName, update.EffectiveChat().GetID(), update.EffectiveMessage.GetID(), replied.ID) 103 | } 104 | return HandleSilentAddTask(ctx, update, user, &types.Task{ 105 | Ctx: ctx, 106 | Status: types.Pending, 107 | StorageName: user.DefaultStorage, 108 | UserID: user.ChatID, 109 | ReplyMessageID: replied.ID, 110 | ReplyChatID: update.GetUserChat().GetID(), 111 | IsTelegraph: true, 112 | TelegraphURL: tgphUrl, 113 | }) 114 | } 115 | -------------------------------------------------------------------------------- /bot/handlers.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "github.com/celestix/gotgproto/dispatcher" 5 | "github.com/celestix/gotgproto/dispatcher/handlers" 6 | "github.com/celestix/gotgproto/dispatcher/handlers/filters" 7 | "github.com/krau/SaveAny-Bot/common" 8 | ) 9 | 10 | func RegisterHandlers(dispatcher dispatcher.Dispatcher) { 11 | dispatcher.AddHandler(handlers.NewMessage(filters.Message.All, checkPermission)) 12 | dispatcher.AddHandler(handlers.NewCommand("start", start)) 13 | dispatcher.AddHandler(handlers.NewCommand("help", help)) 14 | dispatcher.AddHandler(handlers.NewCommand("silent", silent)) 15 | dispatcher.AddHandler(handlers.NewCommand("storage", storageCmd)) 16 | dispatcher.AddHandler(handlers.NewCommand("save", saveCmd)) 17 | dispatcher.AddHandler(handlers.NewCommand("dir", dirCmd)) 18 | dispatcher.AddHandler(handlers.NewCommand("rule", ruleCmd)) 19 | linkRegexFilter, err := filters.Message.Regex(linkRegexString) 20 | if err != nil { 21 | common.Log.Panicf("创建正则表达式过滤器失败: %s", err) 22 | } 23 | dispatcher.AddHandler(handlers.NewMessage(linkRegexFilter, handleLinkMessage)) 24 | telegraphUrlRegexFilter, err := filters.Message.Regex(TelegraphUrlRegexString) 25 | if err != nil { 26 | common.Log.Panicf("创建 Telegraph URL 正则表达式过滤器失败: %s", err) 27 | } 28 | dispatcher.AddHandler(handlers.NewMessage(telegraphUrlRegexFilter, handleTelegraph)) 29 | dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("add"), AddToQueue)) 30 | dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("set_default"), setDefaultStorage)) 31 | dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("cancel"), cancelTask)) 32 | dispatcher.AddHandler(handlers.NewCallbackQuery(filters.CallbackQuery.Prefix("send_here"), sendFileToTelegram)) 33 | dispatcher.AddHandler(handlers.NewMessage(filters.Message.Media, handleFileMessage)) 34 | } 35 | -------------------------------------------------------------------------------- /bot/middlewares.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/celestix/gotgproto/dispatcher" 7 | "github.com/celestix/gotgproto/ext" 8 | "github.com/duke-git/lancet/v2/slice" 9 | "github.com/gotd/contrib/middleware/floodwait" 10 | "github.com/gotd/contrib/middleware/ratelimit" 11 | "github.com/gotd/td/telegram" 12 | "github.com/krau/SaveAny-Bot/common" 13 | "github.com/krau/SaveAny-Bot/config" 14 | "golang.org/x/time/rate" 15 | ) 16 | 17 | func FloodWaitMiddleware() []telegram.Middleware { 18 | waiter := floodwait.NewSimpleWaiter().WithMaxRetries(uint(config.Cfg.Telegram.FloodRetry)) 19 | ratelimiter := ratelimit.New(rate.Every(time.Millisecond*100), 5) 20 | return []telegram.Middleware{ 21 | waiter, 22 | ratelimiter, 23 | } 24 | } 25 | 26 | const noPermissionText string = ` 27 | 您不在白名单中, 无法使用此 Bot. 28 | 您可以部署自己的实例: https://github.com/krau/SaveAny-Bot 29 | ` 30 | 31 | func checkPermission(ctx *ext.Context, update *ext.Update) error { 32 | userID := update.GetUserChat().GetID() 33 | if !slice.Contain(config.Cfg.GetUsersID(), userID) { 34 | if config.Cfg.AsPublicCopyMediaBot { 35 | tryCopyMedia(ctx, update) 36 | return dispatcher.EndGroups 37 | } 38 | ctx.Reply(update, ext.ReplyTextString(noPermissionText), nil) 39 | return dispatcher.EndGroups 40 | } 41 | return dispatcher.ContinueGroups 42 | } 43 | 44 | func tryCopyMedia(ctx *ext.Context, update *ext.Update) { 45 | if !config.Cfg.AsPublicCopyMediaBot { 46 | return 47 | } 48 | if update.EffectiveMessage == nil || update.EffectiveMessage.Message == nil { 49 | return 50 | } 51 | msg := update.EffectiveMessage.Message 52 | if link := linkRegex.FindString(update.EffectiveMessage.Text); link != "" { 53 | linkChatID, messageID, err := parseLink(ctx, link) 54 | if err != nil { 55 | return 56 | } 57 | fileMessage, err := GetTGMessage(ctx, linkChatID, messageID) 58 | if err != nil { 59 | return 60 | } 61 | if fileMessage == nil || fileMessage.Media == nil { 62 | return 63 | } 64 | msg = fileMessage 65 | } 66 | if _, ok := msg.GetMedia(); !ok || msg.Media == nil { 67 | ctx.Reply(update, ext.ReplyTextString("消息中没有文件或媒体"), nil) 68 | return 69 | } 70 | common.Log.Tracef("Got copy media request from %d", update.EffectiveChat().GetID()) 71 | if _, err := copyMediaToChat(ctx, msg, update.EffectiveChat().GetID()); err != nil { 72 | common.Log.Errorf("Failed to copy media: %v", err) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /bot/utils.go: -------------------------------------------------------------------------------- 1 | package bot 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/celestix/gotgproto/dispatcher" 11 | "github.com/celestix/gotgproto/ext" 12 | "github.com/gabriel-vasile/mimetype" 13 | "github.com/gotd/td/telegram/message/entity" 14 | "github.com/gotd/td/telegram/message/styling" 15 | "github.com/gotd/td/tg" 16 | "github.com/krau/SaveAny-Bot/common" 17 | "github.com/krau/SaveAny-Bot/dao" 18 | "github.com/krau/SaveAny-Bot/queue" 19 | "github.com/krau/SaveAny-Bot/storage" 20 | "github.com/krau/SaveAny-Bot/types" 21 | ) 22 | 23 | var ( 24 | ErrEmptyDocument = errors.New("document is empty") 25 | ErrEmptyPhoto = errors.New("photo is empty") 26 | ErrEmptyPhotoSize = errors.New("photo size is empty") 27 | ErrEmptyPhotoSizes = errors.New("photo size slice is empty") 28 | ErrNoStorages = errors.New("no available storage") 29 | ErrEmptyMessage = errors.New("message is empty") 30 | ) 31 | 32 | func supportedMediaFilter(m *tg.Message) (bool, error) { 33 | if not := m.Media == nil; not { 34 | return false, dispatcher.EndGroups 35 | } 36 | switch m.Media.(type) { 37 | case *tg.MessageMediaDocument: 38 | return true, nil 39 | case *tg.MessageMediaPhoto: 40 | return true, nil 41 | default: 42 | return false, nil 43 | } 44 | } 45 | 46 | func getSelectStorageMarkup(userChatID int64, fileChatID, fileMessageID int) (*tg.ReplyInlineMarkup, error) { 47 | user, err := dao.GetUserByChatID(userChatID) 48 | if err != nil { 49 | return nil, fmt.Errorf("failed to get user by chat ID: %d, error: %w", userChatID, err) 50 | } 51 | storages := storage.GetUserStorages(user.ChatID) 52 | // if len(storages) == 0 { 53 | // return nil, ErrNoStorages 54 | // } 55 | 56 | buttons := make([]tg.KeyboardButtonClass, 0) 57 | for _, storage := range storages { 58 | cbData := fmt.Sprintf("%d %d %s 0", fileChatID, fileMessageID, storage.Name()) // 0 for empty dir id 59 | cbDataId, err := dao.CreateCallbackData(cbData) 60 | if err != nil { 61 | return nil, fmt.Errorf("failed to create callback data: %w", err) 62 | } 63 | buttons = append(buttons, &tg.KeyboardButtonCallback{ 64 | Text: storage.Name(), 65 | Data: fmt.Appendf(nil, "add %d", cbDataId), 66 | }) 67 | } 68 | markup := &tg.ReplyInlineMarkup{} 69 | for i := 0; i < len(buttons); i += 3 { 70 | row := tg.KeyboardButtonRow{} 71 | row.Buttons = buttons[i:min(i+3, len(buttons))] 72 | markup.Rows = append(markup.Rows, row) 73 | } 74 | markup.Rows = append(markup.Rows, tg.KeyboardButtonRow{ 75 | Buttons: []tg.KeyboardButtonClass{ 76 | &tg.KeyboardButtonCallback{ 77 | Text: "发送到当前聊天", 78 | Data: []byte(fmt.Sprintf("send_here %d %d", fileChatID, fileMessageID)), 79 | }, 80 | }, 81 | }) 82 | return markup, nil 83 | } 84 | 85 | func getSelectDirMarkup(fileChatID, fileMessageID int, storageName string, dirs []dao.Dir) (*tg.ReplyInlineMarkup, error) { 86 | buttons := make([]tg.KeyboardButtonClass, 0) 87 | for _, dir := range dirs { 88 | if dir.ID == 0 || dir.StorageName != storageName { 89 | return nil, fmt.Errorf("unexpected dir: %v", dir) 90 | } 91 | cbDataId, err := dao.CreateCallbackData(fmt.Sprintf("%d %d %s %d", fileChatID, fileMessageID, storageName, dir.ID)) 92 | if err != nil { 93 | return nil, fmt.Errorf("failed to create callback data: %w", err) 94 | } 95 | buttons = append(buttons, &tg.KeyboardButtonCallback{ 96 | Text: dir.Path, 97 | Data: []byte(fmt.Sprintf("add_to_dir %d", cbDataId)), 98 | }) 99 | } 100 | markup := &tg.ReplyInlineMarkup{} 101 | for i := 0; i < len(buttons); i += 3 { 102 | row := tg.KeyboardButtonRow{} 103 | row.Buttons = buttons[i:min(i+3, len(buttons))] 104 | markup.Rows = append(markup.Rows, row) 105 | } 106 | return markup, nil 107 | } 108 | 109 | func getSetDefaultStorageMarkup(userChatID int64, storages []storage.Storage) (*tg.ReplyInlineMarkup, error) { 110 | buttons := make([]tg.KeyboardButtonClass, 0) 111 | for _, storage := range storages { 112 | cbDataId, err := dao.CreateCallbackData(storage.Name()) 113 | if err != nil { 114 | return nil, fmt.Errorf("failed to create callback data: %w", err) 115 | } 116 | buttons = append(buttons, &tg.KeyboardButtonCallback{ 117 | Text: storage.Name(), 118 | Data: []byte(fmt.Sprintf("set_default %d %d", userChatID, cbDataId)), 119 | }) 120 | } 121 | markup := &tg.ReplyInlineMarkup{} 122 | for i := 0; i < len(buttons); i += 3 { 123 | row := tg.KeyboardButtonRow{} 124 | row.Buttons = buttons[i:min(i+3, len(buttons))] 125 | markup.Rows = append(markup.Rows, row) 126 | } 127 | return markup, nil 128 | } 129 | 130 | func FileFromMedia(media tg.MessageMediaClass, customFileName string) (*types.File, error) { 131 | switch media := media.(type) { 132 | case *tg.MessageMediaDocument: 133 | document, ok := media.Document.AsNotEmpty() 134 | if !ok { 135 | return nil, ErrEmptyDocument 136 | } 137 | if customFileName != "" { 138 | return &types.File{ 139 | Location: document.AsInputDocumentFileLocation(), 140 | FileSize: document.Size, 141 | FileName: customFileName, 142 | }, nil 143 | } 144 | fileName := "" 145 | for _, attribute := range document.Attributes { 146 | if name, ok := attribute.(*tg.DocumentAttributeFilename); ok { 147 | fileName = name.GetFileName() 148 | break 149 | } 150 | } 151 | return &types.File{ 152 | Location: document.AsInputDocumentFileLocation(), 153 | FileSize: document.Size, 154 | FileName: fileName, 155 | }, nil 156 | case *tg.MessageMediaPhoto: 157 | photo, ok := media.Photo.AsNotEmpty() 158 | if !ok { 159 | return nil, ErrEmptyPhoto 160 | } 161 | sizes := photo.Sizes 162 | if len(sizes) == 0 { 163 | return nil, ErrEmptyPhotoSizes 164 | } 165 | photoSize := sizes[len(sizes)-1] 166 | size, ok := photoSize.AsNotEmpty() 167 | if !ok { 168 | return nil, ErrEmptyPhotoSize 169 | } 170 | location := new(tg.InputPhotoFileLocation) 171 | location.ID = photo.GetID() 172 | location.AccessHash = photo.GetAccessHash() 173 | location.FileReference = photo.GetFileReference() 174 | location.ThumbSize = size.GetType() 175 | fileName := customFileName 176 | if fileName == "" { 177 | fileName = fmt.Sprintf("photo_%s_%d.jpg", time.Now().Format("2006-01-02_15-04-05"), photo.GetID()) 178 | } 179 | return &types.File{ 180 | Location: location, 181 | FileSize: 0, 182 | FileName: fileName, 183 | }, nil 184 | 185 | } 186 | return nil, fmt.Errorf("unexpected type %T", media) 187 | } 188 | 189 | func FileFromMessage(ctx *ext.Context, chatID int64, messageID int, customFileName string) (*types.File, error) { 190 | key := fmt.Sprintf("file:%d:%d", chatID, messageID) 191 | cachedFile, err := common.CacheGet[*types.File](ctx, key) 192 | if err == nil { 193 | if customFileName != "" { 194 | cachedFile.FileName = customFileName 195 | } 196 | return cachedFile, nil 197 | } 198 | common.Log.Debugf("Getting file: %s", key) 199 | message, err := GetTGMessage(ctx, chatID, messageID) 200 | if err != nil { 201 | return nil, err 202 | } 203 | file, err := FileFromMedia(message.Media, customFileName) 204 | if err != nil { 205 | return nil, err 206 | } 207 | if err := common.CacheSet(ctx, key, file); err != nil { 208 | common.Log.Errorf("Failed to cache file: %s", err) 209 | } 210 | return file, nil 211 | } 212 | 213 | func GetTGMessage(ctx *ext.Context, chatId int64, messageID int) (*tg.Message, error) { 214 | key := fmt.Sprintf("message:%d:%d", chatId, messageID) 215 | cacheMessage, err := common.CacheGet[*tg.Message](ctx, key) 216 | if err == nil { 217 | return cacheMessage, nil 218 | } 219 | common.Log.Debugf("Fetching message: %d:%d", chatId, messageID) 220 | messages, err := ctx.GetMessages(chatId, []tg.InputMessageClass{&tg.InputMessageID{ID: messageID}}) 221 | if err != nil { 222 | return nil, err 223 | } 224 | if len(messages) == 0 { 225 | return nil, ErrEmptyMessage 226 | } 227 | msg := messages[0] 228 | tgMessage, ok := msg.(*tg.Message) 229 | if !ok { 230 | return nil, fmt.Errorf("unexpected message type: %T", msg) 231 | } 232 | if err := common.CacheSet(ctx, key, tgMessage); err != nil { 233 | common.Log.Errorf("Failed to cache message: %s", err) 234 | } 235 | return tgMessage, nil 236 | } 237 | 238 | func ProvideSelectMessage(ctx *ext.Context, update *ext.Update, fileName string, chatID int64, fileMsgID, toEditMsgID int) error { 239 | entityBuilder := entity.Builder{} 240 | var entities []tg.MessageEntityClass 241 | text := fmt.Sprintf("文件名: %s\n请选择存储位置", fileName) 242 | if err := styling.Perform(&entityBuilder, 243 | styling.Plain("文件名: "), 244 | styling.Code(fileName), 245 | styling.Plain("\n请选择存储位置"), 246 | ); err != nil { 247 | common.Log.Errorf("Failed to build entity: %s", err) 248 | } else { 249 | text, entities = entityBuilder.Complete() 250 | } 251 | markup, err := getSelectStorageMarkup(update.GetUserChat().GetID(), int(chatID), fileMsgID) 252 | if errors.Is(err, ErrNoStorages) { 253 | common.Log.Errorf("Failed to get select storage markup: %s", err) 254 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 255 | Message: "无可用存储", 256 | ID: toEditMsgID, 257 | }) 258 | return dispatcher.EndGroups 259 | } else if err != nil { 260 | common.Log.Errorf("Failed to get select storage markup: %s", err) 261 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 262 | Message: "无法获取存储", 263 | ID: toEditMsgID, 264 | }) 265 | return dispatcher.EndGroups 266 | } 267 | _, err = ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 268 | Message: text, 269 | Entities: entities, 270 | ReplyMarkup: markup, 271 | ID: toEditMsgID, 272 | }) 273 | if err != nil { 274 | common.Log.Errorf("Failed to reply: %s", err) 275 | } 276 | return dispatcher.EndGroups 277 | } 278 | 279 | func HandleSilentAddTask(ctx *ext.Context, update *ext.Update, user *dao.User, task *types.Task) error { 280 | if user.DefaultStorage == "" { 281 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 282 | Message: "请先使用 /storage 设置默认存储位置", 283 | ID: task.ReplyMessageID, 284 | }) 285 | return dispatcher.EndGroups 286 | } 287 | queue.AddTask(task) 288 | ctx.EditMessage(update.EffectiveChat().GetID(), &tg.MessagesEditMessageRequest{ 289 | Message: fmt.Sprintf("已添加到队列: %s\n当前排队任务数: %d", task.FileName(), queue.Len()), 290 | ID: task.ReplyMessageID, 291 | }) 292 | return dispatcher.EndGroups 293 | } 294 | 295 | func GenFileNameFromMessage(message tg.Message, file *types.File) string { 296 | if file.FileName != "" { 297 | return file.FileName 298 | } 299 | fileName := genFileNameFromMessageText(message, file) 300 | media, ok := message.GetMedia() 301 | if !ok { 302 | return fileName 303 | } 304 | ext, ok := extraMediaExt(media) 305 | if ok { 306 | return fileName + ext 307 | } 308 | return fileName 309 | } 310 | 311 | func genFileNameFromMessageText(message tg.Message, file *types.File) string { 312 | text := strings.TrimSpace(message.GetMessage()) 313 | if text == "" { 314 | return file.Hash() 315 | } 316 | tags := common.ExtractTagsFromText(text) 317 | if len(tags) > 0 { 318 | return fmt.Sprintf("%s_%s", strings.Join(tags, "_"), strconv.Itoa(message.GetID())) 319 | } 320 | runes := []rune(text) 321 | return string(runes[:min(128, len(runes))]) 322 | } 323 | 324 | func extraMediaExt(media tg.MessageMediaClass) (string, bool) { 325 | switch media := media.(type) { 326 | case *tg.MessageMediaDocument: 327 | doc, ok := media.Document.AsNotEmpty() 328 | if !ok { 329 | return "", false 330 | } 331 | ext := mimetype.Lookup(doc.MimeType).Extension() 332 | if ext == "" { 333 | return "", false 334 | } 335 | return ext, true 336 | case *tg.MessageMediaPhoto: 337 | return ".jpg", true 338 | } 339 | return "", false 340 | } 341 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var rootCmd = &cobra.Command{ 10 | Use: "saveany-bot", 11 | Short: "saveany-bot", 12 | Run: Run, 13 | } 14 | 15 | func Execute() { 16 | if err := rootCmd.Execute(); err != nil { 17 | fmt.Println(err) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cmd/run.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/signal" 7 | "path/filepath" 8 | "syscall" 9 | 10 | "slices" 11 | 12 | "github.com/krau/SaveAny-Bot/bot" 13 | "github.com/krau/SaveAny-Bot/common" 14 | "github.com/krau/SaveAny-Bot/config" 15 | "github.com/krau/SaveAny-Bot/core" 16 | "github.com/krau/SaveAny-Bot/dao" 17 | "github.com/krau/SaveAny-Bot/storage" 18 | "github.com/spf13/cobra" 19 | ) 20 | 21 | func Run(_ *cobra.Command, _ []string) { 22 | InitAll() 23 | core.Run() 24 | 25 | quit := make(chan os.Signal, 1) 26 | signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) 27 | sig := <-quit 28 | common.Log.Info(sig, ", exitting...") 29 | defer common.Log.Info("Bye!") 30 | if config.Cfg.NoCleanCache { 31 | return 32 | } 33 | if config.Cfg.Temp.BasePath != "" && !config.Cfg.Stream { 34 | if slices.Contains([]string{"/", ".", "\\", ".."}, filepath.Clean(config.Cfg.Temp.BasePath)) { 35 | common.Log.Error("无效的缓存文件夹: ", config.Cfg.Temp.BasePath) 36 | return 37 | } 38 | currentDir, err := os.Getwd() 39 | if err != nil { 40 | common.Log.Error("获取工作目录失败: ", err) 41 | return 42 | } 43 | cachePath := filepath.Join(currentDir, config.Cfg.Temp.BasePath) 44 | cachePath, err = filepath.Abs(cachePath) 45 | if err != nil { 46 | common.Log.Error("获取缓存绝对路径失败: ", err) 47 | return 48 | } 49 | common.Log.Info("正在清理缓存文件夹: ", cachePath) 50 | if err := common.RemoveAllInDir(cachePath); err != nil { 51 | common.Log.Error("清理缓存失败: ", err) 52 | } 53 | } 54 | } 55 | 56 | func InitAll() { 57 | if err := config.Init(); err != nil { 58 | fmt.Println("加载配置文件失败: ", err) 59 | os.Exit(1) 60 | } 61 | common.InitLogger() 62 | common.Log.Info("正在启动 SaveAny-Bot...") 63 | dao.Init() 64 | storage.LoadStorages() 65 | common.Init() 66 | bot.Init() 67 | } 68 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | 7 | "github.com/krau/SaveAny-Bot/common" 8 | "github.com/rhysd/go-github-selfupdate/selfupdate" 9 | 10 | "github.com/blang/semver" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | var VersionCmd = &cobra.Command{ 15 | Use: "version", 16 | Aliases: []string{"v"}, 17 | Short: "Print the version number of saveany-bot", 18 | Run: func(cmd *cobra.Command, args []string) { 19 | fmt.Printf("saveany-bot version: %s %s/%s\nBuildTime: %s, Commit: %s\n", common.Version, runtime.GOOS, runtime.GOARCH, common.BuildTime, common.GitCommit) 20 | }, 21 | } 22 | 23 | var upgradeCmd = &cobra.Command{ 24 | Use: "upgrade", 25 | Aliases: []string{"up"}, 26 | Short: "Upgrade saveany-bot to the latest version", 27 | Run: func(cmd *cobra.Command, args []string) { 28 | v := semver.MustParse(common.Version) 29 | latest, err := selfupdate.UpdateSelf(v, "krau/SaveAny-Bot") 30 | if err != nil { 31 | fmt.Println("Binary update failed:", err) 32 | return 33 | } 34 | if latest.Version.Equals(v) { 35 | fmt.Println("Current binary is the latest version", common.Version) 36 | } else { 37 | fmt.Println("Successfully updated to version", latest.Version) 38 | fmt.Println("Release note:\n", latest.ReleaseNotes) 39 | } 40 | }, 41 | } 42 | 43 | func init() { 44 | rootCmd.AddCommand(VersionCmd) 45 | rootCmd.AddCommand(upgradeCmd) 46 | } 47 | -------------------------------------------------------------------------------- /common/cache.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/eko/gocache/lib/v4/cache" 8 | gocachestore "github.com/eko/gocache/store/go_cache/v4" 9 | gocache "github.com/patrickmn/go-cache" 10 | ) 11 | 12 | var Cache *cache.Cache[any] 13 | 14 | func initCache() { 15 | gocacheClient := gocache.New(time.Hour*1, time.Minute*10) 16 | gocacheStore := gocachestore.NewGoCache(gocacheClient) 17 | cacheManager := cache.New[any](gocacheStore) 18 | Cache = cacheManager 19 | } 20 | 21 | func CacheGet[T any](ctx context.Context, key string) (T, error) { 22 | data, err := Cache.Get(ctx, key) 23 | if err != nil { 24 | return *new(T), err 25 | } 26 | if v, ok := data.(T); ok { 27 | return v, nil 28 | } 29 | return *new(T), nil 30 | } 31 | 32 | func CacheSet(ctx context.Context, key string, value any) error { 33 | return Cache.Set(ctx, key, value) 34 | } 35 | 36 | func CacheDelete(ctx context.Context, key string) error { 37 | return Cache.Delete(ctx, key) 38 | } 39 | -------------------------------------------------------------------------------- /common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | func Init() { 4 | initCache() 5 | } 6 | -------------------------------------------------------------------------------- /common/logger.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/gookit/slog" 5 | "github.com/gookit/slog/handler" 6 | "github.com/gookit/slog/rotatefile" 7 | "github.com/krau/SaveAny-Bot/config" 8 | ) 9 | 10 | var Log *slog.Logger 11 | 12 | func InitLogger() { 13 | if Log != nil { 14 | return 15 | } 16 | slog.DefaultChannelName = "SaveAnyBot" 17 | Log = slog.New() 18 | logLevel := slog.LevelByName(config.Cfg.Log.Level) 19 | logFilePath := config.Cfg.Log.File 20 | logBackupNum := config.Cfg.Log.BackupCount 21 | var logLevels []slog.Level 22 | for _, level := range slog.AllLevels { 23 | if level <= logLevel { 24 | logLevels = append(logLevels, level) 25 | } 26 | } 27 | consoleH := handler.NewConsoleHandler(logLevels) 28 | Log.AddHandler(consoleH) 29 | if logFilePath != "" && logBackupNum > 0 { 30 | fileH, err := handler.NewTimeRotateFile( 31 | logFilePath, 32 | rotatefile.EveryDay, 33 | handler.WithLogLevels(slog.AllLevels), 34 | handler.WithBackupNum(logBackupNum)) 35 | if err != nil { 36 | panic(err) 37 | } 38 | Log.AddHandler(fileH) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /common/os.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "time" 7 | ) 8 | 9 | func RmFileAfter(path string, td time.Duration) { 10 | _, err := os.Stat(path) 11 | if err != nil { 12 | Log.Errorf("Failed to create timer for %s: %s", path, err) 13 | return 14 | } 15 | Log.Debugf("Remove file after %s: %s", td, path) 16 | time.AfterFunc(td, func() { 17 | if err := os.Remove(path); err != nil { 18 | Log.Errorf("Failed to remove file %s: %s", path, err) 19 | } 20 | }) 21 | } 22 | 23 | // 删除目录下的所有内容, 但不删除目录本身 24 | func RemoveAllInDir(dirPath string) error { 25 | entries, err := os.ReadDir(dirPath) 26 | if err != nil { 27 | return err 28 | } 29 | for _, entry := range entries { 30 | entryPath := filepath.Join(dirPath, entry.Name()) 31 | if err := os.RemoveAll(entryPath); err != nil { 32 | return err 33 | } 34 | } 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /common/utils.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "regexp" 7 | ) 8 | 9 | func HashString(s string) string { 10 | hash := md5.New() 11 | hash.Write([]byte(s)) 12 | return hex.EncodeToString(hash.Sum(nil)) 13 | } 14 | 15 | var TagRe = regexp.MustCompile(`(?:^|[\p{Zs}\s.,!?(){}[\]<>\"\',。!?():;、])#([\p{L}\d_]+)`) 16 | 17 | func ExtractTagsFromText(text string) []string { 18 | matches := TagRe.FindAllStringSubmatch(text, -1) 19 | tags := make([]string, 0) 20 | for _, match := range matches { 21 | if len(match) > 1 { 22 | tags = append(tags, match[1]) 23 | } 24 | } 25 | return tags 26 | } 27 | -------------------------------------------------------------------------------- /common/version.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | var ( 4 | Version string = "dev" 5 | BuildTime string = "unknown" 6 | GitCommit string = "unknown" 7 | ) 8 | -------------------------------------------------------------------------------- /config.example.toml: -------------------------------------------------------------------------------- 1 | #创建文件时,若需要保留中文注释,请务必确保本文件编码为 UTF-8 ,否则会无法读取。 2 | workers = 4 # 同时下载文件数 3 | retry = 3 # 下载失败重试次数 4 | threads = 4 # 单个任务下载最大线程数 5 | stream = false # 使用stream模式, 详情请查看文档 6 | 7 | [telegram] 8 | # Bot Token 9 | # 更换 Bot Token 后请删除数据库文件和 session.db 10 | token = "" 11 | # Telegram API 配置, 若不配置也可运行, 将使用默认的 API ID 和 API HASH 12 | # 推荐使用自己的 API ID 和 API HASH (https://my.telegram.org) 13 | # app_id = 1025907 14 | # app_hash = "452b0359b988148995f22ff0f4229750" 15 | 16 | # 初始化超时时间, 单位: 秒 17 | timeout = 60 18 | 19 | # flood_retry = 5 20 | # rpc_retry = 5 21 | 22 | [telegram.proxy] 23 | # 启用代理连接 telegram, 只支持 socks5 24 | enable = false 25 | url = "socks5://127.0.0.1:7890" 26 | 27 | # 用户列表 28 | [[users]] 29 | # telegram user id 30 | id = 114514 31 | # 使用黑名单模式,开启后下方留空以使用所有存储,反之则为白名单,白名单请在下方输入允许的存储名 32 | blacklist = true 33 | # 将列表留空并开启黑名单模式以允许使用所有存储,此处示例为黑名单模式,用户 114514 可使用所有存储 34 | storages = [] 35 | 36 | [[users]] 37 | id = 123456 38 | blacklist = false # 使用白名单模式,此时,用户123456 仅可使用下方列表中的存储 39 | # 此时该用户只能使用名为 本机1 的存储 40 | storages = ["本机1"] 41 | 42 | # 存储列表 43 | [[storages]] 44 | # 标识名, 需要唯一 45 | name = "本机1" 46 | # 存储类型, 目前可用: local, alist, webdav, minio 47 | type = "local" 48 | # 启用存储 49 | enable = true 50 | # 文件保存根路径 51 | base_path = "./downloads" 52 | 53 | [[storages]] 54 | name = "MyAlist" 55 | type = "alist" 56 | enable = false #记得启用 57 | base_path = '/' 58 | url = 'https://alist.com' 59 | username = 'admin' 60 | password = 'password' 61 | # alist token 刷新时间 62 | # 86400--1天 604800--7天 1296000--15天 2592000--30天 15552000--180天 63 | token_exp = 86400 64 | # alist 可直接使用 token 登录, 此时 username, password, token_exp 将被忽略 65 | # 请自行在 alist 侧配置合理的 token 过期时间 66 | # token = "" 67 | 68 | [[storages]] 69 | name = "MyWebdav" 70 | type = "webdav" 71 | enable = false 72 | base_path = '/path/telegram' 73 | url = 'https://example.com/dav' 74 | username = 'username' 75 | password = 'password' 76 | 77 | [[storages]] 78 | name = "MyMinio" 79 | type = "minio" 80 | enable = true 81 | endpoint = 'play.min.io' 82 | use_ssl = true 83 | access_key_id = 'Q3AM3UQ867SPQQA43P2F' 84 | secret_access_key = 'zuf+tfteSlswRu7BJ86wekitnifILbZam1KYY3TG' 85 | bucket_name = 'saveanybot' 86 | base_path = '/path/telegram' 87 | 88 | 89 | # 其他配置 90 | 91 | # [log] 92 | # # 日志等级 93 | # level = "DEBUG" 94 | 95 | # [temp] 96 | # # 下载文件临时目录, 请不要在此目录下存放任何其他文件 97 | # base_path = "cache/" 98 | # # 临时文件保存时间, 单位: 秒 99 | # cache_ttl = 30 100 | 101 | # [db] 102 | # path = "data/data.db" # 数据库文件路径 103 | # session = "data/session.db" 104 | 105 | -------------------------------------------------------------------------------- /config/storage/alist.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/krau/SaveAny-Bot/types" 7 | ) 8 | 9 | type AlistStorageConfig struct { 10 | BaseConfig 11 | URL string `toml:"url" mapstructure:"url" json:"url"` 12 | Username string `toml:"username" mapstructure:"username" json:"username"` 13 | Password string `toml:"password" mapstructure:"password" json:"password"` 14 | Token string `toml:"token" mapstructure:"token" json:"token"` 15 | BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"` 16 | TokenExp int64 `toml:"token_exp" mapstructure:"token_exp" json:"token_exp"` 17 | } 18 | 19 | func (a *AlistStorageConfig) Validate() error { 20 | if a.URL == "" { 21 | return fmt.Errorf("url is required for alist storage") 22 | } 23 | if a.Token == "" && (a.Username == "" || a.Password == "") { 24 | return fmt.Errorf("username and password or token is required for alist storage") 25 | } 26 | if a.BasePath == "" { 27 | return fmt.Errorf("base_path is required for alist storage") 28 | } 29 | return nil 30 | } 31 | 32 | func (a *AlistStorageConfig) GetType() types.StorageType { 33 | return types.StorageTypeAlist 34 | } 35 | 36 | func (a *AlistStorageConfig) GetName() string { 37 | return a.Name 38 | } 39 | -------------------------------------------------------------------------------- /config/storage/factory.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/krau/SaveAny-Bot/types" 8 | "github.com/mitchellh/mapstructure" 9 | "github.com/spf13/viper" 10 | ) 11 | 12 | var storageFactories = map[types.StorageType]func(cfg *BaseConfig) (StorageConfig, error){ 13 | types.StorageTypeLocal: createStorageConfig(&LocalStorageConfig{}), 14 | types.StorageTypeAlist: createStorageConfig(&AlistStorageConfig{}), 15 | types.StorageTypeWebdav: createStorageConfig(&WebdavStorageConfig{}), 16 | types.StorageTypeMinio: createStorageConfig(&MinioStorageConfig{}), 17 | } 18 | 19 | func createStorageConfig(configType StorageConfig) func(cfg *BaseConfig) (StorageConfig, error) { 20 | return func(cfg *BaseConfig) (StorageConfig, error) { 21 | configValue := reflect.New(reflect.TypeOf(configType).Elem()).Interface().(StorageConfig) 22 | 23 | reflect.ValueOf(configValue).Elem().FieldByName("BaseConfig").Set(reflect.ValueOf(*cfg)) 24 | 25 | if err := mapstructure.Decode(cfg.RawConfig, configValue); err != nil { 26 | return nil, fmt.Errorf("failed to decode %s storage config: %w", cfg.Type, err) 27 | } 28 | 29 | return configValue, nil 30 | } 31 | } 32 | 33 | func LoadStorageConfigs(v *viper.Viper) ([]StorageConfig, error) { 34 | var baseConfigs []BaseConfig 35 | if err := v.UnmarshalKey("storages", &baseConfigs); err != nil { 36 | return nil, fmt.Errorf("failed to unmarshal storage configs: %w", err) 37 | } 38 | 39 | var configs []StorageConfig 40 | for _, baseCfg := range baseConfigs { 41 | if !baseCfg.Enable { 42 | continue 43 | } 44 | 45 | factory, ok := storageFactories[types.StorageType(baseCfg.Type)] 46 | if !ok { 47 | return nil, fmt.Errorf("unsupported storage type: %s", baseCfg.Type) 48 | } 49 | 50 | cfg, err := factory(&baseCfg) 51 | if err != nil { 52 | return nil, fmt.Errorf("failed to create storage config for %s: %w", baseCfg.Name, err) 53 | } 54 | 55 | if err := cfg.Validate(); err != nil { 56 | return nil, fmt.Errorf("invalid storage config for %s: %w", baseCfg.Name, err) 57 | } 58 | 59 | configs = append(configs, cfg) 60 | } 61 | 62 | return configs, nil 63 | } 64 | -------------------------------------------------------------------------------- /config/storage/local.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/krau/SaveAny-Bot/types" 7 | ) 8 | 9 | type LocalStorageConfig struct { 10 | BaseConfig 11 | BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"` 12 | } 13 | 14 | func (l *LocalStorageConfig) Validate() error { 15 | if l.BasePath == "" { 16 | return fmt.Errorf("path is required for local storage") 17 | } 18 | return nil 19 | } 20 | 21 | func (l *LocalStorageConfig) GetType() types.StorageType { 22 | return types.StorageTypeLocal 23 | } 24 | 25 | func (l *LocalStorageConfig) GetName() string { 26 | return l.Name 27 | } 28 | -------------------------------------------------------------------------------- /config/storage/minio.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/krau/SaveAny-Bot/types" 7 | ) 8 | 9 | type MinioStorageConfig struct { 10 | BaseConfig 11 | Endpoint string `toml:"endpoint" mapstructure:"endpoint" json:"endpoint"` 12 | AccessKeyID string `toml:"access_key_id" mapstructure:"access_key_id" json:"access_key_id"` 13 | SecretAccessKey string `toml:"secret_access_key" mapstructure:"secret_access_key" json:"secret_access_key"` 14 | BucketName string `toml:"bucket_name" mapstructure:"bucket_name" json:"bucket_name"` 15 | UseSSL bool `toml:"use_ssl" mapstructure:"use_ssl" json:"use_ssl"` 16 | BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"` 17 | } 18 | 19 | func (m *MinioStorageConfig) Validate() error { 20 | if m.Endpoint == "" { 21 | return fmt.Errorf("endpoint is required for minio storage") 22 | } 23 | if m.AccessKeyID == "" || m.SecretAccessKey == "" { 24 | return fmt.Errorf("access_key_id and secret_access_key are required for minio storage") 25 | } 26 | if m.BucketName == "" { 27 | return fmt.Errorf("bucket_name is required for minio storage") 28 | } 29 | if m.BasePath == "" { 30 | return fmt.Errorf("base_path is required for minio storage") 31 | } 32 | return nil 33 | } 34 | 35 | func (m *MinioStorageConfig) GetType() types.StorageType { 36 | return types.StorageTypeMinio 37 | } 38 | 39 | func (m *MinioStorageConfig) GetName() string { 40 | return m.Name 41 | } 42 | -------------------------------------------------------------------------------- /config/storage/types.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import "github.com/krau/SaveAny-Bot/types" 4 | 5 | type StorageConfig interface { 6 | Validate() error 7 | GetType() types.StorageType 8 | GetName() string 9 | } 10 | 11 | type BaseConfig struct { 12 | Name string `toml:"name" mapstructure:"name" json:"name"` 13 | Type string `toml:"type" mapstructure:"type" json:"type"` 14 | Enable bool `toml:"enable" mapstructure:"enable" json:"enable"` 15 | RawConfig map[string]any `toml:"-" mapstructure:",remain"` 16 | } 17 | -------------------------------------------------------------------------------- /config/storage/webdav.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/krau/SaveAny-Bot/types" 7 | ) 8 | 9 | type WebdavStorageConfig struct { 10 | BaseConfig 11 | URL string `toml:"url" mapstructure:"url" json:"url"` 12 | Username string `toml:"username" mapstructure:"username" json:"username"` 13 | Password string `toml:"password" mapstructure:"password" json:"password"` 14 | BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"` 15 | } 16 | 17 | func (w *WebdavStorageConfig) Validate() error { 18 | if w.URL == "" { 19 | return fmt.Errorf("url is required for webdav storage") 20 | } 21 | if w.Username == "" || w.Password == "" { 22 | return fmt.Errorf("username and password is required for webdav storage") 23 | } 24 | if w.BasePath == "" { 25 | return fmt.Errorf("base_path is required for webdav storage") 26 | } 27 | return nil 28 | } 29 | 30 | func (w *WebdavStorageConfig) GetType() types.StorageType { 31 | return types.StorageTypeWebdav 32 | } 33 | 34 | func (w *WebdavStorageConfig) GetName() string { 35 | return w.Name 36 | } 37 | -------------------------------------------------------------------------------- /config/user.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/duke-git/lancet/v2/slice" 5 | ) 6 | 7 | type userConfig struct { 8 | ID int64 `toml:"id" mapstructure:"id" json:"id"` // telegram user id 9 | Storages []string `toml:"storages" mapstructure:"storages" json:"storages"` // storage names 10 | Blacklist bool `toml:"blacklist" mapstructure:"blacklist" json:"blacklist"` // 黑名单模式, storage names 中的存储将不会被使用, 默认为白名单模式 11 | } 12 | 13 | var userIDs []int64 14 | var storages []string 15 | var userStorages = make(map[int64][]string) 16 | 17 | func (c *Config) GetStorageNamesByUserID(userID int64) []string { 18 | us, ok := userStorages[userID] 19 | if ok { 20 | return us 21 | } 22 | return nil 23 | } 24 | 25 | func (c *Config) GetUsersID() []int64 { 26 | return userIDs 27 | } 28 | 29 | func (c *Config) HasStorage(userID int64, storageName string) bool { 30 | us, ok := userStorages[userID] 31 | if !ok { 32 | return false 33 | } 34 | return slice.Contain(us, storageName) 35 | } 36 | -------------------------------------------------------------------------------- /config/viper.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/duke-git/lancet/v2/slice" 9 | "github.com/krau/SaveAny-Bot/config/storage" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | type Config struct { 14 | Workers int `toml:"workers" mapstructure:"workers"` 15 | Retry int `toml:"retry" mapstructure:"retry"` 16 | NoCleanCache bool `toml:"no_clean_cache" mapstructure:"no_clean_cache" json:"no_clean_cache"` 17 | Threads int `toml:"threads" mapstructure:"threads" json:"threads"` 18 | Stream bool `toml:"stream" mapstructure:"stream" json:"stream"` 19 | 20 | // Experimental: 将拷贝媒体文件的功能设为公开可用 21 | AsPublicCopyMediaBot bool `toml:"as_public_copy_media_bot" mapstructure:"as_public_copy_media_bot" json:"as_public_copy_media_bot"` 22 | 23 | Users []userConfig `toml:"users" mapstructure:"users" json:"users"` 24 | 25 | Temp tempConfig `toml:"temp" mapstructure:"temp"` 26 | Log logConfig `toml:"log" mapstructure:"log"` 27 | DB dbConfig `toml:"db" mapstructure:"db"` 28 | Telegram telegramConfig `toml:"telegram" mapstructure:"telegram"` 29 | Storages []storage.StorageConfig `toml:"-" mapstructure:"-" json:"storages"` 30 | } 31 | 32 | type tempConfig struct { 33 | BasePath string `toml:"base_path" mapstructure:"base_path" json:"base_path"` 34 | CacheTTL int64 `toml:"cache_ttl" mapstructure:"cache_ttl" json:"cache_ttl"` 35 | } 36 | 37 | type logConfig struct { 38 | Level string `toml:"level" mapstructure:"level"` 39 | File string `toml:"file" mapstructure:"file"` 40 | BackupCount uint `toml:"backup_count" mapstructure:"backup_count" json:"backup_count"` 41 | } 42 | 43 | type dbConfig struct { 44 | Path string `toml:"path" mapstructure:"path"` 45 | Session string `toml:"session" mapstructure:"session"` 46 | Expire int64 `toml:"expire" mapstructure:"expire"` 47 | } 48 | 49 | type telegramConfig struct { 50 | Token string `toml:"token" mapstructure:"token"` 51 | AppID int `toml:"app_id" mapstructure:"app_id" json:"app_id"` 52 | AppHash string `toml:"app_hash" mapstructure:"app_hash" json:"app_hash"` 53 | Timeout int `toml:"timeout" mapstructure:"timeout" json:"timeout"` 54 | Proxy proxyConfig `toml:"proxy" mapstructure:"proxy"` 55 | FloodRetry int `toml:"flood_retry" mapstructure:"flood_retry" json:"flood_retry"` 56 | RpcRetry int `toml:"rpc_retry" mapstructure:"rpc_retry" json:"rpc_retry"` 57 | } 58 | 59 | type proxyConfig struct { 60 | Enable bool `toml:"enable" mapstructure:"enable"` 61 | URL string `toml:"url" mapstructure:"url"` 62 | } 63 | 64 | var Cfg *Config 65 | 66 | func (c Config) GetStorageByName(name string) storage.StorageConfig { 67 | for _, storage := range c.Storages { 68 | if storage.GetName() == name { 69 | return storage 70 | } 71 | } 72 | return nil 73 | } 74 | 75 | func Init() error { 76 | viper.SetConfigName("config") 77 | viper.AddConfigPath(".") 78 | viper.AddConfigPath("/etc/saveany/") 79 | viper.SetConfigType("toml") 80 | viper.SetEnvPrefix("SAVEANY") 81 | viper.AutomaticEnv() 82 | replacer := strings.NewReplacer(".", "_") 83 | viper.SetEnvKeyReplacer(replacer) 84 | 85 | viper.SetDefault("workers", 3) 86 | viper.SetDefault("retry", 3) 87 | viper.SetDefault("threads", 4) 88 | 89 | viper.SetDefault("telegram.app_id", 1025907) 90 | viper.SetDefault("telegram.app_hash", "452b0359b988148995f22ff0f4229750") 91 | viper.SetDefault("telegram.timeout", 60) 92 | viper.SetDefault("telegram.flood_retry", 5) 93 | viper.SetDefault("telegram.rpc_retry", 5) 94 | 95 | viper.SetDefault("temp.base_path", "cache/") 96 | viper.SetDefault("temp.cache_ttl", 30) 97 | 98 | viper.SetDefault("log.level", "INFO") 99 | 100 | viper.SetDefault("db.path", "data/saveany.db") 101 | viper.SetDefault("db.session", "data/session.db") 102 | viper.SetDefault("db.expire", 86400*5) 103 | 104 | if err := viper.SafeWriteConfigAs("config.toml"); err != nil { 105 | if _, ok := err.(viper.ConfigFileAlreadyExistsError); !ok { 106 | return fmt.Errorf("error saving default config: %w", err) 107 | } 108 | } 109 | 110 | if err := viper.ReadInConfig(); err != nil { 111 | fmt.Println("Error reading config file, ", err) 112 | os.Exit(1) 113 | } 114 | 115 | Cfg = &Config{} 116 | 117 | if err := viper.Unmarshal(Cfg); err != nil { 118 | fmt.Println("Error unmarshalling config file, ", err) 119 | os.Exit(1) 120 | } 121 | 122 | storagesConfig, err := storage.LoadStorageConfigs(viper.GetViper()) 123 | if err != nil { 124 | return fmt.Errorf("error loading storage configs: %w", err) 125 | } 126 | Cfg.Storages = storagesConfig 127 | 128 | storageNames := make(map[string]struct{}) 129 | for _, storage := range Cfg.Storages { 130 | if _, ok := storageNames[storage.GetName()]; ok { 131 | return fmt.Errorf("重复的存储名: %s", storage.GetName()) 132 | } 133 | storageNames[storage.GetName()] = struct{}{} 134 | } 135 | 136 | fmt.Printf("已加载 %d 个存储:\n", len(Cfg.Storages)) 137 | for _, storage := range Cfg.Storages { 138 | fmt.Printf(" - %s (%s)\n", storage.GetName(), storage.GetType()) 139 | } 140 | 141 | if Cfg.Workers < 1 || Cfg.Retry < 1 { 142 | return fmt.Errorf("workers 和 retry 必须大于 0, 当前值: workers=%d, retry=%d", Cfg.Workers, Cfg.Retry) 143 | } 144 | 145 | for _, storage := range Cfg.Storages { 146 | storages = append(storages, storage.GetName()) 147 | } 148 | for _, user := range Cfg.Users { 149 | userIDs = append(userIDs, user.ID) 150 | if user.Blacklist { 151 | userStorages[user.ID] = slice.Compact(slice.Difference(storages, user.Storages)) 152 | } else { 153 | userStorages[user.ID] = user.Storages 154 | } 155 | } 156 | 157 | return nil 158 | } 159 | 160 | func Set(key string, value any) { 161 | viper.Set(key, value) 162 | } 163 | 164 | func ReloadConfig() error { 165 | if err := viper.WriteConfig(); err != nil { 166 | return err 167 | } 168 | if err := viper.ReadInConfig(); err != nil { 169 | return err 170 | } 171 | if error := viper.Unmarshal(Cfg); error != nil { 172 | return error 173 | } 174 | return nil 175 | } 176 | -------------------------------------------------------------------------------- /core/core.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/celestix/gotgproto/ext" 9 | "github.com/gotd/td/telegram/downloader" 10 | "github.com/gotd/td/tg" 11 | "github.com/krau/SaveAny-Bot/common" 12 | "github.com/krau/SaveAny-Bot/config" 13 | "github.com/krau/SaveAny-Bot/queue" 14 | "github.com/krau/SaveAny-Bot/types" 15 | ) 16 | 17 | var Downloader *downloader.Downloader 18 | 19 | func init() { 20 | Downloader = downloader.NewDownloader().WithPartSize(1024 * 1024) 21 | } 22 | 23 | func worker(queue *queue.TaskQueue, semaphore chan struct{}) { 24 | for { 25 | semaphore <- struct{}{} 26 | task := queue.GetTask() 27 | common.Log.Debugf("Got task: %s", task.String()) 28 | 29 | switch task.Status { 30 | case types.Pending: 31 | common.Log.Infof("Processing task: %s", task.String()) 32 | if err := processPendingTask(task); err != nil { 33 | task.Error = err 34 | if errors.Is(err, context.Canceled) { 35 | task.Status = types.Canceled 36 | } else { 37 | common.Log.Errorf("Failed to do task: %s", err) 38 | task.Status = types.Failed 39 | } 40 | } else { 41 | task.Status = types.Succeeded 42 | } 43 | queue.AddTask(task) 44 | case types.Succeeded: 45 | common.Log.Infof("Task succeeded: %s", task.String()) 46 | extCtx, ok := task.Ctx.(*ext.Context) 47 | if !ok { 48 | common.Log.Errorf("Context is not *ext.Context: %T", task.Ctx) 49 | } else if task.ReplyMessageID != 0 { 50 | extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 51 | Message: fmt.Sprintf("文件保存成功\n [%s]: %s", task.StorageName, task.StoragePath), 52 | ID: task.ReplyMessageID, 53 | }) 54 | } 55 | case types.Failed: 56 | common.Log.Errorf("Task failed: %s", task.String()) 57 | extCtx, ok := task.Ctx.(*ext.Context) 58 | if !ok { 59 | common.Log.Errorf("Context is not *ext.Context: %T", task.Ctx) 60 | } else if task.ReplyMessageID != 0 { 61 | extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 62 | Message: "文件保存失败\n" + task.Error.Error(), 63 | ID: task.ReplyMessageID, 64 | }) 65 | } 66 | case types.Canceled: 67 | common.Log.Infof("Task canceled: %s", task.String()) 68 | extCtx, ok := task.Ctx.(*ext.Context) 69 | if !ok { 70 | common.Log.Errorf("Context is not *ext.Context: %T", task.Ctx) 71 | } else if task.ReplyMessageID != 0 { 72 | extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 73 | Message: "任务已取消", 74 | ID: task.ReplyMessageID, 75 | }) 76 | } 77 | default: 78 | common.Log.Errorf("Unknown task status: %s", task.Status) 79 | } 80 | <-semaphore 81 | common.Log.Debugf("Task done: %s; status: %s", task.String(), task.Status) 82 | queue.DoneTask(task) 83 | } 84 | } 85 | 86 | func Run() { 87 | common.Log.Info("Start processing tasks...") 88 | semaphore := make(chan struct{}, config.Cfg.Workers) 89 | for i := 0; i < config.Cfg.Workers; i++ { 90 | go worker(queue.Queue, semaphore) 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /core/download.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "path" 10 | "path/filepath" 11 | "strings" 12 | "time" 13 | 14 | "github.com/celestix/gotgproto/ext" 15 | "github.com/celestix/telegraph-go/v2" 16 | "github.com/duke-git/lancet/v2/fileutil" 17 | "github.com/gotd/td/telegram/message/entity" 18 | "github.com/gotd/td/telegram/message/styling" 19 | "github.com/gotd/td/tg" 20 | "github.com/krau/SaveAny-Bot/bot" 21 | "github.com/krau/SaveAny-Bot/common" 22 | "github.com/krau/SaveAny-Bot/config" 23 | "github.com/krau/SaveAny-Bot/storage" 24 | "github.com/krau/SaveAny-Bot/types" 25 | "golang.org/x/sync/errgroup" 26 | ) 27 | 28 | func processPendingTask(task *types.Task) error { 29 | common.Log.Debugf("Start processing task: %s", task.String()) 30 | 31 | if task.FileName() != "" && !task.IsTelegraph && task.File.FileSize != 0 && task.FileDBID != 0 { 32 | ext := path.Ext(task.FileName()) 33 | name := task.FileName()[:len(task.FileName())-len(ext)] 34 | task.File.FileName = fmt.Sprintf("%s_%d%s", name, task.FileDBID, ext) 35 | } 36 | 37 | if task.FileName() == "" { 38 | task.File.FileName = fmt.Sprintf("%d_%d_%s", task.FileChatID, task.FileMessageID, task.File.Hash()) 39 | } 40 | 41 | taskStorage, storagePath, err := getStorageAndPathForTask(task) 42 | if err != nil { 43 | return err 44 | } 45 | if taskStorage == nil { 46 | return fmt.Errorf("not found storage: %s", task.StorageName) 47 | } 48 | task.StoragePath = storagePath 49 | 50 | ctx, ok := task.Ctx.(*ext.Context) 51 | if !ok { 52 | return fmt.Errorf("context is not *ext.Context: %T", task.Ctx) 53 | } 54 | 55 | cancelCtx, cancel := context.WithCancel(ctx) 56 | task.Cancel = cancel 57 | 58 | if task.IsTelegraph { 59 | return processTelegraph(ctx, cancelCtx, task, taskStorage) 60 | } 61 | 62 | if task.File.FileSize == 0 { 63 | return processPhoto(task, taskStorage) 64 | } 65 | 66 | downloadBuilder := Downloader.Download(bot.Client.API(), task.File.Location).WithThreads(getTaskThreads(task.File.FileSize)) 67 | 68 | notsupportStreamStorage, notsupportStream := taskStorage.(storage.StorageNotSupportStream) 69 | cancelMarkUp := getCancelTaskMarkup(task) 70 | 71 | if config.Cfg.Stream { 72 | if !notsupportStream { 73 | text, entities := buildProgressMessageEntity(task, 0, task.StartTime, 0) 74 | if task.ReplyMessageID != 0 { 75 | ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 76 | Message: text, 77 | Entities: entities, 78 | ID: task.ReplyMessageID, 79 | ReplyMarkup: cancelMarkUp, 80 | }) 81 | } 82 | 83 | pr, pw := io.Pipe() 84 | defer pr.Close() 85 | 86 | task.StartTime = time.Now() 87 | progressCallback := buildProgressCallback(ctx, task, getProgressUpdateCount(task.File.FileSize)) 88 | 89 | progressStream := NewProgressStream(pw, task.File.FileSize, progressCallback) 90 | 91 | eg, uploadCtx := errgroup.WithContext(cancelCtx) 92 | 93 | eg.Go(func() error { 94 | return taskStorage.Save(uploadCtx, pr, task.StoragePath) 95 | }) 96 | eg.Go(func() error { 97 | _, err := downloadBuilder.Stream(uploadCtx, progressStream) 98 | if closeErr := pw.CloseWithError(err); closeErr != nil { 99 | common.Log.Errorf("Failed to close pipe writer: %v", closeErr) 100 | } 101 | return err 102 | }) 103 | if err := eg.Wait(); err != nil { 104 | return err 105 | } 106 | 107 | return nil 108 | } 109 | common.Log.Warnf("存储 %s 不支持流式传输: %s", task.StorageName, notsupportStreamStorage.NotSupportStream()) 110 | 111 | if task.ReplyMessageID != 0 { 112 | ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 113 | Message: fmt.Sprintf("存储 %s 不支持流式传输: %s\n正在使用普通下载...", task.StorageName, notsupportStreamStorage.NotSupportStream()), 114 | ID: task.ReplyMessageID, 115 | ReplyMarkup: cancelMarkUp, 116 | }) 117 | } 118 | } 119 | 120 | cacheDestPath := filepath.Join(config.Cfg.Temp.BasePath, task.FileName()) 121 | cacheDestPath, err = filepath.Abs(cacheDestPath) 122 | if err != nil { 123 | return fmt.Errorf("处理路径失败: %w", err) 124 | } 125 | if err := fileutil.CreateDir(filepath.Dir(cacheDestPath)); err != nil { 126 | return fmt.Errorf("创建目录失败: %w", err) 127 | } 128 | 129 | text, entities := buildProgressMessageEntity(task, 0, task.StartTime, 0) 130 | if task.ReplyMessageID != 0 { 131 | ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 132 | Message: text, 133 | Entities: entities, 134 | ID: task.ReplyMessageID, 135 | ReplyMarkup: cancelMarkUp, 136 | }) 137 | } 138 | 139 | progressCallback := buildProgressCallback(ctx, task, getProgressUpdateCount(task.File.FileSize)) 140 | dest, err := NewTaskLocalFile(cacheDestPath, task.File.FileSize, progressCallback) 141 | if err != nil { 142 | return fmt.Errorf("创建文件失败: %w", err) 143 | } 144 | defer dest.Close() 145 | task.StartTime = time.Now() 146 | _, err = downloadBuilder.Parallel(cancelCtx, dest) 147 | if err != nil { 148 | return fmt.Errorf("下载文件失败: %w", err) 149 | } 150 | defer cleanCacheFile(cacheDestPath) 151 | 152 | fixTaskFileExt(task, cacheDestPath) 153 | 154 | common.Log.Infof("Downloaded file: %s", cacheDestPath) 155 | if task.ReplyMessageID != 0 { 156 | ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 157 | Message: fmt.Sprintf("下载完成: %s\n正在转存文件...", task.FileName()), 158 | ID: task.ReplyMessageID, 159 | }) 160 | } 161 | return saveFileWithRetry(cancelCtx, task.StoragePath, taskStorage, cacheDestPath) 162 | } 163 | 164 | func processTelegraph(extCtx *ext.Context, cancelCtx context.Context, task *types.Task, taskStorage storage.Storage) error { 165 | if bot.TelegraphClient == nil { 166 | return fmt.Errorf("telegraph client is not initialized") 167 | } 168 | tgphUrl := task.TelegraphURL 169 | tgphPath := strings.Split(tgphUrl, "/")[len(strings.Split(tgphUrl, "/"))-1] 170 | if tgphUrl == "" || tgphPath == "" { 171 | return fmt.Errorf("invalid telegraph url") 172 | } 173 | entityBuilder := entity.Builder{} 174 | text := fmt.Sprintf("正在下载 Telegraph \n文件夹: %s\n保存路径: %s", 175 | task.FileName(), 176 | fmt.Sprintf("[%s]:%s", task.StorageName, task.StoragePath), 177 | ) 178 | var entities []tg.MessageEntityClass 179 | if err := styling.Perform(&entityBuilder, 180 | styling.Plain("正在下载 Telegraph \n文件夹: "), 181 | styling.Code(task.FileName()), 182 | styling.Plain("\n保存路径: "), 183 | styling.Code(fmt.Sprintf("[%s]:%s", task.StorageName, task.StoragePath)), 184 | ); err != nil { 185 | common.Log.Errorf("Failed to build entities: %s", err) 186 | } 187 | 188 | if task.ReplyMessageID != 0 { 189 | extCtx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 190 | Message: text, 191 | Entities: entities, 192 | ID: task.ReplyMessageID, 193 | ReplyMarkup: getCancelTaskMarkup(task), 194 | }) 195 | } 196 | 197 | resultCh := make(chan error) 198 | go func() { 199 | page, err := bot.TelegraphClient.GetPage(tgphPath, true) 200 | if err != nil { 201 | resultCh <- fmt.Errorf("获取 telegraph 页面失败: %w", err) 202 | return 203 | } 204 | imgs := make([]string, 0) 205 | for _, element := range page.Content { 206 | var node telegraph.NodeElement 207 | data, err := json.Marshal(element) 208 | if err != nil { 209 | common.Log.Errorf("Failed to marshal element: %s", err) 210 | continue 211 | } 212 | err = json.Unmarshal(data, &node) 213 | if err != nil { 214 | common.Log.Errorf("Failed to unmarshal element: %s", err) 215 | continue 216 | } 217 | 218 | if len(node.Children) != 0 { 219 | for _, child := range node.Children { 220 | imgs = append(imgs, getNodeImages(child)...) 221 | } 222 | } 223 | 224 | if node.Tag == "img" { 225 | if src, ok := node.Attrs["src"]; ok { 226 | imgs = append(imgs, src) 227 | } 228 | } 229 | 230 | } 231 | if len(imgs) == 0 { 232 | resultCh <- fmt.Errorf("没有找到图片") 233 | return 234 | } 235 | hc := bot.TelegraphClient.HttpClient 236 | eg, ectx := errgroup.WithContext(cancelCtx) 237 | eg.SetLimit(config.Cfg.Workers) // TODO: use a new config field for this 238 | for i, img := range imgs { 239 | if strings.HasPrefix(img, "/file/") { 240 | img = "https://telegra.ph" + img 241 | } 242 | eg.Go(func() error { 243 | var lastErr error 244 | for attempt := range config.Cfg.Retry { 245 | if attempt > 0 { 246 | retryDelay := time.Duration(attempt*attempt) * time.Second 247 | select { 248 | case <-ectx.Done(): 249 | return ectx.Err() 250 | case <-time.After(retryDelay): 251 | } 252 | common.Log.Debugf("Retrying to download image %s (attempt %d)", img, attempt+1) 253 | } 254 | req, err := http.NewRequestWithContext(ectx, http.MethodGet, img, nil) 255 | if err != nil { 256 | lastErr = fmt.Errorf("创建请求失败: %w", err) 257 | continue 258 | } 259 | resp, err := hc.Do(req) 260 | if err != nil { 261 | lastErr = fmt.Errorf("发送请求失败: %w", err) 262 | continue 263 | } 264 | defer resp.Body.Close() 265 | if resp.StatusCode != http.StatusOK { 266 | lastErr = fmt.Errorf("请求图片失败: %s", resp.Status) 267 | continue 268 | } 269 | targetPath := path.Join(task.StoragePath, fmt.Sprintf("%d%s", i+1, path.Ext(img))) 270 | err = taskStorage.Save(ectx, resp.Body, targetPath) 271 | if err != nil { 272 | lastErr = fmt.Errorf("保存图片失败: %w", err) 273 | continue 274 | } 275 | common.Log.Infof("Saved image: %s", targetPath) 276 | return nil 277 | } 278 | return lastErr 279 | }) 280 | } 281 | if err := eg.Wait(); err != nil { 282 | resultCh <- err 283 | return 284 | } 285 | resultCh <- nil 286 | }() 287 | select { 288 | case err := <-resultCh: 289 | return err 290 | case <-cancelCtx.Done(): 291 | return cancelCtx.Err() 292 | } 293 | } 294 | -------------------------------------------------------------------------------- /core/download_test.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/celestix/telegraph-go/v2" 8 | ) 9 | 10 | func TestGetImgSrcs(t *testing.T) { 11 | complexStructure := telegraph.NodeElement{ 12 | Tag: "div", 13 | Children: []telegraph.Node{ 14 | telegraph.NodeElement{ 15 | Tag: "figure", 16 | Children: []telegraph.Node{ 17 | telegraph.NodeElement{ 18 | Tag: "img", 19 | Attrs: map[string]string{ 20 | "src": "https://example.com/image1.png", 21 | }, 22 | }, 23 | telegraph.NodeElement{ 24 | Tag: "p", 25 | Children: []telegraph.Node{ 26 | "A text node", 27 | }, 28 | }, 29 | telegraph.NodeElement{ 30 | Tag: "figure", 31 | Children: []telegraph.Node{ 32 | telegraph.NodeElement{ 33 | Tag: "img", 34 | Attrs: map[string]string{ 35 | "src": "https://example.com/image2.png", 36 | }, 37 | }, 38 | }, 39 | }, 40 | }, 41 | }, 42 | telegraph.NodeElement{ 43 | Tag: "img", 44 | Attrs: map[string]string{ 45 | "src": "https://example.com/image3.png", 46 | }, 47 | }, 48 | "text node", 49 | telegraph.NodeElement{ 50 | Tag: "div", 51 | Children: []telegraph.Node{ 52 | telegraph.NodeElement{ 53 | Tag: "span", 54 | Children: []telegraph.Node{ 55 | telegraph.NodeElement{ 56 | Tag: "img", 57 | Attrs: map[string]string{ 58 | "src": "https://example.com/image4.png", 59 | }, 60 | }, 61 | }, 62 | }, 63 | }, 64 | }, 65 | }, 66 | } 67 | 68 | expected := []string{ 69 | "https://example.com/image1.png", 70 | "https://example.com/image2.png", 71 | "https://example.com/image3.png", 72 | "https://example.com/image4.png", 73 | } 74 | 75 | got := getNodeImages(complexStructure) 76 | 77 | if !reflect.DeepEqual(expected, got) { 78 | t.Errorf("expected %v,got %v", expected, got) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /core/rule.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "path" 6 | "regexp" 7 | 8 | "github.com/celestix/gotgproto/ext" 9 | "github.com/krau/SaveAny-Bot/bot" 10 | "github.com/krau/SaveAny-Bot/common" 11 | "github.com/krau/SaveAny-Bot/dao" 12 | "github.com/krau/SaveAny-Bot/storage" 13 | "github.com/krau/SaveAny-Bot/types" 14 | ) 15 | 16 | func getStorageAndPathForTask(task *types.Task) (storage.Storage, string, error) { 17 | user, err := dao.GetUserByChatID(task.UserID) 18 | if err != nil { 19 | return nil, "", fmt.Errorf("failed to get user by chat ID: %w", err) 20 | } 21 | if task.StoragePath == "" { 22 | task.StoragePath = task.FileName() 23 | } 24 | taskStorage, err := storage.GetStorageByUserIDAndName(task.UserID, task.StorageName) 25 | if err != nil { 26 | return nil, "", err 27 | } 28 | storagePath := taskStorage.JoinStoragePath(*task) 29 | if !user.ApplyRule || user.Rules == nil { 30 | return taskStorage, storagePath, nil 31 | } 32 | var ruleTaskStorage storage.Storage 33 | var ruleStoragePath string 34 | for _, rule := range user.Rules { 35 | matchStorage, matchStoragePath := applyRule(&rule, *task) 36 | if matchStorage != nil && matchStoragePath != "" { 37 | ruleTaskStorage = matchStorage 38 | ruleStoragePath = matchStoragePath 39 | } 40 | } 41 | if ruleStoragePath == "" || ruleTaskStorage == nil { 42 | return taskStorage, storagePath, nil 43 | } 44 | common.Log.Debugf("Rule matched: %s, %s", ruleTaskStorage.Name(), ruleStoragePath) 45 | return ruleTaskStorage, ruleStoragePath, nil 46 | } 47 | 48 | func applyRule(rule *dao.Rule, task types.Task) (storage.Storage, string) { 49 | var DirPath, StorageName string 50 | switch rule.Type { 51 | case string(types.RuleTypeFileNameRegex): 52 | ruleRegex, err := regexp.Compile(rule.Data) 53 | if err != nil { 54 | common.Log.Errorf("failed to compile regex: %s", err) 55 | return nil, "" 56 | } 57 | if !ruleRegex.MatchString(task.FileName()) { 58 | return nil, "" 59 | } 60 | DirPath = rule.DirPath 61 | StorageName = rule.StorageName 62 | case string(types.RuleTypeMessageRegex): 63 | ruleRegex, err := regexp.Compile(rule.Data) 64 | if err != nil { 65 | common.Log.Errorf("failed to compile regex: %s", err) 66 | return nil, "" 67 | } 68 | ctx, ok := task.Ctx.(*ext.Context) 69 | if !ok { 70 | common.Log.Fatalf("context is not *ext.Context: %T", task.Ctx) 71 | return nil, "" 72 | } 73 | msg, err := bot.GetTGMessage(ctx, task.FileChatID, task.FileMessageID) 74 | if err != nil { 75 | common.Log.Errorf("failed to get message: %s", err) 76 | return nil, "" 77 | } 78 | if msg == nil { 79 | return nil, "" 80 | } 81 | if !ruleRegex.MatchString(msg.GetMessage()) { 82 | return nil, "" 83 | } 84 | DirPath = rule.DirPath 85 | StorageName = rule.StorageName 86 | default: 87 | common.Log.Errorf("unknown rule type: %s", rule.Type) 88 | return nil, "" 89 | } 90 | taskStorageName := func() string { 91 | if StorageName == "" || StorageName == "CHOSEN" { 92 | return task.StorageName 93 | } 94 | return StorageName 95 | }() 96 | taskStorage, err := storage.GetStorageByUserIDAndName(task.UserID, taskStorageName) 97 | if err != nil { 98 | common.Log.Errorf("failed to get storage: %s", err) 99 | return nil, "" 100 | } 101 | task.StoragePath = path.Join(DirPath, task.StoragePath) 102 | return taskStorage, taskStorage.JoinStoragePath(task) 103 | } 104 | -------------------------------------------------------------------------------- /core/utils.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path" 11 | "time" 12 | 13 | "github.com/celestix/gotgproto/ext" 14 | "github.com/celestix/telegraph-go/v2" 15 | "github.com/gabriel-vasile/mimetype" 16 | "github.com/gotd/td/telegram/message/entity" 17 | "github.com/gotd/td/telegram/message/styling" 18 | "github.com/gotd/td/tg" 19 | "github.com/krau/SaveAny-Bot/bot" 20 | "github.com/krau/SaveAny-Bot/common" 21 | "github.com/krau/SaveAny-Bot/config" 22 | "github.com/krau/SaveAny-Bot/storage" 23 | "github.com/krau/SaveAny-Bot/types" 24 | ) 25 | 26 | func saveFileWithRetry(ctx context.Context, storagePath string, taskStorage storage.Storage, cacheFilePath string) error { 27 | file, err := os.Open(cacheFilePath) 28 | if err != nil { 29 | return fmt.Errorf("failed to open cache file: %w", err) 30 | } 31 | defer file.Close() 32 | fileStat, err := file.Stat() 33 | if err != nil { 34 | return fmt.Errorf("failed to get file stat: %w", err) 35 | } 36 | vctx := context.WithValue(ctx, types.ContextKeyContentLength, fileStat.Size()) 37 | for i := 0; i <= config.Cfg.Retry; i++ { 38 | if err := vctx.Err(); err != nil { 39 | return fmt.Errorf("context canceled while saving file: %w", err) 40 | } 41 | file, err := os.Open(cacheFilePath) 42 | if err != nil { 43 | return fmt.Errorf("failed to open cache file: %w", err) 44 | } 45 | defer file.Close() 46 | if err := taskStorage.Save(vctx, file, storagePath); err != nil { 47 | if i == config.Cfg.Retry { 48 | return fmt.Errorf("failed to save file: %w", err) 49 | } 50 | common.Log.Errorf("Failed to save file: %s, retrying...", err) 51 | select { 52 | case <-vctx.Done(): 53 | return fmt.Errorf("context canceled during retry delay: %w", vctx.Err()) 54 | case <-time.After(time.Duration(i*500) * time.Millisecond): 55 | } 56 | continue 57 | } 58 | return nil 59 | } 60 | return nil 61 | } 62 | 63 | func processPhoto(task *types.Task, taskStorage storage.Storage) error { 64 | res, err := bot.Client.API().UploadGetFile(task.Ctx, &tg.UploadGetFileRequest{ 65 | Location: task.File.Location, 66 | Offset: 0, 67 | Limit: 1024 * 1024, 68 | }) 69 | if err != nil { 70 | return fmt.Errorf("failed to get file: %w", err) 71 | } 72 | 73 | result, ok := res.(*tg.UploadFile) 74 | if !ok { 75 | return fmt.Errorf("unexpected type %T", res) 76 | } 77 | 78 | common.Log.Infof("Downloaded photo: %s", task.FileName()) 79 | 80 | return taskStorage.Save(task.Ctx, bytes.NewReader(result.Bytes), task.StoragePath) 81 | } 82 | 83 | func cleanCacheFile(destPath string) { 84 | if config.Cfg.Temp.CacheTTL > 0 { 85 | common.RmFileAfter(destPath, time.Duration(config.Cfg.Temp.CacheTTL)*time.Second) 86 | } else { 87 | if err := os.Remove(destPath); err != nil { 88 | common.Log.Errorf("Failed to purge file: %s", err) 89 | } 90 | } 91 | } 92 | 93 | // 获取进度需要更新的次数 94 | func getProgressUpdateCount(fileSize int64) int { 95 | updateCount := 5 96 | if fileSize > 1024*1024*1000 { 97 | updateCount = 50 98 | } else if fileSize > 1024*1024*500 { 99 | updateCount = 20 100 | } else if fileSize > 1024*1024*200 { 101 | updateCount = 10 102 | } 103 | return updateCount 104 | } 105 | 106 | func getSpeed(bytesRead int64, startTime time.Time) string { 107 | if startTime.IsZero() { 108 | return "0MB/s" 109 | } 110 | elapsed := time.Since(startTime) 111 | speed := float64(bytesRead) / 1024 / 1024 / elapsed.Seconds() 112 | return fmt.Sprintf("%.2fMB/s", speed) 113 | } 114 | 115 | func buildProgressMessageEntity(task *types.Task, bytesRead int64, startTime time.Time, progress float64) (string, []tg.MessageEntityClass) { 116 | entityBuilder := entity.Builder{} 117 | text := fmt.Sprintf("正在处理下载任务\n文件名: %s\n保存路径: %s\n平均速度: %s\n当前进度: %.2f%%", 118 | task.FileName(), 119 | fmt.Sprintf("[%s]:%s", task.StorageName, task.StoragePath), 120 | getSpeed(bytesRead, startTime), 121 | progress, 122 | ) 123 | var entities []tg.MessageEntityClass 124 | if err := styling.Perform(&entityBuilder, 125 | styling.Plain("正在处理下载任务\n文件名: "), 126 | styling.Code(task.FileName()), 127 | styling.Plain("\n保存路径: "), 128 | styling.Code(fmt.Sprintf("[%s]:%s", task.StorageName, task.StoragePath)), 129 | styling.Plain("\n平均速度: "), 130 | styling.Bold(getSpeed(bytesRead, task.StartTime)), 131 | styling.Plain("\n当前进度: "), 132 | styling.Bold(fmt.Sprintf("%.2f%%", progress)), 133 | ); err != nil { 134 | common.Log.Errorf("Failed to build entities: %s", err) 135 | return text, entities 136 | } 137 | return entityBuilder.Complete() 138 | } 139 | 140 | func buildProgressCallback(ctx *ext.Context, task *types.Task, updateCount int) func(bytesRead, contentLength int64) { 141 | return func(bytesRead, contentLength int64) { 142 | progress := float64(bytesRead) / float64(contentLength) * 100 143 | common.Log.Tracef("Downloading %s: %.2f%%", task.String(), progress) 144 | progressInt := int(progress) 145 | if task.File.FileSize < 1024*1024*50 || progressInt == 0 || progressInt%int(100/updateCount) != 0 { 146 | return 147 | } 148 | if task.ReplyMessageID == 0 { 149 | return 150 | } 151 | text, entities := buildProgressMessageEntity(task, bytesRead, task.StartTime, progress) 152 | ctx.EditMessage(task.ReplyChatID, &tg.MessagesEditMessageRequest{ 153 | Message: text, 154 | Entities: entities, 155 | ID: task.ReplyMessageID, 156 | ReplyMarkup: getCancelTaskMarkup(task), 157 | }) 158 | } 159 | } 160 | 161 | func getCancelTaskMarkup(task *types.Task) *tg.ReplyInlineMarkup { 162 | return &tg.ReplyInlineMarkup{ 163 | Rows: []tg.KeyboardButtonRow{{Buttons: []tg.KeyboardButtonClass{&tg.KeyboardButtonCallback{Text: "取消任务", Data: fmt.Appendf(nil, "cancel %s", task.Key())}}}}, 164 | } 165 | } 166 | 167 | func fixTaskFileExt(task *types.Task, localFilePath string) { 168 | if path.Ext(task.FileName()) == "" { 169 | mimeType, err := mimetype.DetectFile(localFilePath) 170 | if err != nil { 171 | common.Log.Errorf("Failed to detect mime type: %s", err) 172 | } else { 173 | task.File.FileName = fmt.Sprintf("%s%s", task.FileName(), mimeType.Extension()) 174 | task.StoragePath = fmt.Sprintf("%s%s", task.StoragePath, mimeType.Extension()) 175 | } 176 | } 177 | } 178 | 179 | func getTaskThreads(fileSize int64) int { 180 | threads := 1 181 | if fileSize > 1024*1024*100 { 182 | threads = config.Cfg.Threads 183 | } else if fileSize > 1024*1024*50 { 184 | threads = config.Cfg.Threads / 2 185 | } 186 | return threads 187 | } 188 | 189 | type TaskLocalFile struct { 190 | file *os.File 191 | size int64 192 | done int64 193 | progressCallback func(bytesRead, contentLength int64) 194 | callbackTimes int64 195 | nextCallbackAt int64 196 | callbackInterval int64 197 | } 198 | 199 | func (t *TaskLocalFile) Read(p []byte) (n int, err error) { 200 | return t.file.Read(p) 201 | } 202 | 203 | func (t *TaskLocalFile) Close() error { 204 | return t.file.Close() 205 | } 206 | func (t *TaskLocalFile) WriteAt(p []byte, off int64) (int, error) { 207 | n, err := t.file.WriteAt(p, off) 208 | if err != nil { 209 | return n, err 210 | } 211 | t.done += int64(n) 212 | if t.progressCallback != nil && t.done >= t.nextCallbackAt { 213 | t.progressCallback(t.done, t.size) 214 | t.nextCallbackAt += t.callbackInterval 215 | } 216 | return n, nil 217 | } 218 | 219 | func NewTaskLocalFile(filePath string, fileSize int64, progressCallback func(bytesRead, contentLength int64)) (*TaskLocalFile, error) { 220 | file, err := os.Create(filePath) 221 | if err != nil { 222 | return nil, fmt.Errorf("failed to open file: %w", err) 223 | } 224 | var callbackInterval int64 225 | callbackInterval = fileSize / 100 226 | if callbackInterval == 0 { 227 | callbackInterval = 1 228 | } 229 | return &TaskLocalFile{ 230 | file: file, 231 | size: fileSize, 232 | progressCallback: progressCallback, 233 | callbackTimes: 100, 234 | nextCallbackAt: callbackInterval, 235 | callbackInterval: callbackInterval, 236 | }, nil 237 | } 238 | 239 | type ProgressStream struct { 240 | writer io.Writer 241 | size int64 242 | done int64 243 | callback func(bytesRead, contentLength int64) 244 | nextAt int64 245 | interval int64 246 | } 247 | 248 | func (ps *ProgressStream) Write(p []byte) (n int, err error) { 249 | n, err = ps.writer.Write(p) 250 | if err != nil { 251 | return n, err 252 | } 253 | ps.done += int64(n) 254 | if ps.callback != nil && ps.done >= ps.nextAt { 255 | ps.callback(ps.done, ps.size) 256 | ps.nextAt += ps.interval 257 | } 258 | return n, nil 259 | } 260 | 261 | func NewProgressStream(writer io.Writer, size int64, callback func(bytesRead, contentLength int64)) *ProgressStream { 262 | var interval int64 263 | interval = size / 100 264 | if interval == 0 { 265 | interval = 1 266 | } 267 | return &ProgressStream{ 268 | writer: writer, 269 | size: size, 270 | callback: callback, 271 | nextAt: interval, 272 | interval: interval, 273 | } 274 | } 275 | 276 | func getNodeImages(node telegraph.Node) []string { 277 | var srcs []string 278 | 279 | var nodeElement telegraph.NodeElement 280 | data, err := json.Marshal(node) 281 | if err != nil { 282 | return srcs 283 | } 284 | err = json.Unmarshal(data, &nodeElement) 285 | if err != nil { 286 | return srcs 287 | } 288 | 289 | if nodeElement.Tag == "img" { 290 | if src, exists := nodeElement.Attrs["src"]; exists { 291 | srcs = append(srcs, src) 292 | } 293 | } 294 | for _, child := range nodeElement.Children { 295 | srcs = append(srcs, getNodeImages(child)...) 296 | } 297 | return srcs 298 | } 299 | -------------------------------------------------------------------------------- /dao/callback_data.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | func CreateCallbackData(data string) (uint, error) { 4 | callbackData := CallbackData{ 5 | Data: data, 6 | } 7 | err := db.Create(&callbackData).Error 8 | return callbackData.ID, err 9 | } 10 | 11 | func GetCallbackData(id uint) (string, error) { 12 | var callbackData CallbackData 13 | err := db.First(&callbackData, id).Error 14 | return callbackData.Data, err 15 | } 16 | 17 | func DeleteCallbackData(id uint) error { 18 | return db.Unscoped().Where("id = ?", id).Delete(&CallbackData{}).Error 19 | } 20 | -------------------------------------------------------------------------------- /dao/db.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "github.com/glebarez/sqlite" 11 | "github.com/krau/SaveAny-Bot/common" 12 | "github.com/krau/SaveAny-Bot/config" 13 | "gorm.io/gorm" 14 | glogger "gorm.io/gorm/logger" 15 | ) 16 | 17 | var db *gorm.DB 18 | 19 | func Init() { 20 | if err := os.MkdirAll(filepath.Dir(config.Cfg.DB.Path), 0755); err != nil { 21 | common.Log.Panic("Failed to create data directory: ", err) 22 | } 23 | var err error 24 | db, err = gorm.Open(sqlite.Open(config.Cfg.DB.Path), &gorm.Config{ 25 | Logger: glogger.New(common.Log, glogger.Config{ 26 | Colorful: true, 27 | SlowThreshold: time.Second * 5, 28 | LogLevel: glogger.Error, 29 | IgnoreRecordNotFoundError: true, 30 | ParameterizedQueries: true, 31 | }), 32 | PrepareStmt: true, 33 | }) 34 | if err != nil { 35 | common.Log.Panic("Failed to open database: ", err) 36 | } 37 | common.Log.Debug("Database connected") 38 | if err := db.AutoMigrate(&ReceivedFile{}, &User{}, &Dir{}, &CallbackData{}, &Rule{}); err != nil { 39 | common.Log.Panic("迁移数据库失败, 如果您从旧版本升级, 建议手动删除数据库文件后重试: ", err) 40 | } 41 | if err := syncUsers(); err != nil { 42 | common.Log.Panic("Failed to sync users:", err) 43 | } 44 | common.Log.Debug("Database migrated") 45 | if config.Cfg.DB.Expire == 0 { 46 | return 47 | } 48 | if err := cleanExpiredData(db); err != nil { 49 | common.Log.Error("Failed to clean expired data: ", err) 50 | } else { 51 | common.Log.Debug("Cleaned expired data") 52 | } 53 | go cleanJob(db) 54 | } 55 | 56 | func syncUsers() error { 57 | dbUsers, err := GetAllUsers() 58 | if err != nil { 59 | return fmt.Errorf("failed to get users: %w", err) 60 | } 61 | 62 | dbUserMap := make(map[int64]User) 63 | for _, u := range dbUsers { 64 | dbUserMap[u.ChatID] = u 65 | } 66 | 67 | cfgUserMap := make(map[int64]struct{}) 68 | for _, u := range config.Cfg.Users { 69 | cfgUserMap[u.ID] = struct{}{} 70 | } 71 | 72 | for cfgID := range cfgUserMap { 73 | if _, exists := dbUserMap[cfgID]; !exists { 74 | if err := CreateUser(cfgID); err != nil { 75 | return fmt.Errorf("failed to create user %d: %w", cfgID, err) 76 | } 77 | common.Log.Infof("创建用户: %d", cfgID) 78 | } 79 | } 80 | 81 | for dbID, dbUser := range dbUserMap { 82 | if _, exists := cfgUserMap[dbID]; !exists { 83 | if err := DeleteUser(&dbUser); err != nil { 84 | return fmt.Errorf("failed to delete user %d: %w", dbID, err) 85 | } 86 | common.Log.Infof("删除用户: %d", dbID) 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func cleanExpiredData(db *gorm.DB) error { 94 | var fileErr error 95 | if err := db.Where("updated_at < ?", time.Now().Add(-time.Duration(config.Cfg.DB.Expire)*time.Second)).Unscoped().Delete(&ReceivedFile{}).Error; err != nil { 96 | fileErr = fmt.Errorf("failed to delete expired files: %w", err) 97 | } 98 | var cbErr error 99 | if err := db.Where("updated_at < ?", time.Now().Add(-time.Duration(config.Cfg.DB.Expire)*time.Second)).Unscoped().Delete(&CallbackData{}).Error; err != nil { 100 | cbErr = fmt.Errorf("failed to delete expired callback data: %w", err) 101 | } 102 | return errors.Join(fileErr, cbErr) 103 | } 104 | 105 | func cleanJob(db *gorm.DB) { 106 | tick := time.NewTicker(time.Duration(config.Cfg.DB.Expire) * time.Second) 107 | defer tick.Stop() 108 | for range tick.C { 109 | if err := cleanExpiredData(db); err != nil { 110 | common.Log.Error("Failed to clean expired data: ", err) 111 | } else { 112 | common.Log.Debug("Cleaned expired data") 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /dao/dir.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | func CreateDirForUser(userID uint, storageName, path string) error { 4 | dir := Dir{ 5 | UserID: userID, 6 | StorageName: storageName, 7 | Path: path, 8 | } 9 | return db.Create(&dir).Error 10 | } 11 | 12 | func GetDirByID(id uint) (*Dir, error) { 13 | dir := &Dir{} 14 | err := db.First(dir, id).Error 15 | if err != nil { 16 | return nil, err 17 | } 18 | return dir, err 19 | } 20 | 21 | func GetUserDirs(userID uint) ([]Dir, error) { 22 | var dirs []Dir 23 | err := db.Where("user_id = ?", userID).Find(&dirs).Error 24 | return dirs, err 25 | } 26 | 27 | func GetUserDirsByChatID(chatID int64) ([]Dir, error) { 28 | user, err := GetUserByChatID(chatID) 29 | if err != nil { 30 | return nil, err 31 | } 32 | return GetUserDirs(user.ID) 33 | } 34 | 35 | func GetDirsByUserIDAndStorageName(userID uint, storageName string) ([]Dir, error) { 36 | var dirs []Dir 37 | err := db.Where("user_id = ? AND storage_name = ?", userID, storageName).Find(&dirs).Error 38 | return dirs, err 39 | } 40 | 41 | func DeleteDirForUser(userID uint, storageName, path string) error { 42 | return db.Unscoped().Where("user_id = ? AND storage_name = ? AND path = ?", userID, storageName, path).Delete(&Dir{}).Error 43 | } 44 | 45 | func DeleteDirByID(id uint) error { 46 | return db.Unscoped().Delete(&Dir{}, id).Error 47 | } -------------------------------------------------------------------------------- /dao/file.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | func SaveReceivedFile(receivedFile *ReceivedFile) (*ReceivedFile, error) { 4 | record, err := GetReceivedFileByChatAndMessageID(receivedFile.ChatID, receivedFile.MessageID) 5 | if err == nil { 6 | receivedFile.ID = record.ID 7 | } 8 | db.Save(receivedFile) 9 | return receivedFile, db.Error 10 | } 11 | 12 | func GetReceivedFileByChatAndMessageID(chatID int64, messageID int) (*ReceivedFile, error) { 13 | var receivedFile ReceivedFile 14 | err := db.Where("chat_id = ? AND message_id = ?", chatID, messageID).First(&receivedFile).Error 15 | if err != nil { 16 | return nil, err 17 | } 18 | return &receivedFile, nil 19 | } 20 | 21 | func DeleteReceivedFile(receivedFile *ReceivedFile) error { 22 | return db.Unscoped().Delete(receivedFile).Error 23 | } 24 | -------------------------------------------------------------------------------- /dao/model.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | ) 6 | 7 | type ReceivedFile struct { 8 | gorm.Model 9 | Processing bool 10 | // Which chat the file is from 11 | ChatID int64 `gorm:"uniqueIndex:idx_chat_id_message_id;not null"` 12 | // Which message the file is from 13 | MessageID int `gorm:"uniqueIndex:idx_chat_id_message_id;not null"` 14 | ReplyMessageID int 15 | ReplyChatID int64 16 | FileName string 17 | IsTelegraph bool 18 | TelegraphURL string 19 | } 20 | 21 | type User struct { 22 | gorm.Model 23 | ChatID int64 `gorm:"uniqueIndex;not null"` 24 | Silent bool 25 | DefaultStorage string // Default storage name 26 | Dirs []Dir 27 | ApplyRule bool 28 | Rules []Rule 29 | } 30 | 31 | type Dir struct { 32 | gorm.Model 33 | UserID uint 34 | StorageName string 35 | Path string 36 | } 37 | 38 | type CallbackData struct { 39 | gorm.Model 40 | Data string 41 | } 42 | 43 | type Rule struct { 44 | gorm.Model 45 | UserID uint 46 | Type string 47 | Data string 48 | StorageName string 49 | DirPath string 50 | } 51 | -------------------------------------------------------------------------------- /dao/rule.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | func CreateRule(rule *Rule) error { 4 | return db.Create(rule).Error 5 | } 6 | 7 | func DeleteRule(ruleID uint) error { 8 | return db.Unscoped().Delete(&Rule{}, ruleID).Error 9 | } 10 | 11 | func UpdateUserApplyRule(chatID int64, applyRule bool) error { 12 | return db.Model(&User{}).Where("chat_id = ?", chatID).Update("apply_rule", applyRule).Error 13 | } 14 | 15 | func GetRulesByUserChatID(chatID int64) ([]Rule, error) { 16 | var rules []Rule 17 | err := db.Where("user_id = (SELECT id FROM users WHERE chat_id = ?)", chatID).Find(&rules).Error 18 | if err != nil { 19 | return nil, err 20 | } 21 | return rules, nil 22 | } 23 | -------------------------------------------------------------------------------- /dao/user.go: -------------------------------------------------------------------------------- 1 | package dao 2 | 3 | func CreateUser(chatID int64) error { 4 | if _, err := GetUserByChatID(chatID); err == nil { 5 | return nil 6 | } 7 | return db.Create(&User{ChatID: chatID}).Error 8 | } 9 | 10 | func GetAllUsers() ([]User, error) { 11 | var users []User 12 | err := db.Preload("Dirs"). 13 | Preload("Rules"). 14 | Find(&users).Error 15 | return users, err 16 | } 17 | 18 | func GetUserByChatID(chatID int64) (*User, error) { 19 | var user User 20 | err := db. 21 | Preload("Dirs"). 22 | Preload("Rules"). 23 | Where("chat_id = ?", chatID).First(&user).Error 24 | return &user, err 25 | } 26 | 27 | func UpdateUser(user *User) error { 28 | return db.Save(user).Error 29 | } 30 | 31 | func DeleteUser(user *User) error { 32 | return db.Unscoped().Select("Dirs", "Rules").Delete(user).Error 33 | } 34 | -------------------------------------------------------------------------------- /docker-compose.local.yml: -------------------------------------------------------------------------------- 1 | services: 2 | saveany-bot: 3 | build: . 4 | container_name: saveany-bot 5 | restart: unless-stopped 6 | volumes: 7 | - ./data:/app/data 8 | - ./config.toml:/app/config.toml 9 | - ./downloads:/app/downloads 10 | - ./cache:/app/cache 11 | # 使用 host 模式以便访问宿主机服务 (如代理) 12 | # 如果你对 Docker 网络模式熟悉, 可以自行修改 13 | network_mode: host -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | saveany-bot: 3 | image: ghcr.io/krau/saveany-bot:latest 4 | container_name: saveany-bot 5 | restart: unless-stopped 6 | volumes: 7 | - ./data:/app/data 8 | - ./config.toml:/app/config.toml 9 | - ./downloads:/app/downloads 10 | - ./cache:/app/cache 11 | # 使用 host 模式以便访问宿主机服务 (如代理) 12 | # 如果你对 Docker 网络模式熟悉, 可以自行修改 13 | network_mode: host -------------------------------------------------------------------------------- /docs/docs/contribute.md: -------------------------------------------------------------------------------- 1 | # 参与开发 2 | 3 | ## 贡献新存储端 4 | 5 | 1. Fork 本项目, 克隆到本地 6 | 2. 在 `config/storage` 目录下定义存储端配置, 并添加到 `config/storage/factory.go` 中 7 | 3. 在 `types/types.go` 中添加新的存储端类型 8 | 4. 在 `storage` 目录下新建一个包, 编写存储端实现, 然后在 `storage/storage.go` 中导入并添加它 9 | 5. 更新 `config.example.toml` 文件, 添加新的示例配置 10 | 11 | *可能确实有点麻烦了 = =* -------------------------------------------------------------------------------- /docs/docs/deploy.md: -------------------------------------------------------------------------------- 1 | # 部署指南 2 | 3 | ## 从二进制文件部署 4 | 5 | 在 [Release](https://github.com/krau/SaveAny-Bot/releases) 页面下载对应平台的二进制文件. 6 | 7 | 在解压后目录新建 `config.toml` 文件, 参考 [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件. 8 | 9 | 运行: 10 | 11 | ```bash 12 | chmod +x saveany-bot 13 | ./saveany-bot 14 | ``` 15 | 16 | ### 添加为 systemd 服务 17 | 18 | 创建文件 `/etc/systemd/system/saveany-bot.service` 并写入以下内容: 19 | 20 | ``` 21 | [Unit] 22 | Description=SaveAnyBot 23 | After=systemd-user-sessions.service 24 | 25 | [Service] 26 | Type=simple 27 | WorkingDirectory=/yourpath/ 28 | ExecStart=/yourpath/saveany-bot 29 | Restart=on-failure 30 | 31 | [Install] 32 | WantedBy=multi-user.target 33 | ``` 34 | 35 | 设为开机启动并启动服务: 36 | 37 | ```bash 38 | systemctl enable --now saveany-bot 39 | ``` 40 | 41 | ### 为OpenWrt及衍生系统添加开机自启动服务 42 | 43 | 创建文件 ` /etc/init.d/saveanybot` ,参考[saveanybot](https://github.com/krau/SaveAny-Bot/blob/main/docs/saveanybot)自行修改. 44 | 45 | `chmod +x /etc/init.d/saveanybot` 46 | 47 | 完成后,将文件复制到 `/etc/rc.d`并重命名为`S99saveanybot`. 48 | 49 | `chmod +x /etc/rc.d/S99saveanybot` 50 | 51 | ### 为OpenWrt及衍生系统添加快捷指令 52 | 53 | 创建文件` /usr/bin/sabot` ,参考[sabot](https://github.com/krau/SaveAny-Bot/blob/main/docs/sabot)自行配置修改,注意此处文件编码仅支持 ANSI 936 . 54 | 55 | `chmod +x /usr/bin/sabot` 56 | 57 | 之后,终端输入`sabot start|stop|restart|status|enable|disable`即可. 58 | 59 | 60 | ## 使用 Docker 部署 61 | 62 | ### Docker Compose 63 | 64 | 下载 [docker-compose.yml](https://github.com/krau/SaveAny-Bot/blob/main/docker-compose.yml) 文件, 在同目录下新建 `config.toml` 文件, 参考 [config.example.toml](https://github.com/krau/SaveAny-Bot/blob/main/config.example.toml) 编辑配置文件. 65 | 66 | 启动: 67 | 68 | ```bash 69 | docker compose up -d 70 | ``` 71 | 72 | ### Docker 73 | 74 | ```shell 75 | docker run -d --name saveany-bot \ 76 | -v /path/to/config.toml:/app/config.toml \ 77 | -v /path/to/downloads:/app/downloads \ 78 | ghcr.io/krau/saveany-bot:latest 79 | ``` 80 | 81 | ## 更新 82 | 83 | 使用 `upgrade` 或 `up` 升级到最新版 84 | 85 | ```bash 86 | ./saveany-bot upgrade 87 | ``` 88 | 89 | 如果是 Docker 部署, 使用以下命令更新: 90 | 91 | ```bash 92 | docker pull ghcr.io/krau/saveany-bot:latest 93 | docker restart saveany-bot 94 | ``` -------------------------------------------------------------------------------- /docs/docs/experimental.md: -------------------------------------------------------------------------------- 1 | # 实验性功能 2 | 3 | 这里的功能不太稳定, 且未来可能会被删除或修改。 4 | 5 | ## 存储规则 6 | 7 | 允许你为 Bot 在上传文件到存储时设置一些重定向规则, 用于自动整理所保存的文件. 8 | 9 | 见: https://github.com/krau/SaveAny-Bot/issues/28 10 | 11 | 目前支持的规则类型: 12 | 13 | 1. FILENAME-REGEX 14 | 2. MESSAGE-REGEX 15 | 16 | 添加规则的基本语法: 17 | 18 | "规则类型 规则内容 存储名 路径" 19 | 20 | 注意空格的使用, 语法正确 bot 才能解析, 以下是一条合法的添加规则命令: 21 | 22 | ``` 23 | /rule add FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频 24 | ``` 25 | 26 | 此外, 规则中的存储名若使用 "CHOSEN" , 则表示存储到点击按钮选择的存储端的路径下 27 | 28 | 规则介绍: 29 | 30 | ### FILENAME-REGEX 31 | 32 | 根据文件名正则匹配, 规则内容要求为一个合法的正则表达式, 如 33 | 34 | ``` 35 | FILENAME-REGEX (?i)\.(mp4|mkv|ts|avi|flv)$ MyAlist /视频 36 | ``` 37 | 38 | 表示将文件名后缀为 mp4,mkv,ts,avi,flv 的文件放到名为 MyAlist 存储下的 /视频 目录内 (同时受配置文件中的 `base_path` 影响) 39 | 40 | ### MESSAGE-REGEX 41 | 42 | 同上, 根据消息文本内容正则匹配 43 | 44 | ## 复制并发送媒体消息 45 | 46 | 将接收到的文件(媒体)消息, 或链接对应的消息原样发送到当前聊天, 点击选择存储按钮中的 "发送到当前聊天" 即可. 47 | -------------------------------------------------------------------------------- /docs/docs/faq.md: -------------------------------------------------------------------------------- 1 | # 常见问题 2 | 3 | ## 上传 alist 失败也会显示成功 4 | 5 | 在 alist 管理页面适当调整上传分片大小, 为 alist 使用更稳定的网络环境部署, 都可以减少这种情况的发生. 6 | 7 | ## Bot 提示下载成功但是 alist 未显示 8 | 9 | alist 缓存了目录结构, 参考文档可以调整缓存时间 10 | 11 | https://alist.nn.ci/zh/guide/drivers/common.html#缓存过期 12 | 13 | ## docker部署配置了代理后仍无法连接 telegram (初始化客户端超时) 14 | 15 | docker 不能直接访问宿主机网络, 如果你不熟悉其用法, 请将容器设为 host 模式: 16 | 17 | -------------------------------------------------------------------------------- /docs/docs/help.md: -------------------------------------------------------------------------------- 1 | # 使用帮助 2 | 3 | ## 保存文件 4 | 5 | Bot 接受两种消息: 文件和链接. 6 | 7 | 支持以下链接: 8 | 9 | 1. 公开频道 (具有用户名) 的消息链接, 例如: `https://t.me/acherkrau/1097`. **即使频道禁止了转发和保存, Bot 依然可以下载其文件.** 10 | 2. Telegra.ph 的文章链接, Bot 将下载其中的所有图片 11 | 12 | ## 静默模式 (silent) 13 | 14 | 使用 `/silent` 命令可以开关静默模式. 15 | 16 | 默认情况下不开启静默模式, Bot 会询问你每个文件的保存位置. 17 | 18 | 开启静默模式后, Bot 会直接保存文件到默认位置, 无需确认. 19 | 20 | 在开启静默模式之前, 需要使用 `/storage` 命令设置默认保存位置. 21 | 22 | ## Stream 模式 23 | 24 | 在配置文件中将 `stream` 设置为 `true` 可以开启 Stream 模式. 25 | 26 | 未开启时, Bot 处理任务分为两步: 下载和上传. Bot 会将文件暂存到本地, 然后上传到对应存储位置, 最后删除本地文件. 27 | 28 | 开启后, Bot 将直接将文件流式传输到存储端, 不需要下载到本地. 29 | 30 | 该功能对于硬盘空间有限的部署环境十分有用, 然而相较于普通模式也具有一些弊端: 31 | 32 | - 无法使用多线程从 telegram 下载文件, 速度较慢. 33 | - 网络不稳定时, 任务失败率高. 34 | - 无法在中间层对文件进行处理, 例如自动文件类型识别. 35 | 36 | **不支持** Stream 模式的存储端: 37 | 38 | - alist -------------------------------------------------------------------------------- /docs/docs/index.md: -------------------------------------------------------------------------------- 1 | # SaveAnyBot 文档 2 | 3 | SaveAnyBot 是一个可以保存 Telegram 上的文件到云存储的机器人, 就像 PikPak Bot 一样. 4 | 5 | 不同的是, SaveAnyBot 提供更灵活的存储端选择, 并实现一些更强大的功能. 6 | 7 | 本项目以 AGPL-3.0 协议开源, 请遵守协议使用. -------------------------------------------------------------------------------- /docs/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krau/SaveAny-Bot/a9c56892c3c26b0660f925e7c75fddcacf07c92e/docs/logo.jpg -------------------------------------------------------------------------------- /docs/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SaveAnyBot 官方文档 2 | site_author: Krau 3 | site_description: SaveAnyBot 是一个可以保存 Telegram 上的文件到多种云存储的机器人, 本文档将帮助你了解如何部署和使用它. 4 | repo_name: krau/saveany-bot 5 | repo_url: https://github.com/krau/saveany-bot 6 | copyright: CC BY-NC-SA 4.0 7 | theme: 8 | name: material 9 | language: zh 10 | highlightjs: true 11 | palette: 12 | - media: "(prefers-color-scheme)" 13 | toggle: 14 | icon: material/brightness-auto 15 | name: 切换主题 16 | - media: "(prefers-color-scheme: light)" 17 | scheme: default 18 | primary: indigo 19 | toggle: 20 | icon: material/brightness-7 21 | name: 暗色模式 22 | - media: "(prefers-color-scheme: dark)" 23 | scheme: slate 24 | primary: blue grey 25 | toggle: 26 | icon: material/brightness-4 27 | name: 亮色模式 28 | 29 | nav: 30 | - index.md 31 | - deploy.md 32 | - help.md 33 | - experimental.md 34 | - faq.md 35 | - contribute.md 36 | -------------------------------------------------------------------------------- /docs/sabot: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | case "$1" in 4 | start) 5 | /etc/init.d/saveanybot start 6 | ;; 7 | stop) 8 | /etc/init.d/saveanybot stop 9 | ;; 10 | restart) 11 | /etc/init.d/saveanybot restart 12 | ;; 13 | status) 14 | /etc/init.d/saveanybot status 15 | ;; 16 | enable) 17 | /etc/init.d/saveanybot enable 18 | echo "Enable SaveAnyBot auto-start." 19 | ;; 20 | disable) 21 | /etc/init.d/saveanybot disable 22 | echo "Disable SaveAnyBot auto-start." 23 | ;; 24 | *) 25 | echo "Usage: $0 {start|stop|restart|status|enable|disable}" 26 | exit 1 27 | ;; 28 | esac 29 | -------------------------------------------------------------------------------- /docs/saveanybot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/krau/SaveAny-Bot/a9c56892c3c26b0660f925e7c75fddcacf07c92e/docs/saveanybot -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/krau/SaveAny-Bot 2 | 3 | go 1.23.5 4 | 5 | require ( 6 | github.com/blang/semver v3.5.1+incompatible 7 | github.com/celestix/gotgproto v1.0.0-beta21 8 | github.com/celestix/telegraph-go/v2 v2.0.4 9 | github.com/eko/gocache/lib/v4 v4.2.0 10 | github.com/eko/gocache/store/go_cache/v4 v4.2.2 11 | github.com/gabriel-vasile/mimetype v1.4.9 12 | github.com/gookit/slog v0.5.8 13 | github.com/gotd/contrib v0.21.0 14 | github.com/gotd/td v0.123.0 15 | github.com/minio/minio-go/v7 v7.0.91 16 | github.com/rhysd/go-github-selfupdate v1.2.3 17 | github.com/spf13/cobra v1.9.1 18 | github.com/spf13/viper v1.20.1 19 | golang.org/x/net v0.40.0 20 | golang.org/x/time v0.11.0 21 | ) 22 | 23 | require ( 24 | github.com/AnimeKaizoku/cacher v1.0.2 // indirect 25 | github.com/beorn7/perks v1.0.1 // indirect 26 | github.com/cenkalti/backoff/v4 v4.3.0 // indirect 27 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 28 | github.com/coder/websocket v1.8.13 // indirect 29 | github.com/dlclark/regexp2 v1.11.5 // indirect 30 | github.com/dustin/go-humanize v1.0.1 // indirect 31 | github.com/fatih/color v1.18.0 // indirect 32 | github.com/ghodss/yaml v1.0.0 // indirect 33 | github.com/glebarez/go-sqlite v1.22.0 // indirect 34 | github.com/go-faster/errors v0.7.1 // indirect 35 | github.com/go-faster/jx v1.1.0 // indirect 36 | github.com/go-faster/xor v1.0.0 // indirect 37 | github.com/go-faster/yaml v0.4.6 // indirect 38 | github.com/go-ini/ini v1.67.0 // indirect 39 | github.com/go-viper/mapstructure/v2 v2.2.1 // indirect 40 | github.com/goccy/go-json v0.10.5 // indirect 41 | github.com/golang/mock v1.6.0 // indirect 42 | github.com/google/go-github/v30 v30.1.0 // indirect 43 | github.com/google/go-querystring v1.1.0 // indirect 44 | github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect 45 | github.com/google/uuid v1.6.0 // indirect 46 | github.com/gotd/ige v0.2.2 // indirect 47 | github.com/gotd/neo v0.1.5 // indirect 48 | github.com/inconshreveable/go-update v0.0.0-20160112193335-8152e7eb6ccf // indirect 49 | github.com/jinzhu/inflection v1.0.0 // indirect 50 | github.com/jinzhu/now v1.1.5 // indirect 51 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 52 | github.com/mattn/go-colorable v0.1.14 // indirect 53 | github.com/mattn/go-isatty v0.0.20 // indirect 54 | github.com/minio/crc64nvme v1.0.2 // indirect 55 | github.com/minio/md5-simd v1.1.2 // indirect 56 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 57 | github.com/ncruces/go-strftime v0.1.9 // indirect 58 | github.com/ogen-go/ogen v1.12.0 // indirect 59 | github.com/onsi/gomega v1.36.2 // indirect 60 | github.com/pkg/errors v0.9.1 // indirect 61 | github.com/prometheus/client_golang v1.22.0 // indirect 62 | github.com/prometheus/client_model v0.6.2 // indirect 63 | github.com/prometheus/common v0.63.0 // indirect 64 | github.com/prometheus/procfs v0.16.1 // indirect 65 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 66 | github.com/rs/xid v1.6.0 // indirect 67 | github.com/segmentio/asm v1.2.0 // indirect 68 | github.com/tcnksm/go-gitconfig v0.1.2 // indirect 69 | github.com/ulikunitz/xz v0.5.12 // indirect 70 | go.opentelemetry.io/otel v1.35.0 // indirect 71 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 72 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 73 | go.uber.org/atomic v1.11.0 // indirect 74 | go.uber.org/mock v0.5.2 // indirect 75 | go.uber.org/zap v1.27.0 // indirect 76 | golang.org/x/crypto v0.38.0 // indirect 77 | golang.org/x/mod v0.24.0 // indirect 78 | golang.org/x/oauth2 v0.30.0 // indirect 79 | golang.org/x/term v0.32.0 // indirect 80 | golang.org/x/tools v0.33.0 // indirect 81 | google.golang.org/protobuf v1.36.6 // indirect 82 | gopkg.in/yaml.v2 v2.4.0 // indirect 83 | modernc.org/libc v1.65.0 // indirect 84 | modernc.org/mathutil v1.7.1 // indirect 85 | modernc.org/memory v1.10.0 // indirect 86 | modernc.org/sqlite v1.37.0 // indirect 87 | rsc.io/qr v0.2.0 // indirect 88 | ) 89 | 90 | require ( 91 | github.com/duke-git/lancet/v2 v2.3.5 92 | github.com/fsnotify/fsnotify v1.9.0 // indirect 93 | github.com/glebarez/sqlite v1.11.0 94 | github.com/gookit/color v1.5.4 // indirect 95 | github.com/gookit/goutil v0.6.18 // indirect 96 | github.com/gookit/gsr v0.1.1 // indirect 97 | github.com/hashicorp/hcl v1.0.0 // indirect 98 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 99 | github.com/klauspost/compress v1.18.0 // indirect 100 | github.com/magiconair/properties v1.8.10 // indirect 101 | github.com/mitchellh/mapstructure v1.5.0 102 | github.com/patrickmn/go-cache v2.1.0+incompatible 103 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect 104 | github.com/sagikazarmark/locafero v0.9.0 // indirect 105 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 106 | github.com/sourcegraph/conc v0.3.0 // indirect 107 | github.com/spf13/afero v1.14.0 // indirect 108 | github.com/spf13/cast v1.8.0 // indirect 109 | github.com/spf13/pflag v1.0.6 // indirect 110 | github.com/subosito/gotenv v1.6.0 // indirect 111 | github.com/valyala/bytebufferpool v1.0.0 // indirect 112 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 113 | go.uber.org/multierr v1.11.0 // indirect 114 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 115 | golang.org/x/sync v0.14.0 116 | golang.org/x/sys v0.33.0 // indirect 117 | golang.org/x/text v0.25.0 // indirect 118 | gopkg.in/ini.v1 v1.67.0 // indirect 119 | gopkg.in/yaml.v3 v3.0.1 // indirect 120 | gorm.io/gorm v1.26.0 121 | ) 122 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/krau/SaveAny-Bot/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /queue/queue.go: -------------------------------------------------------------------------------- 1 | package queue 2 | 3 | import ( 4 | "container/list" 5 | "sync" 6 | 7 | "github.com/krau/SaveAny-Bot/types" 8 | ) 9 | 10 | type TaskQueue struct { 11 | list *list.List 12 | cond *sync.Cond 13 | mutex *sync.Mutex 14 | activeMap map[string]*types.Task 15 | } 16 | 17 | func (q *TaskQueue) AddTask(task *types.Task) { 18 | q.mutex.Lock() 19 | defer q.mutex.Unlock() 20 | q.list.PushBack(task) 21 | q.cond.Signal() 22 | if task.Status != types.Pending { 23 | delete(q.activeMap, task.Key()) 24 | } 25 | } 26 | 27 | func (q *TaskQueue) GetTask() *types.Task { 28 | q.mutex.Lock() 29 | defer q.mutex.Unlock() 30 | for q.list.Len() == 0 { 31 | q.cond.Wait() 32 | } 33 | e := q.list.Front() 34 | task := e.Value.(*types.Task) 35 | q.list.Remove(e) 36 | if task.Status == types.Pending { 37 | q.activeMap[task.Key()] = task 38 | } 39 | return task 40 | } 41 | 42 | func (q *TaskQueue) DoneTask(task *types.Task) { 43 | q.mutex.Lock() 44 | defer q.mutex.Unlock() 45 | delete(q.activeMap, task.Key()) 46 | } 47 | 48 | func (q *TaskQueue) CancelTask(key string) bool { 49 | q.mutex.Lock() 50 | defer q.mutex.Unlock() 51 | if task, ok := q.activeMap[key]; ok { 52 | if task.Cancel != nil { 53 | task.Cancel() 54 | return true 55 | } 56 | } 57 | for e := q.list.Front(); e != nil; e = e.Next() { 58 | task := e.Value.(*types.Task) 59 | if task.Key() == key { 60 | if task.Cancel != nil { 61 | task.Cancel() 62 | } 63 | q.list.Remove(e) 64 | return true 65 | } 66 | } 67 | return false 68 | } 69 | 70 | func (q *TaskQueue) Len() int { 71 | q.mutex.Lock() 72 | defer q.mutex.Unlock() 73 | return q.list.Len() 74 | } 75 | 76 | var Queue *TaskQueue 77 | 78 | func init() { 79 | Queue = NewQueue() 80 | } 81 | 82 | func NewQueue() *TaskQueue { 83 | m := &sync.Mutex{} 84 | return &TaskQueue{ 85 | list: list.New(), 86 | cond: sync.NewCond(m), 87 | mutex: m, 88 | activeMap: make(map[string]*types.Task), 89 | } 90 | } 91 | 92 | func AddTask(task *types.Task) { 93 | Queue.AddTask(task) 94 | } 95 | 96 | func GetTask() *types.Task { 97 | return Queue.GetTask() 98 | } 99 | 100 | func Len() int { 101 | return Queue.Len() 102 | } 103 | 104 | func CancelTask(key string) bool { 105 | return Queue.CancelTask(key) 106 | } 107 | 108 | func DoneTask(task *types.Task) { 109 | Queue.DoneTask(task) 110 | } 111 | -------------------------------------------------------------------------------- /storage/alist/alist.go: -------------------------------------------------------------------------------- 1 | package alist 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "path" 11 | "time" 12 | 13 | "github.com/krau/SaveAny-Bot/common" 14 | config "github.com/krau/SaveAny-Bot/config/storage" 15 | "github.com/krau/SaveAny-Bot/types" 16 | ) 17 | 18 | type Alist struct { 19 | client *http.Client 20 | token string 21 | baseURL string 22 | loginInfo *loginRequest 23 | config config.AlistStorageConfig 24 | } 25 | 26 | func (a *Alist) Init(cfg config.StorageConfig) error { 27 | alistConfig, ok := cfg.(*config.AlistStorageConfig) 28 | if !ok { 29 | return fmt.Errorf("failed to cast alist config") 30 | } 31 | if err := alistConfig.Validate(); err != nil { 32 | return err 33 | } 34 | a.config = *alistConfig 35 | 36 | a.baseURL = alistConfig.URL 37 | a.client = getHttpClient() 38 | if alistConfig.Token != "" { 39 | a.token = alistConfig.Token 40 | ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) 41 | defer cancel() 42 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, a.baseURL+"/api/me", nil) 43 | if err != nil { 44 | common.Log.Fatalf("Failed to create request: %v", err) 45 | return err 46 | } 47 | req.Header.Set("Authorization", a.token) 48 | 49 | resp, err := a.client.Do(req) 50 | if err != nil { 51 | common.Log.Fatalf("Failed to send request: %v", err) 52 | return err 53 | } 54 | defer resp.Body.Close() 55 | if resp.StatusCode != http.StatusOK { 56 | common.Log.Fatalf("Failed to get alist user info: %s", resp.Status) 57 | return err 58 | } 59 | body, err := io.ReadAll(resp.Body) 60 | if err != nil { 61 | common.Log.Fatalf("Failed to read response body: %v", err) 62 | return err 63 | } 64 | var meResp meResponse 65 | if err := json.Unmarshal(body, &meResp); err != nil { 66 | common.Log.Fatalf("Failed to unmarshal me response: %v", err) 67 | return err 68 | } 69 | if meResp.Code != http.StatusOK { 70 | common.Log.Fatalf("Failed to get alist user info: %s", meResp.Message) 71 | return err 72 | } 73 | common.Log.Debugf("Logged in Alist as %s", meResp.Data.Username) 74 | return nil 75 | } 76 | a.loginInfo = &loginRequest{ 77 | Username: alistConfig.Username, 78 | Password: alistConfig.Password, 79 | } 80 | 81 | if err := a.getToken(); err != nil { 82 | common.Log.Fatalf("Failed to login to Alist: %v", err) 83 | return err 84 | } 85 | common.Log.Debug("Logged in to Alist") 86 | 87 | go a.refreshToken(*alistConfig) 88 | return nil 89 | } 90 | 91 | func (a *Alist) Type() types.StorageType { 92 | return types.StorageTypeAlist 93 | } 94 | 95 | func (a *Alist) Name() string { 96 | return a.config.Name 97 | } 98 | 99 | func (a *Alist) Save(ctx context.Context, reader io.Reader, storagePath string) error { 100 | common.Log.Infof("Saving file to %s", storagePath) 101 | 102 | req, err := http.NewRequestWithContext(ctx, http.MethodPut, a.baseURL+"/api/fs/put", reader) 103 | if err != nil { 104 | return fmt.Errorf("failed to create request: %w", err) 105 | } 106 | req.Header.Set("Authorization", a.token) 107 | req.Header.Set("File-Path", url.PathEscape(storagePath)) 108 | req.Header.Set("Content-Type", "application/octet-stream") 109 | if length := ctx.Value(types.ContextKeyContentLength); length != nil { 110 | length, ok := length.(int64) 111 | if ok { 112 | req.ContentLength = length 113 | } 114 | } 115 | 116 | resp, err := a.client.Do(req) 117 | if err != nil { 118 | return fmt.Errorf("failed to send request: %w", err) 119 | } 120 | defer resp.Body.Close() 121 | 122 | if resp.StatusCode != http.StatusOK { 123 | return fmt.Errorf("failed to save file to Alist: %s", resp.Status) 124 | } 125 | 126 | body, err := io.ReadAll(resp.Body) 127 | if err != nil { 128 | return fmt.Errorf("failed to read response body: %w", err) 129 | } 130 | 131 | var putResp putResponse 132 | if err := json.Unmarshal(body, &putResp); err != nil { 133 | return fmt.Errorf("failed to unmarshal put response: %w", err) 134 | } 135 | 136 | if putResp.Code != http.StatusOK { 137 | return fmt.Errorf("failed to save file to Alist: %d, %s", putResp.Code, putResp.Message) 138 | } 139 | 140 | return nil 141 | } 142 | 143 | func (a *Alist) NotSupportStream() string { 144 | return "Alist does not support chunked transfer encoding" 145 | } 146 | 147 | func (a *Alist) JoinStoragePath(task types.Task) string { 148 | return path.Join(a.config.BasePath, task.StoragePath) 149 | } 150 | -------------------------------------------------------------------------------- /storage/alist/token.go: -------------------------------------------------------------------------------- 1 | package alist 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "time" 10 | 11 | "github.com/krau/SaveAny-Bot/common" 12 | config "github.com/krau/SaveAny-Bot/config/storage" 13 | ) 14 | 15 | func (a *Alist) getToken() error { 16 | loginBody, err := json.Marshal(a.loginInfo) 17 | if err != nil { 18 | return fmt.Errorf("failed to marshal login request: %w", err) 19 | } 20 | 21 | req, err := http.NewRequest(http.MethodPost, a.baseURL+"/api/auth/login", bytes.NewBuffer(loginBody)) 22 | if err != nil { 23 | return fmt.Errorf("failed to create login request: %w", err) 24 | } 25 | req.Header.Set("Content-Type", "application/json") 26 | 27 | resp, err := a.client.Do(req) 28 | if err != nil { 29 | return fmt.Errorf("failed to send login request: %w", err) 30 | } 31 | defer resp.Body.Close() 32 | 33 | body, err := io.ReadAll(resp.Body) 34 | if err != nil { 35 | return fmt.Errorf("failed to read login response: %w", err) 36 | } 37 | 38 | var loginResp loginResponse 39 | if err := json.Unmarshal(body, &loginResp); err != nil { 40 | return fmt.Errorf("failed to unmarshal login response: %w", err) 41 | } 42 | 43 | if loginResp.Code != http.StatusOK { 44 | return fmt.Errorf("%w: %s", ErrAlistLoginFailed, loginResp.Message) 45 | } 46 | 47 | a.token = loginResp.Data.Token 48 | return nil 49 | } 50 | 51 | func (a *Alist) refreshToken(cfg config.AlistStorageConfig) { 52 | tokenExp := cfg.TokenExp 53 | if tokenExp <= 0 { 54 | common.Log.Warn("Invalid token expiration time, using default value") 55 | tokenExp = 3600 56 | } 57 | for { 58 | time.Sleep(time.Duration(tokenExp) * time.Second) 59 | if err := a.getToken(); err != nil { 60 | common.Log.Errorf("Failed to refresh jwt token: %v", err) 61 | continue 62 | } 63 | common.Log.Info("Refreshed Alist jwt token") 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /storage/alist/types.go: -------------------------------------------------------------------------------- 1 | package alist 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrAlistLoginFailed = errors.New("failed to login to Alist") 7 | ) 8 | 9 | type loginRequest struct { 10 | Username string `json:"username"` 11 | Password string `json:"password"` 12 | } 13 | 14 | type loginResponse struct { 15 | Code int `json:"code"` 16 | Message string `json:"message"` 17 | Data struct { 18 | Token string `json:"token"` 19 | } `json:"data"` 20 | } 21 | 22 | type meResponse struct { 23 | Code int `json:"code"` 24 | Message string `json:"message"` 25 | Data struct { 26 | ID int `json:"id"` 27 | Username string `json:"username"` 28 | } `json:"data"` 29 | } 30 | 31 | type putResponse struct { 32 | Code int `json:"code"` 33 | Message string `json:"message"` 34 | Data struct { 35 | Task struct { 36 | ID string `json:"id"` 37 | Name string `json:"name"` 38 | State int `json:"state"` 39 | Status string `json:"status"` 40 | Progress int `json:"progress"` 41 | Error string `json:"error"` 42 | } `json:"task"` 43 | } `json:"data"` 44 | } 45 | -------------------------------------------------------------------------------- /storage/alist/utils.go: -------------------------------------------------------------------------------- 1 | package alist 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | var ( 9 | httpClient *http.Client 10 | ) 11 | 12 | func getHttpClient() *http.Client { 13 | if httpClient != nil { 14 | return httpClient 15 | } 16 | httpClient = &http.Client{ 17 | Timeout: 12 * time.Hour, 18 | Transport: &http.Transport{ 19 | TLSHandshakeTimeout: 10 * time.Second, 20 | }, 21 | } 22 | return httpClient 23 | } 24 | -------------------------------------------------------------------------------- /storage/errs.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "errors" 5 | ) 6 | 7 | var ( 8 | ErrStorageNameEmpty = errors.New("storage name is empty") 9 | ) 10 | -------------------------------------------------------------------------------- /storage/local/local.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/duke-git/lancet/v2/fileutil" 11 | "github.com/krau/SaveAny-Bot/common" 12 | config "github.com/krau/SaveAny-Bot/config/storage" 13 | "github.com/krau/SaveAny-Bot/types" 14 | ) 15 | 16 | type Local struct { 17 | config config.LocalStorageConfig 18 | } 19 | 20 | func (l *Local) Init(cfg config.StorageConfig) error { 21 | localConfig, ok := cfg.(*config.LocalStorageConfig) 22 | if !ok { 23 | return fmt.Errorf("failed to cast local config") 24 | } 25 | if err := localConfig.Validate(); err != nil { 26 | return err 27 | } 28 | l.config = *localConfig 29 | err := os.MkdirAll(localConfig.BasePath, os.ModePerm) 30 | if err != nil { 31 | return fmt.Errorf("failed to create local storage directory: %w", err) 32 | } 33 | return nil 34 | } 35 | 36 | func (l *Local) Type() types.StorageType { 37 | return types.StorageTypeLocal 38 | } 39 | 40 | func (l *Local) Name() string { 41 | return l.config.Name 42 | } 43 | 44 | func (l *Local) JoinStoragePath(task types.Task) string { 45 | return filepath.Join(l.config.BasePath, task.StoragePath) 46 | } 47 | 48 | func (l *Local) Save(ctx context.Context, r io.Reader, storagePath string) error { 49 | common.Log.Infof("Saving file to %s", storagePath) 50 | 51 | absPath, err := filepath.Abs(storagePath) 52 | if err != nil { 53 | return err 54 | } 55 | if err := fileutil.CreateDir(filepath.Dir(absPath)); err != nil { 56 | return err 57 | } 58 | file, err := os.Create(absPath) 59 | if err != nil { 60 | return err 61 | } 62 | defer file.Close() 63 | _, err = io.Copy(file, r) 64 | return err 65 | } 66 | -------------------------------------------------------------------------------- /storage/minio/client.go: -------------------------------------------------------------------------------- 1 | package minio 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "path" 8 | 9 | "github.com/krau/SaveAny-Bot/common" 10 | config "github.com/krau/SaveAny-Bot/config/storage" 11 | "github.com/krau/SaveAny-Bot/types" 12 | "github.com/minio/minio-go/v7" 13 | "github.com/minio/minio-go/v7/pkg/credentials" 14 | ) 15 | 16 | type Minio struct { 17 | config config.MinioStorageConfig 18 | client *minio.Client 19 | } 20 | 21 | func (m *Minio) Init(cfg config.StorageConfig) error { 22 | minioConfig, ok := cfg.(*config.MinioStorageConfig) 23 | if !ok { 24 | return fmt.Errorf("failed to cast minio config") 25 | } 26 | if err := minioConfig.Validate(); err != nil { 27 | return err 28 | } 29 | m.config = *minioConfig 30 | 31 | client, err := minio.New(m.config.Endpoint, &minio.Options{ 32 | Creds: credentials.NewStaticV4(m.config.AccessKeyID, m.config.SecretAccessKey, ""), 33 | Secure: m.config.UseSSL, 34 | }) 35 | if err != nil { 36 | return fmt.Errorf("failed to create minio client: %w", err) 37 | } 38 | 39 | exists, err := client.BucketExists(context.Background(), m.config.BucketName) 40 | if err != nil { 41 | return fmt.Errorf("failed to check bucket existence: %w", err) 42 | } 43 | if !exists { 44 | return fmt.Errorf("bucket %s does not exist", m.config.BucketName) 45 | } 46 | 47 | m.client = client 48 | return nil 49 | } 50 | 51 | func (m *Minio) Type() types.StorageType { 52 | return types.StorageTypeMinio 53 | } 54 | 55 | func (m *Minio) Name() string { 56 | return m.config.Name 57 | } 58 | 59 | func (m *Minio) JoinStoragePath(task types.Task) string { 60 | return path.Join(m.config.BasePath, task.StoragePath) 61 | } 62 | 63 | func (m *Minio) Save(ctx context.Context, r io.Reader, storagePath string) error { 64 | common.Log.Infof("Saving file from reader to %s", storagePath) 65 | 66 | _, err := m.client.PutObject(ctx, m.config.BucketName, storagePath, r, -1, minio.PutObjectOptions{}) 67 | if err != nil { 68 | return fmt.Errorf("failed to upload file to minio: %w", err) 69 | } 70 | 71 | return nil 72 | } 73 | -------------------------------------------------------------------------------- /storage/storage.go: -------------------------------------------------------------------------------- 1 | package storage 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/krau/SaveAny-Bot/common" 9 | "github.com/krau/SaveAny-Bot/config" 10 | sc "github.com/krau/SaveAny-Bot/config/storage" 11 | "github.com/krau/SaveAny-Bot/storage/alist" 12 | "github.com/krau/SaveAny-Bot/storage/local" 13 | "github.com/krau/SaveAny-Bot/storage/minio" 14 | "github.com/krau/SaveAny-Bot/storage/webdav" 15 | "github.com/krau/SaveAny-Bot/types" 16 | ) 17 | 18 | type Storage interface { 19 | Init(cfg sc.StorageConfig) error 20 | Type() types.StorageType 21 | Name() string 22 | JoinStoragePath(task types.Task) string 23 | Save(ctx context.Context, reader io.Reader, storagePath string) error 24 | } 25 | 26 | type StorageNotSupportStream interface { 27 | Storage 28 | NotSupportStream() string 29 | } 30 | 31 | var Storages = make(map[string]Storage) 32 | 33 | var UserStorages = make(map[int64][]Storage) 34 | 35 | // GetStorageByName returns storage by name from cache or creates new one 36 | func GetStorageByName(name string) (Storage, error) { 37 | if name == "" { 38 | return nil, ErrStorageNameEmpty 39 | } 40 | 41 | storage, ok := Storages[name] 42 | if ok { 43 | return storage, nil 44 | } 45 | cfg := config.Cfg.GetStorageByName(name) 46 | if cfg == nil { 47 | return nil, fmt.Errorf("未找到存储 %s", name) 48 | } 49 | 50 | storage, err := NewStorage(cfg) 51 | if err != nil { 52 | return nil, err 53 | } 54 | Storages[name] = storage 55 | return storage, nil 56 | } 57 | 58 | // 检查 user 是否可用指定的 storage, 若不可用则返回未找到错误 59 | func GetStorageByUserIDAndName(chatID int64, name string) (Storage, error) { 60 | if name == "" { 61 | return nil, ErrStorageNameEmpty 62 | } 63 | 64 | if !config.Cfg.HasStorage(chatID, name) { 65 | return nil, fmt.Errorf("没有找到用户 %d 的存储 %s", chatID, name) 66 | } 67 | 68 | return GetStorageByName(name) 69 | } 70 | 71 | func GetUserStorages(chatID int64) []Storage { 72 | if chatID <= 0 { 73 | return nil 74 | } 75 | if storages, ok := UserStorages[chatID]; ok { 76 | return storages 77 | } 78 | var storages []Storage 79 | for _, name := range config.Cfg.GetStorageNamesByUserID(chatID) { 80 | storage, err := GetStorageByName(name) 81 | if err != nil { 82 | continue 83 | } 84 | storages = append(storages, storage) 85 | } 86 | return storages 87 | } 88 | 89 | type StorageConstructor func() Storage 90 | 91 | var storageConstructors = map[string]StorageConstructor{ 92 | string(types.StorageTypeAlist): func() Storage { return new(alist.Alist) }, 93 | string(types.StorageTypeLocal): func() Storage { return new(local.Local) }, 94 | string(types.StorageTypeWebdav): func() Storage { return new(webdav.Webdav) }, 95 | string(types.StorageTypeMinio): func() Storage { return new(minio.Minio) }, 96 | } 97 | 98 | func NewStorage(cfg sc.StorageConfig) (Storage, error) { 99 | constructor, ok := storageConstructors[string(cfg.GetType())] 100 | if !ok { 101 | return nil, fmt.Errorf("不支持的存储类型: %s", cfg.GetType()) 102 | } 103 | 104 | storage := constructor() 105 | if err := storage.Init(cfg); err != nil { 106 | return nil, fmt.Errorf("初始化 %s 存储失败: %w", cfg.GetName(), err) 107 | } 108 | 109 | return storage, nil 110 | } 111 | 112 | func LoadStorages() { 113 | common.Log.Info("加载存储...") 114 | for _, storage := range config.Cfg.Storages { 115 | _, err := GetStorageByName(storage.GetName()) 116 | if err != nil { 117 | common.Log.Errorf("加载存储 %s 失败: %v", storage.GetName(), err) 118 | } 119 | } 120 | common.Log.Infof("成功加载 %d 个存储", len(Storages)) 121 | for user := range config.Cfg.GetUsersID() { 122 | UserStorages[int64(user)] = GetUserStorages(int64(user)) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /storage/webdav/client.go: -------------------------------------------------------------------------------- 1 | package webdav 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "strings" 11 | 12 | "github.com/krau/SaveAny-Bot/types" 13 | ) 14 | 15 | type Client struct { 16 | BaseURL string 17 | Username string 18 | Password string 19 | httpClient *http.Client 20 | } 21 | 22 | type WebdavMethod string 23 | 24 | const ( 25 | WebdavMethodMkcol WebdavMethod = "MKCOL" 26 | WebdavMethodPropfind WebdavMethod = "PROPFIND" 27 | WebdavMethodPut WebdavMethod = "PUT" 28 | ) 29 | 30 | func NewClient(baseURL, username, password string, httpClient *http.Client) *Client { 31 | if !strings.HasSuffix(baseURL, "/") { 32 | baseURL += "/" 33 | } 34 | if httpClient == nil { 35 | httpClient = http.DefaultClient 36 | } 37 | return &Client{ 38 | BaseURL: baseURL, 39 | Username: username, 40 | Password: password, 41 | httpClient: httpClient, 42 | } 43 | } 44 | 45 | func (c *Client) doRequest(ctx context.Context, method WebdavMethod, url string, body io.Reader) (*http.Response, error) { 46 | req, err := http.NewRequestWithContext(ctx, string(method), url, body) 47 | if err != nil { 48 | return nil, err 49 | } 50 | if c.Username != "" && c.Password != "" { 51 | req.SetBasicAuth(c.Username, c.Password) 52 | } 53 | if method == WebdavMethodPropfind { 54 | req.Header.Set("Depth", "1") 55 | } 56 | if method == WebdavMethodPut && ctx != nil { 57 | if length := ctx.Value(types.ContextKeyContentLength); length != nil { 58 | if l, ok := length.(int64); ok { 59 | req.ContentLength = l 60 | } 61 | } 62 | } 63 | return c.httpClient.Do(req) 64 | } 65 | 66 | func (c *Client) Exists(ctx context.Context, remotePath string) (bool, error) { 67 | url := c.BaseURL + remotePath 68 | resp, err := c.doRequest(ctx, WebdavMethodPropfind, url, nil) 69 | if err != nil { 70 | return false, err 71 | } 72 | defer resp.Body.Close() 73 | 74 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { 75 | return true, nil 76 | } 77 | if resp.StatusCode == http.StatusNotFound { 78 | return false, nil 79 | } 80 | return false, fmt.Errorf("PROPFIND: %s", resp.Status) 81 | } 82 | 83 | func (c *Client) MkDir(ctx context.Context, dirPath string) error { 84 | dirPath = strings.Trim(dirPath, "/") 85 | if dirPath == "" { 86 | return nil 87 | } 88 | parts := strings.Split(dirPath, "/") 89 | currentPath := "" 90 | for i, part := range parts { 91 | if i > 0 { 92 | currentPath += "/" 93 | } 94 | currentPath += part 95 | 96 | exists, err := c.Exists(ctx, currentPath) 97 | if err != nil { 98 | return err 99 | } 100 | if exists { 101 | continue 102 | } 103 | url := c.BaseURL + currentPath 104 | resp, err := c.doRequest(ctx, WebdavMethodMkcol, url, nil) 105 | if err != nil { 106 | return err 107 | } 108 | resp.Body.Close() 109 | 110 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { 111 | return fmt.Errorf("MKCOL %s: %s", currentPath, resp.Status) 112 | } 113 | } 114 | return nil 115 | } 116 | 117 | func (c *Client) WriteFile(ctx context.Context, remotePath string, content io.Reader) error { 118 | u, err := url.Parse(c.BaseURL) 119 | if err != nil { 120 | return err 121 | } 122 | parts := strings.Split(strings.Trim(remotePath, "/"), "/") 123 | u.Path = path.Join(u.Path, strings.Join(parts, "/")) 124 | resp, err := c.doRequest(ctx, WebdavMethodPut, u.String(), content) 125 | if err != nil { 126 | return err 127 | } 128 | defer resp.Body.Close() 129 | 130 | if resp.StatusCode >= 200 && resp.StatusCode < 300 { 131 | return nil 132 | } 133 | return fmt.Errorf("PUT: %s", resp.Status) 134 | 135 | } 136 | -------------------------------------------------------------------------------- /storage/webdav/client_test.go: -------------------------------------------------------------------------------- 1 | package webdav 2 | 3 | import ( 4 | "context" 5 | "net/http/httptest" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "strings" 10 | "testing" 11 | 12 | "golang.org/x/net/webdav" 13 | ) 14 | 15 | func setupWebDAVServer(t *testing.T) (*httptest.Server, string) { 16 | t.Helper() 17 | tempDir, err := os.MkdirTemp("", "webdav_test") 18 | if err != nil { 19 | t.Fatalf("mk temp dir failed: %v", err) 20 | } 21 | 22 | handler := &webdav.Handler{ 23 | Prefix: "/", 24 | FileSystem: webdav.Dir(tempDir), 25 | LockSystem: webdav.NewMemLS(), 26 | } 27 | 28 | server := httptest.NewServer(handler) 29 | return server, tempDir 30 | } 31 | 32 | func TestMkDirAndExists(t *testing.T) { 33 | server, tempDir := setupWebDAVServer(t) 34 | defer os.RemoveAll(tempDir) 35 | defer server.Close() 36 | 37 | client := NewClient(server.URL, "", "", nil) 38 | ctx := context.Background() 39 | 40 | testpaths := []string{"testdir", "testdir/subdir", "testdir/子目录", "/testdir/测试路径/测试路径2"} 41 | for _, p := range testpaths { 42 | exists, err := client.Exists(ctx, p) 43 | if err != nil { 44 | t.Fatalf("Call Exists Err: %v", err) 45 | } 46 | if exists { 47 | t.Fatalf("Dir should not exist") 48 | } 49 | 50 | if err := client.MkDir(ctx, p); err != nil { 51 | t.Fatalf("Call MkDir Err: %v", err) 52 | } 53 | 54 | exists, err = client.Exists(ctx, p) 55 | if err != nil { 56 | t.Fatalf("Call Exists Err: %v", err) 57 | } 58 | if !exists { 59 | t.Fatalf("Dir should exist") 60 | } 61 | } 62 | 63 | } 64 | 65 | func TestWriteFile(t *testing.T) { 66 | server, tempDir := setupWebDAVServer(t) 67 | defer os.RemoveAll(tempDir) 68 | defer server.Close() 69 | 70 | client := NewClient(server.URL, "", "", nil) 71 | ctx := context.Background() 72 | 73 | testCases := []struct { 74 | remotePath string 75 | content string 76 | }{ 77 | { 78 | remotePath: "hello.txt", 79 | content: "Hello webdav", 80 | }, 81 | { 82 | remotePath: "nested/dir/test.txt", 83 | content: "Nested file", 84 | }, 85 | { 86 | remotePath: "empty.txt", 87 | content: "", 88 | }, 89 | { 90 | remotePath: "unicode.txt", 91 | content: "测试", 92 | }, 93 | } 94 | 95 | for _, tc := range testCases { 96 | t.Run(tc.remotePath, func(t *testing.T) { 97 | dir := path.Dir(tc.remotePath) 98 | if dir != "." { 99 | if err := client.MkDir(ctx, dir); err != nil { 100 | t.Fatalf("创建目录 %s 失败: %v", dir, err) 101 | } 102 | } 103 | 104 | if err := client.WriteFile(ctx, tc.remotePath, strings.NewReader(tc.content)); err != nil { 105 | t.Fatalf("写入文件 %s 失败: %v", tc.remotePath, err) 106 | } 107 | 108 | localPath := filepath.Join(tempDir, tc.remotePath) 109 | data, err := os.ReadFile(localPath) 110 | if err != nil { 111 | t.Fatalf("读取文件 %s 失败: %v", localPath, err) 112 | } 113 | if string(data) != tc.content { 114 | t.Fatalf("文件内容不匹配: got %s, want %s", string(data), tc.content) 115 | } 116 | 117 | appended := tc.content + " Overwritten." 118 | if err := client.WriteFile(ctx, tc.remotePath, strings.NewReader(appended)); err != nil { 119 | t.Fatalf("覆盖写入文件 %s 失败: %v", tc.remotePath, err) 120 | } 121 | data, err = os.ReadFile(localPath) 122 | if err != nil { 123 | t.Fatalf("读取覆盖后的文件 %s 失败: %v", localPath, err) 124 | } 125 | if string(data) != appended { 126 | t.Fatalf("文件覆盖后的内容不匹配: got %s, want %s", string(data), appended) 127 | } 128 | }) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /storage/webdav/errs.go: -------------------------------------------------------------------------------- 1 | package webdav 2 | 3 | import "errors" 4 | 5 | var ( 6 | ErrFailedToCreateDirectory = errors.New("webdav: failed to create directory") 7 | ErrFailedToWriteFile = errors.New("webdav: failed to write file") 8 | ) 9 | -------------------------------------------------------------------------------- /storage/webdav/webdav.go: -------------------------------------------------------------------------------- 1 | package webdav 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "path" 9 | "time" 10 | 11 | "github.com/krau/SaveAny-Bot/common" 12 | config "github.com/krau/SaveAny-Bot/config/storage" 13 | "github.com/krau/SaveAny-Bot/types" 14 | ) 15 | 16 | type Webdav struct { 17 | config config.WebdavStorageConfig 18 | client *Client 19 | } 20 | 21 | func (w *Webdav) Init(cfg config.StorageConfig) error { 22 | webdavConfig, ok := cfg.(*config.WebdavStorageConfig) 23 | if !ok { 24 | return fmt.Errorf("failed to cast webdav config") 25 | } 26 | if err := webdavConfig.Validate(); err != nil { 27 | return err 28 | } 29 | w.config = *webdavConfig 30 | w.client = NewClient(w.config.URL, w.config.Username, w.config.Password, &http.Client{ 31 | Timeout: time.Hour * 12, 32 | }) 33 | return nil 34 | } 35 | 36 | func (w *Webdav) Type() types.StorageType { 37 | return types.StorageTypeWebdav 38 | } 39 | 40 | func (w *Webdav) Name() string { 41 | return w.config.Name 42 | } 43 | 44 | func (w *Webdav) JoinStoragePath(task types.Task) string { 45 | return path.Join(w.config.BasePath, task.StoragePath) 46 | } 47 | 48 | func (w *Webdav) Save(ctx context.Context, r io.Reader, storagePath string) error { 49 | common.Log.Infof("Saving file to %s", storagePath) 50 | if err := w.client.MkDir(ctx, path.Dir(storagePath)); err != nil { 51 | common.Log.Errorf("Failed to create directory %s: %v", path.Dir(storagePath), err) 52 | return ErrFailedToCreateDirectory 53 | } 54 | if err := w.client.WriteFile(ctx, storagePath, r); err != nil { 55 | common.Log.Errorf("Failed to write file %s: %v", storagePath, err) 56 | return ErrFailedToWriteFile 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /types/task.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "fmt" 8 | "net/url" 9 | "strings" 10 | "time" 11 | 12 | "github.com/gotd/td/tg" 13 | ) 14 | 15 | type Task struct { 16 | Ctx context.Context 17 | Cancel context.CancelFunc 18 | Error error 19 | Status TaskStatus 20 | StorageName string 21 | StoragePath string 22 | StartTime time.Time 23 | FileDBID uint 24 | 25 | File *File 26 | FileMessageID int 27 | FileChatID int64 28 | 29 | IsTelegraph bool 30 | TelegraphURL string 31 | 32 | // to track the reply message 33 | ReplyMessageID int 34 | ReplyChatID int64 35 | UserID int64 36 | } 37 | 38 | func (t Task) Key() string { 39 | if t.IsTelegraph { 40 | return hashStr(t.TelegraphURL) 41 | } 42 | return fmt.Sprintf("%d:%d", t.FileChatID, t.FileMessageID) 43 | } 44 | 45 | func (t Task) String() string { 46 | if t.IsTelegraph { 47 | return fmt.Sprintf("[telegraph]:%s", t.TelegraphURL) 48 | } 49 | return fmt.Sprintf("[%d:%d]:%s", t.FileChatID, t.FileMessageID, t.File.FileName) 50 | } 51 | 52 | func (t Task) FileName() string { 53 | if t.IsTelegraph { 54 | tgphPath := strings.Split(t.TelegraphURL, "/")[len(strings.Split(t.TelegraphURL, "/"))-1] 55 | tgphPathUnescaped, err := url.PathUnescape(tgphPath) 56 | if err != nil { 57 | return tgphPath 58 | } 59 | return tgphPathUnescaped 60 | } 61 | return t.File.FileName 62 | } 63 | 64 | type File struct { 65 | Location tg.InputFileLocationClass 66 | FileSize int64 67 | FileName string 68 | } 69 | 70 | func (f File) Hash() string { 71 | locationBytes := []byte(f.Location.String()) 72 | fileSizeBytes := []byte(fmt.Sprintf("%d", f.FileSize)) 73 | fileNameBytes := []byte(f.FileName) 74 | 75 | structBytes := append(locationBytes, fileSizeBytes...) 76 | structBytes = append(structBytes, fileNameBytes...) 77 | 78 | hash := md5.New() 79 | hash.Write(structBytes) 80 | hashBytes := hash.Sum(nil) 81 | 82 | return hex.EncodeToString(hashBytes) 83 | } 84 | -------------------------------------------------------------------------------- /types/types.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | type TaskStatus string 4 | 5 | const ( 6 | Pending TaskStatus = "pending" 7 | Succeeded TaskStatus = "succeeded" 8 | Failed TaskStatus = "failed" 9 | Canceled TaskStatus = "canceled" 10 | ) 11 | 12 | type StorageType string 13 | 14 | const ( 15 | StorageTypeLocal StorageType = "local" 16 | StorageTypeWebdav StorageType = "webdav" 17 | StorageTypeAlist StorageType = "alist" 18 | StorageTypeMinio StorageType = "minio" 19 | ) 20 | 21 | var StorageTypes = []StorageType{StorageTypeLocal, StorageTypeAlist, StorageTypeWebdav, StorageTypeMinio} 22 | var StorageTypeDisplay = map[StorageType]string{ 23 | StorageTypeLocal: "本地磁盘", 24 | StorageTypeWebdav: "WebDAV", 25 | StorageTypeAlist: "Alist", 26 | StorageTypeMinio: "Minio", 27 | } 28 | 29 | type ContextKey string 30 | 31 | const ( 32 | ContextKeyContentLength ContextKey = "content-length" 33 | ) 34 | 35 | type RuleType string 36 | 37 | const ( 38 | RuleTypeFileNameRegex RuleType = "FILENAME-REGEX" 39 | RuleTypeMessageRegex RuleType = "MESSAGE-REGEX" 40 | ) 41 | 42 | var RuleTypes = []RuleType{RuleTypeFileNameRegex, RuleTypeMessageRegex} -------------------------------------------------------------------------------- /types/utils.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | ) 7 | 8 | func hashStr(s string) string { 9 | hash := md5.New() 10 | hash.Write([]byte(s)) 11 | return hex.EncodeToString(hash.Sum(nil)) 12 | } 13 | --------------------------------------------------------------------------------