From 24dd34bf5b750e049a34e590dc95317d8458b934 Mon Sep 17 00:00:00 2001
From: fyaz05
Date: Sun, 6 Sep 2026 09:38:16 +0000
Subject: [PATCH 01/49] chore: normalize line endings to LF + add
.gitattributes (L8 hygiene)
---
.gitattributes | 14 +
.github/workflows/dockerize.yml | 52 +-
.gitignore | 54 +-
.python-version | 0
AGENTS.md | 0
Dockerfile | 46 +-
LICENSE | 402 +++++------
Procfile | 0
README.md | 1164 +++++++++++++++---------------
Thunder/__init__.py | 12 +-
Thunder/__main__.py | 656 ++++++++---------
Thunder/bot/__init__.py | 0
Thunder/bot/clients.py | 164 ++---
Thunder/bot/plugins/admin.py | 990 ++++++++++++-------------
Thunder/bot/plugins/callbacks.py | 446 ++++++------
Thunder/bot/plugins/common.py | 560 +++++++-------
Thunder/bot/plugins/stream.py | 1030 +++++++++++++-------------
Thunder/server/__init__.py | 20 +-
Thunder/server/exceptions.py | 14 +-
Thunder/server/stream_routes.py | 0
Thunder/template/dl.html | 52 +-
Thunder/template/req.html | 718 +++++++++---------
Thunder/utils/bot_utils.py | 246 +++----
Thunder/utils/broadcast.py | 378 +++++-----
Thunder/utils/canonical_files.py | 924 ++++++++++++------------
Thunder/utils/commands.py | 76 +-
Thunder/utils/config_parser.py | 72 +-
Thunder/utils/custom_dl.py | 0
Thunder/utils/database.py | 906 +++++++++++------------
Thunder/utils/decorators.py | 362 +++++-----
Thunder/utils/file_properties.py | 198 ++---
Thunder/utils/force_channel.py | 178 ++---
Thunder/utils/human_readable.py | 36 +-
Thunder/utils/keepalive.py | 44 +-
Thunder/utils/logger.py | 78 +-
Thunder/utils/messages.py | 790 ++++++++++----------
Thunder/utils/rate_limiter.py | 874 +++++++++++-----------
Thunder/utils/render_template.py | 0
Thunder/utils/shortener.py | 0
Thunder/utils/speedtest.py | 86 +--
Thunder/utils/time_format.py | 34 +-
Thunder/utils/tokens.py | 320 ++++----
Thunder/vars.py | 315 +++++---
config_sample.env | 250 +++----
heroku.yml | 6 +-
requirements.txt | 20 +-
thunder.sh | 0
update.py | 82 +--
48 files changed, 6397 insertions(+), 6272 deletions(-)
create mode 100644 .gitattributes
mode change 100644 => 100755 .github/workflows/dockerize.yml
mode change 100644 => 100755 .gitignore
mode change 100644 => 100755 .python-version
mode change 100644 => 100755 AGENTS.md
mode change 100644 => 100755 Dockerfile
mode change 100644 => 100755 LICENSE
mode change 100644 => 100755 Procfile
mode change 100644 => 100755 README.md
mode change 100644 => 100755 Thunder/__init__.py
mode change 100644 => 100755 Thunder/__main__.py
mode change 100644 => 100755 Thunder/bot/__init__.py
mode change 100644 => 100755 Thunder/bot/clients.py
mode change 100644 => 100755 Thunder/bot/plugins/admin.py
mode change 100644 => 100755 Thunder/bot/plugins/callbacks.py
mode change 100644 => 100755 Thunder/bot/plugins/common.py
mode change 100644 => 100755 Thunder/bot/plugins/stream.py
mode change 100644 => 100755 Thunder/server/__init__.py
mode change 100644 => 100755 Thunder/server/exceptions.py
mode change 100644 => 100755 Thunder/server/stream_routes.py
mode change 100644 => 100755 Thunder/template/dl.html
mode change 100644 => 100755 Thunder/template/req.html
mode change 100644 => 100755 Thunder/utils/bot_utils.py
mode change 100644 => 100755 Thunder/utils/broadcast.py
mode change 100644 => 100755 Thunder/utils/canonical_files.py
mode change 100644 => 100755 Thunder/utils/commands.py
mode change 100644 => 100755 Thunder/utils/config_parser.py
mode change 100644 => 100755 Thunder/utils/custom_dl.py
mode change 100644 => 100755 Thunder/utils/database.py
mode change 100644 => 100755 Thunder/utils/decorators.py
mode change 100644 => 100755 Thunder/utils/file_properties.py
mode change 100644 => 100755 Thunder/utils/force_channel.py
mode change 100644 => 100755 Thunder/utils/human_readable.py
mode change 100644 => 100755 Thunder/utils/keepalive.py
mode change 100644 => 100755 Thunder/utils/logger.py
mode change 100644 => 100755 Thunder/utils/messages.py
mode change 100644 => 100755 Thunder/utils/rate_limiter.py
mode change 100644 => 100755 Thunder/utils/render_template.py
mode change 100644 => 100755 Thunder/utils/shortener.py
mode change 100644 => 100755 Thunder/utils/speedtest.py
mode change 100644 => 100755 Thunder/utils/time_format.py
mode change 100644 => 100755 Thunder/utils/tokens.py
mode change 100644 => 100755 Thunder/vars.py
mode change 100644 => 100755 config_sample.env
mode change 100644 => 100755 heroku.yml
mode change 100644 => 100755 requirements.txt
mode change 100644 => 100755 thunder.sh
mode change 100644 => 100755 update.py
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..cbcdca3
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,14 @@
+# Normalize all text files to LF in the repository and working tree.
+* text=auto eol=lf
+
+# Windows scripts that must keep CRLF if any are ever added
+# (*.bat text eol=crlf)
+
+# Binary assets
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.ico binary
+*.docx binary
+*.session binary
diff --git a/.github/workflows/dockerize.yml b/.github/workflows/dockerize.yml
old mode 100644
new mode 100755
index a810be6..537af40
--- a/.github/workflows/dockerize.yml
+++ b/.github/workflows/dockerize.yml
@@ -1,26 +1,26 @@
-name: Docker Build & Push
-
-on:
- push:
- branches: [main]
- workflow_dispatch:
-
-jobs:
- build:
- if: github.repository == 'fyaz05/FileToLink'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v5
-
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: fyaz05
- password: ${{ secrets.DOCKER_TOKEN }}
-
- - name: Build and Push
- uses: docker/build-push-action@v6
- with:
- context: .
- push: true
- tags: fyaz05/thunder:latest
+name: Docker Build & Push
+
+on:
+ push:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ build:
+ if: github.repository == 'fyaz05/FileToLink'
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v3
+ with:
+ username: fyaz05
+ password: ${{ secrets.DOCKER_TOKEN }}
+
+ - name: Build and Push
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ push: true
+ tags: fyaz05/thunder:latest
diff --git a/.gitignore b/.gitignore
old mode 100644
new mode 100755
index 431eeca..514cd43
--- a/.gitignore
+++ b/.gitignore
@@ -1,28 +1,28 @@
-*.py[cod]
-*$py.class
-*.so
-.venv/
-.Python
-config.env
-log.text
-.vscode/
-**/__pycache__/
-*.session
-*.session-journal
-*.session-shm
-*.session-wal
-build/
-develop-eggs/
-dist/
-downloads/
-eggs/
-.eggs/
-lib/
-lib64/
-parts/
-sdist/
-var/
-wheels/
-*.egg-info/
-.installed.cfg
+*.py[cod]
+*$py.class
+*.so
+.venv/
+.Python
+config.env
+log.text
+.vscode/
+**/__pycache__/
+*.session
+*.session-journal
+*.session-shm
+*.session-wal
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+wheels/
+*.egg-info/
+.installed.cfg
*.egg
\ No newline at end of file
diff --git a/.python-version b/.python-version
old mode 100644
new mode 100755
diff --git a/AGENTS.md b/AGENTS.md
old mode 100644
new mode 100755
diff --git a/Dockerfile b/Dockerfile
old mode 100644
new mode 100755
index 5e7ede2..edd9793
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,23 +1,23 @@
-FROM python:3.13-slim
-
-ENV PYTHONUNBUFFERED=1 \
- PYTHONDONTWRITEBYTECODE=1
-
-WORKDIR /app
-
-RUN apt-get update && \
- apt-get install -y --no-install-recommends \
- git \
- build-essential \
- libssl-dev \
- && apt-get clean && \
- rm -rf /var/lib/apt/lists/*
-
-COPY requirements.txt .
-
-RUN pip install --upgrade pip && \
- pip install --no-cache-dir -r requirements.txt
-
-COPY . .
-
-CMD ["bash", "thunder.sh"]
+FROM python:3.13-slim
+
+ENV PYTHONUNBUFFERED=1 \
+ PYTHONDONTWRITEBYTECODE=1
+
+WORKDIR /app
+
+RUN apt-get update && \
+ apt-get install -y --no-install-recommends \
+ git \
+ build-essential \
+ libssl-dev \
+ && apt-get clean && \
+ rm -rf /var/lib/apt/lists/*
+
+COPY requirements.txt .
+
+RUN pip install --upgrade pip && \
+ pip install --no-cache-dir -r requirements.txt
+
+COPY . .
+
+CMD ["bash", "thunder.sh"]
diff --git a/LICENSE b/LICENSE
old mode 100644
new mode 100755
index 29f81d8..261eeb9
--- a/LICENSE
+++ b/LICENSE
@@ -1,201 +1,201 @@
- Apache License
- Version 2.0, January 2004
- http://www.apache.org/licenses/
-
- TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
-
- 1. Definitions.
-
- "License" shall mean the terms and conditions for use, reproduction,
- and distribution as defined by Sections 1 through 9 of this document.
-
- "Licensor" shall mean the copyright owner or entity authorized by
- the copyright owner that is granting the License.
-
- "Legal Entity" shall mean the union of the acting entity and all
- other entities that control, are controlled by, or are under common
- control with that entity. For the purposes of this definition,
- "control" means (i) the power, direct or indirect, to cause the
- direction or management of such entity, whether by contract or
- otherwise, or (ii) ownership of fifty percent (50%) or more of the
- outstanding shares, or (iii) beneficial ownership of such entity.
-
- "You" (or "Your") shall mean an individual or Legal Entity
- exercising permissions granted by this License.
-
- "Source" form shall mean the preferred form for making modifications,
- including but not limited to software source code, documentation
- source, and configuration files.
-
- "Object" form shall mean any form resulting from mechanical
- transformation or translation of a Source form, including but
- not limited to compiled object code, generated documentation,
- and conversions to other media types.
-
- "Work" shall mean the work of authorship, whether in Source or
- Object form, made available under the License, as indicated by a
- copyright notice that is included in or attached to the work
- (an example is provided in the Appendix below).
-
- "Derivative Works" shall mean any work, whether in Source or Object
- form, that is based on (or derived from) the Work and for which the
- editorial revisions, annotations, elaborations, or other modifications
- represent, as a whole, an original work of authorship. For the purposes
- of this License, Derivative Works shall not include works that remain
- separable from, or merely link (or bind by name) to the interfaces of,
- the Work and Derivative Works thereof.
-
- "Contribution" shall mean any work of authorship, including
- the original version of the Work and any modifications or additions
- to that Work or Derivative Works thereof, that is intentionally
- submitted to Licensor for inclusion in the Work by the copyright owner
- or by an individual or Legal Entity authorized to submit on behalf of
- the copyright owner. For the purposes of this definition, "submitted"
- means any form of electronic, verbal, or written communication sent
- to the Licensor or its representatives, including but not limited to
- communication on electronic mailing lists, source code control systems,
- and issue tracking systems that are managed by, or on behalf of, the
- Licensor for the purpose of discussing and improving the Work, but
- excluding communication that is conspicuously marked or otherwise
- designated in writing by the copyright owner as "Not a Contribution."
-
- "Contributor" shall mean Licensor and any individual or Legal Entity
- on behalf of whom a Contribution has been received by Licensor and
- subsequently incorporated within the Work.
-
- 2. Grant of Copyright License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- copyright license to reproduce, prepare Derivative Works of,
- publicly display, publicly perform, sublicense, and distribute the
- Work and such Derivative Works in Source or Object form.
-
- 3. Grant of Patent License. Subject to the terms and conditions of
- this License, each Contributor hereby grants to You a perpetual,
- worldwide, non-exclusive, no-charge, royalty-free, irrevocable
- (except as stated in this section) patent license to make, have made,
- use, offer to sell, sell, import, and otherwise transfer the Work,
- where such license applies only to those patent claims licensable
- by such Contributor that are necessarily infringed by their
- Contribution(s) alone or by combination of their Contribution(s)
- with the Work to which such Contribution(s) was submitted. If You
- institute patent litigation against any entity (including a
- cross-claim or counterclaim in a lawsuit) alleging that the Work
- or a Contribution incorporated within the Work constitutes direct
- or contributory patent infringement, then any patent licenses
- granted to You under this License for that Work shall terminate
- as of the date such litigation is filed.
-
- 4. Redistribution. You may reproduce and distribute copies of the
- Work or Derivative Works thereof in any medium, with or without
- modifications, and in Source or Object form, provided that You
- meet the following conditions:
-
- (a) You must give any other recipients of the Work or
- Derivative Works a copy of this License; and
-
- (b) You must cause any modified files to carry prominent notices
- stating that You changed the files; and
-
- (c) You must retain, in the Source form of any Derivative Works
- that You distribute, all copyright, patent, trademark, and
- attribution notices from the Source form of the Work,
- excluding those notices that do not pertain to any part of
- the Derivative Works; and
-
- (d) If the Work includes a "NOTICE" text file as part of its
- distribution, then any Derivative Works that You distribute must
- include a readable copy of the attribution notices contained
- within such NOTICE file, excluding those notices that do not
- pertain to any part of the Derivative Works, in at least one
- of the following places: within a NOTICE text file distributed
- as part of the Derivative Works; within the Source form or
- documentation, if provided along with the Derivative Works; or,
- within a display generated by the Derivative Works, if and
- wherever such third-party notices normally appear. The contents
- of the NOTICE file are for informational purposes only and
- do not modify the License. You may add Your own attribution
- notices within Derivative Works that You distribute, alongside
- or as an addendum to the NOTICE text from the Work, provided
- that such additional attribution notices cannot be construed
- as modifying the License.
-
- You may add Your own copyright statement to Your modifications and
- may provide additional or different license terms and conditions
- for use, reproduction, or distribution of Your modifications, or
- for any such Derivative Works as a whole, provided Your use,
- reproduction, and distribution of the Work otherwise complies with
- the conditions stated in this License.
-
- 5. Submission of Contributions. Unless You explicitly state otherwise,
- any Contribution intentionally submitted for inclusion in the Work
- by You to the Licensor shall be under the terms and conditions of
- this License, without any additional terms or conditions.
- Notwithstanding the above, nothing herein shall supersede or modify
- the terms of any separate license agreement you may have executed
- with Licensor regarding such Contributions.
-
- 6. Trademarks. This License does not grant permission to use the trade
- names, trademarks, service marks, or product names of the Licensor,
- except as required for reasonable and customary use in describing the
- origin of the Work and reproducing the content of the NOTICE file.
-
- 7. Disclaimer of Warranty. Unless required by applicable law or
- agreed to in writing, Licensor provides the Work (and each
- Contributor provides its Contributions) on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
- implied, including, without limitation, any warranties or conditions
- of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
- PARTICULAR PURPOSE. You are solely responsible for determining the
- appropriateness of using or redistributing the Work and assume any
- risks associated with Your exercise of permissions under this License.
-
- 8. Limitation of Liability. In no event and under no legal theory,
- whether in tort (including negligence), contract, or otherwise,
- unless required by applicable law (such as deliberate and grossly
- negligent acts) or agreed to in writing, shall any Contributor be
- liable to You for damages, including any direct, indirect, special,
- incidental, or consequential damages of any character arising as a
- result of this License or out of the use or inability to use the
- Work (including but not limited to damages for loss of goodwill,
- work stoppage, computer failure or malfunction, or any and all
- other commercial damages or losses), even if such Contributor
- has been advised of the possibility of such damages.
-
- 9. Accepting Warranty or Additional Liability. While redistributing
- the Work or Derivative Works thereof, You may choose to offer,
- and charge a fee for, acceptance of support, warranty, indemnity,
- or other liability obligations and/or rights consistent with this
- License. However, in accepting such obligations, You may act only
- on Your own behalf and on Your sole responsibility, not on behalf
- of any other Contributor, and only if You agree to indemnify,
- defend, and hold each Contributor harmless for any liability
- incurred by, or claims asserted against, such Contributor by reason
- of your accepting any such warranty or additional liability.
-
- END OF TERMS AND CONDITIONS
-
- APPENDIX: How to apply the Apache License to your work.
-
- To apply the Apache License to your work, attach the following
- boilerplate notice, with the fields enclosed by brackets "[]"
- replaced with your own identifying information. (Don't include
- the brackets!) The text should be enclosed in the appropriate
- comment syntax for the file format. We also recommend that a
- file or class name and description of purpose be included on the
- same "printed page" as the copyright notice for easier
- identification within third-party archives.
-
- Copyright [yyyy] [name of copyright owner]
-
- Licensed under the Apache License, Version 2.0 (the "License");
- you may not use this file except in compliance with the License.
- You may obtain a copy of the License at
-
- http://www.apache.org/licenses/LICENSE-2.0
-
- Unless required by applicable law or agreed to in writing, software
- distributed under the License is distributed on an "AS IS" BASIS,
- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- See the License for the specific language governing permissions and
- limitations under the License.
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Procfile b/Procfile
old mode 100644
new mode 100755
diff --git a/README.md b/README.md
old mode 100644
new mode 100755
index 78a722e..cfd8d98
--- a/README.md
+++ b/README.md
@@ -1,582 +1,582 @@
-
-
-
β‘ Thunder
-
-
-
- High-Performance Telegram File-to-Link Bot for Direct Links & Streaming
-
-
-
-
-
-
-
-
-
-
-
-## π Table of Contents
-
-- [About The Project](#about-the-project)
-- [How It Works](#how-it-works)
-- [Features](#features)
-- [Configuration](#configuration)
- - [Essential Configuration](#essential-configuration)
- - [Optional Configuration](#optional-configuration)
-- [Usage and Commands](#usage-and-commands)
- - [Basic Usage](#basic-usage)
- - [Commands Reference](#commands-reference)
-- [Advanced Feature Setup](#advanced-feature-setup)
- - [Token System](#token-system)
- - [URL Shortening](#url-shortening)
- - [Rate Limiting System](#rate-limiting-system)
- - [Network Speed Testing](#network-speed-testing)
-- [Deployment Guide](#deployment-guide)
- - [Prerequisites](#prerequisites)
- - [Installation](#installation)
- - [Quick Deploy](#quick-deploy)
- - [Deploy to Koyeb](#deploy-to-koyeb)
- - [Deploy to Render](#deploy-to-render)
- - [Deploy to Railway](#deploy-to-railway)
- - [Deploy to Heroku](#deploy-to-heroku)
- - [Reverse Proxy Setup](#reverse-proxy-setup)
-- [Support & Community](#support--community)
- - [Troubleshooting & FAQ](#troubleshooting--faq)
- - [Contributing](#contributing)
-- [License](#license)
-- [Acknowledgments](#acknowledgments)
-
-
-
-## About The Project
-
-**Thunder** is a powerful Telegram bot that transforms Telegram files into high-speed direct links, perfect for both streaming and rapid downloading. Share files via HTTP(S) links without needing to download them from the Telegram client first.
-
-### π‘ Perfect For
-
-- π Bypassing Telegram's built-in download speed limits
-- βοΈ Unlimited cloud storage with fast streaming and download links
-- π¬ Content creators sharing media files
-- π₯ Communities distributing resources
-- π Educational platforms sharing materials
-
-## How It Works
-
-```
-User Uploads File β Telegram Bot β Forwards to Channel β Generates Direct Link β Direct Download / Streaming
-```
-
-1. **Upload** β User sends any file to the bot.
-2. **Store** β The bot forwards the file to your private storage channel (`BIN_CHANNEL`), where it is permanently saved to generate the link.
-3. **Generate** β A unique, permanent link is created.
-4. **Stream/Download** β Anyone with the link can stream or download the file directly in their browser.
-5. **Balance** β Multi-client support distributes the load for high availability.
-
-## Features
-
-#### Core Functionality
-
-- β **Direct Link Generation** - Convert any Telegram file into a direct HTTP(S) link.
-- β **Permanent Links** - Links remain active as long as the file exists in the storage channel.
-- β **Browser Streaming & Downloading** - Stream media directly or download files at high speed without a Telegram client.
-- β **All File Types** - Supports video, audio, documents, images, and any other file format.
-- β **Batch Processing** - Generate links for multiple files at once with a single command.
-
-#### Performance & Scalability
-
-- β **Multi-Client Support** - Distributes traffic across multiple Telegram bots to avoid limits and increase throughput.
-- β **Async Architecture** - Built with `aiohttp` and `asyncio` for non-blocking, high-performance operations.
-- β **MongoDB Integration** - Ensures persistent and reliable data storage.
-
-#### Security & Control
-
-- π **Token Authentication** - Secure user access with a time-limited token system.
-- π‘οΈ **Admin Controls** - Full suite of commands for user and bot management.
-- π€ **User Authentication** - Require users to join a specific channel before they can use the bot.
-- β **Channel/Group Support** - Fully functional in private chats, groups, and channels.
-
-#### Customization
-
-- π **Custom Domain** - Serve files from your own domain for a professional look.
-- π **URL Shortening** - Integrate with URL shortener services for clean, shareable links.
-- π¨ **Custom Templates** - Personalize messages sent by the bot to match your brand.
-- π **Media Info Display** - Shows file size, duration, and format details in the response message.
-
-## Configuration
-
-Copy `config_sample.env` to `config.env` and fill in your values.
-
-### Essential Configuration
-
-| Variable | Description | Example |
-| :--- | :--- | :--- |
-| `API_ID` | Telegram API ID | `12345678` |
-| `API_HASH` | Telegram API Hash | `abc123def456` |
-| `BOT_TOKEN` | Bot token from @BotFather | `123456:ABCdefGHI` |
-| `BIN_CHANNEL` | Storage channel ID | `-1001234567890` |
-| `OWNER_ID` | Owner user ID | `12345678` |
-| `DATABASE_URL` | MongoDB connection | `mongodb+srv://...` |
-| `FQDN` | Domain/IP address | `f2l.thunder.com` |
-| `HAS_SSL` | HTTPS enabled | `True` or `False` |
-| `PORT` | Server port | `8080` |
-| `NO_PORT` | Hide port in URLs | `True` or `False` |
-
-### Optional Configuration
-
-
-Optional Configuration Details
-
-| Variable | Description | Default |
-| :--- | :--- | :--- |
-| `MULTI_TOKEN1` | Additional bot token 1 (use MULTI_TOKEN1, MULTI_TOKEN2, etc.) | *(empty)* |
-| `FORCE_CHANNEL_ID` | Required channel join | *(empty)* |
-| `MAX_BATCH_FILES` | Maximum files in batch processing | `50` |
-| `CHANNEL` | Allow processing messages from channels | `False` |
-| `BANNED_CHANNELS` | Blocked channel IDs | *(empty)* |
-| `SLEEP_THRESHOLD` | Client switch threshold | `300` |
-| `WORKERS` | Async workers | `8` |
-| `NAME` | Bot name | `ThunderF2L` |
-| `BIND_ADDRESS` | Bind address | `0.0.0.0` |
-| `PING_INTERVAL` | Ping interval (seconds) | `840` |
-| `TOKEN_ENABLED` | Enable tokens | `False` |
-| `SHORTEN_ENABLED` | URL shortening for tokens | `False` |
-| `SHORTEN_MEDIA_LINKS` | URL shortening for media | `False` |
-| `TOKEN_TTL_HOURS` | Token validity duration in hours | `24` |
-| `URL_SHORTENER_API_KEY` | Shortener API key | *(empty)* |
-| `URL_SHORTENER_SITE` | Shortener service | *(empty)* |
-| `SET_COMMANDS` | Auto-set bot commands | `True` |
-| `RATE_LIMIT_ENABLED` | Enable rate limiting | `False` |
-| `MAX_FILES_PER_PERIOD` | Files per window | `2` |
-| `RATE_LIMIT_PERIOD_MINUTES` | Time window | `1` |
-| `MAX_QUEUE_SIZE` | Queue size | `100` |
-| `GLOBAL_RATE_LIMIT` | Global limiting | `True` |
-| `MAX_GLOBAL_REQUESTS_PER_MINUTE` | Global limit | `4` |
-
-
-
-## Usage and Commands
-
-### Basic Usage
-
-1. **Start** β Send `/start` to the bot.
-2. **Authenticate** β Join required channels (if configured).
-3. **Upload** β Send any media file.
-4. **Receive** β Get a direct streaming and download link.
-5. **Share** β Anyone can access the file via the link.
-
-### Commands Reference
-
-#### User Commands
-
-| Command | Description |
-| :--- | :--- |
-| `/start` | Start the bot and get a welcome message. Also used for token activation. |
-| `/link` | Generates a link. For batches, **reply to the first file** of a group and specify the count. **Example:** `/link 5` will process that file and the next four. |
-| `/dc` | Get the data center (DC) of a user or file. Use `/dc id`, or reply to a file or user. |
-| `/ping` | Check if the bot is online and measure response time. |
-| `/about` | Get information about the bot. |
-| `/help` | Show help and usage instructions. |
-
-#### Admin Commands
-
-| Command | Description |
-| :--- | :--- |
-| `/status` | Check bot status, uptime, and resource usage. |
-| `/broadcast` | Send a message to all users (supports text, media, buttons). |
-| `/stats` | View usage statistics and analytics. |
-| `/ban` | Ban a user or channel (reply to message or use user/channel ID). |
-| `/unban` | Unban a user or channel. |
-| `/log` | Send bot logs. |
-| `/restart` | Restart the bot. |
-| `/shell` | Execute a shell command. |
-| `/speedtest` | Run network speed test and display comprehensive results. |
-| `/users` | Show total number of users. |
-| `/authorize` | Permanently authorize a user to use the bot (bypasses token system). |
-| `/deauthorize` | Remove permanent authorization from a user. |
-| `/listauth` | List all permanently authorized users. |
-
-
-
BotFather Commands Setup
-
-```text
-start - Initialize bot
-link - Generate direct link
-dc - Get data center info
-ping - Check bot status
-about - Bot information
-help - Show help guide
-status - [Admin] System status
-stats - [Admin] Usage statistics
-broadcast - [Admin] Message all users
-ban - [Admin] Ban user
-unban - [Admin] Unban user
-users - [Admin] User count
-authorize - [Admin] Grant access
-deauthorize - [Admin] Revoke access
-listauth - [Admin] List authorized
-log - [Admin] Send bot logs
-restart - [Admin] Restart the bot
-shell - [Admin] Execute shell command
-speedtest - [Admin] Run network speed test
-```
-
-
-
-## Advanced Feature Setup
-
-### Token System
-
-Enable controlled access with tokens:
-
-1. Set `TOKEN_ENABLED=True` in your `config.env`.
-2. Users receive automatic tokens on first use.
-3. Admins can grant permanent authorization with `/authorize` to bypass tokens.
-4. Tokens include activation links for secure access.
-
-### URL Shortening
-
-Configure URL shortening for cleaner links:
-
-```env
-SHORTEN_ENABLED=True
-SHORTEN_MEDIA_LINKS=True
-URL_SHORTENER_API_KEY=your_api_key
-URL_SHORTENER_SITE=shortener.example.com
-```
-
-### Rate Limiting System
-
-Thunder implements a sophisticated multi-tier rate limiting system designed for high-performance file sharing:
-
-#### **Priority Queue Architecture**
-
-- **Owner Priority**: Complete bypass of all rate limits.
-- **Authorized Users**: Dedicated priority queue with faster processing.
-- **Regular Users**: Standard queue with fair scheduling.
-
-#### **Multi-Level Rate Limiting**
-
-- **Per-User Limits**: Configurable files per time window.
-- **Global Limits**: System-wide request throttling.
-- **Sliding Window**: Time-based rate limiting with automatic cleanup.
-
-#### **Smart Queue Management**
-
-- **Automatic Re-queuing**: Failed requests due to rate limits are intelligently re-queued.
-- **Queue Size Limits**: Configurable maximum queue size.
-- **Flood Protection**: Built-in protection against Telegram flood waits.
-
-### Network Speed Testing
-
-Monitor server performance with built-in speed testing:
-
-```bash
-/speedtest
-```
-
-Features include download/upload speeds, latency measurements, and shareable result images for performance monitoring.
-
-## Deployment Guide
-
-This section covers the complete setup process for deploying Thunder, from prerequisites to production deployment.
-
-### Prerequisites
-
-| Requirement | Description | Source |
-| :--- | :--- | :--- |
-| Python 3.13 | Programming language | [python.org](https://python.org) |
-| MongoDB | Database | [mongodb.com](https://mongodb.com) |
-| Telegram API | API credentials | [my.telegram.org](https://my.telegram.org/apps) |
-| Bot Token | From @BotFather | [@BotFather](https://t.me/BotFather) |
-| Public Server | VPS/Dedicated server | Any provider |
-| Storage Channel | For file storage | Create in Telegram |
-
-### Installation
-
-#### Docker Installation (Recommended)
-
-```bash
-# 1. Clone repository
-git clone https://github.com/fyaz05/FileToLink.git
-cd FileToLink
-
-# 2. Configure
-cp config_sample.env config.env
-nano config.env # Edit your settings
-
-# 3. Build and run
-docker build -t thunder .
-docker run -d --name thunder -p 8080:8080 thunder
-```
-
-
-Manual Installation
-
-```bash
-# 1. Clone repository
-git clone https://github.com/fyaz05/FileToLink.git
-cd FileToLink
-
-# 2. Setup virtual environment
-python3 -m venv venv
-source venv/bin/activate # Windows: venv\Scripts\activate
-
-# 3. Install dependencies
-pip install -r requirements.txt
-
-# 4. Configure
-cp config_sample.env config.env
-nano config.env
-
-# 5. Run bot
-python -m Thunder
-```
-
-> **Tip:** Start with the essential configuration to get Thunder running, then add optional features as needed.
-
-
-
-## Quick Deploy
-
-### Deploy to Koyeb
-
-[](https://app.koyeb.com/deploy?type=docker&image=docker.io/fyaz05/thunder:latest&name=thunder&ports=8080;http;/&env[API_ID]=&env[API_HASH]=&env[BOT_TOKEN]=&env[BIN_CHANNEL]=&env[OWNER_ID]=&env[DATABASE_URL]=&env[FQDN]=)
-
-After deployment, to add any additional environment variables, use the Koyeb dashboard under **Settings** β **Environment Variables**.
-
-### Deploy to Render
-
-1. Open [Render Dashboard](https://dashboard.render.com) β **New** β **Web Service**
-2. Choose **Existing Image**: `fyaz05/thunder:latest`
-3. Add your environment variables
-4. Click **Deploy**
-
-### Deploy to Railway
-
-1. Open [Railway](https://railway.app) β **New Project** β **Deploy Service**
-2. Choose **Docker Image**: `fyaz05/thunder:latest`
-3. Add your environment variables
-4. Click **Deploy**
-
-### Deploy to Heroku
-
-1. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) and login.
-2. Create an app in the EU region (required for GDPR compliance):
- ```bash
- heroku create your-app-name --region eu
- heroku stack:set container
- ```
-3. Set your config vars:
- ```bash
- heroku config:set API_ID="your_id" API_HASH="your_hash" BOT_TOKEN="your_token" \
- BIN_CHANNEL="-100xxx" OWNER_ID="your_id" FQDN="your-app-name.herokuapp.com" \
- HAS_SSL="True" NO_PORT="True" PORT="8080"
- ```
-4. Set `DATABASE_URL` via the Heroku Dashboard or API (ampersand in MongoDB URL causes shell issues).
-5. Deploy via source upload (Heroku does not support direct git push with API key auth):
- ```bash
- # Create source tarball
- tar -czf source.tar.gz --exclude='.git' --exclude='__pycache__' .
- # Upload via Heroku Builds API β see devcenter.heroku.com/articles/build-and-release-using-the-api
- ```
-6. Scale the dyno:
- ```bash
- heroku ps:scale web=1
- ```
-7. Set `UPSTREAM_REPO` for auto-updates on dyno restart:
- ```bash
- heroku config:set UPSTREAM_REPO="https://github.com/fyaz05/FileToLink" UPSTREAM_BRANCH="main"
- ```
-
-> **Note:** Heroku provides HTTPS automatically. Set `FQDN` to `your-app-name.herokuapp.com` and `HAS_SSL` to `True`.
-
-> **Note:** See the [Configuration](#configuration) section for required environment variables.
-
-## Reverse Proxy Setup
-
-
-Reverse Proxy Guide
-
-This guide will help you set up a secure reverse proxy using **NGINX** for your file streaming bot with **Cloudflare SSL protection**.
-
----
-
-#### β What You Need
-
-- A **VPS or server** running Ubuntu/Debian with NGINX installed.
-- Your **file streaming bot** running on a local port (e.g., `8080`).
-- A **subdomain** (e.g., `f2l.thunder.com`) set up in **Cloudflare**.
-- **Cloudflare Origin Certificate** files: `cert.pem` and `key.key`.
-
----
-
-#### π Step 1: Configure Cloudflare
-
-- **DNS**: Add an `A` record for your subdomain pointing to your server's IP. Ensure **Proxy Status** is **Proxied (orange cloud)**.
-- **SSL**: In the **SSL/TLS** tab, set the encryption mode to **Full (strict)**.
-
----
-
-#### π‘οΈ Step 2: Set Up SSL Certificates on Server
-
-Create a folder for your certificates and place `cert.pem` and `key.key` inside. Secure the private key.
-
-```bash
-sudo mkdir -p /etc/ssl/cloudflare/f2l.thunder.com
-# Move/copy your cert.pem and key.key files into this directory
-sudo chmod 600 /etc/ssl/cloudflare/f2l.thunder.com/key.key
-sudo chmod 644 /etc/ssl/cloudflare/f2l.thunder.com/cert.pem
-```
-
----
-
-#### π οΈ Step 3: Create NGINX Configuration
-
-Create a new file at `/etc/nginx/sites-available/f2l.thunder.conf` and paste the following, replacing `f2l.thunder.com` and `8080` with your values.
-
-```nginx
-server {
- listen 443 ssl;
- listen [::]:443 ssl;
- server_name f2l.thunder.com;
-
- # SSL Configuration
- ssl_certificate /etc/ssl/cloudflare/f2l.thunder.com/cert.pem;
- ssl_certificate_key /etc/ssl/cloudflare/f2l.thunder.com/key.key;
-
- # Basic security
- add_header X-Frame-Options DENY;
- add_header X-Content-Type-Options nosniff;
-
- location / {
- # Forward requests to your bot
- proxy_pass http://localhost:8080;
- proxy_set_header Host $host;
- proxy_set_header X-Real-IP $remote_addr;
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header X-Forwarded-Proto $scheme;
-
- # Settings for file streaming
- proxy_buffering off;
- proxy_request_buffering off;
- client_max_body_size 0;
- }
-}
-
-# Redirect HTTP to HTTPS
-server {
- listen 80;
- listen [::]:80;
- server_name f2l.thunder.com;
- return 301 https://$host$request_uri;
-}
-```
-
----
-
-#### π Step 4: Test and Apply Changes
-
-Enable the configuration, test it, and reload NGINX.
-
-```bash
-sudo ln -s /etc/nginx/sites-available/f2l.thunder.conf /etc/nginx/sites-enabled/
-sudo nginx -t
-sudo systemctl reload nginx
-```
-
-Your reverse proxy is now securely streaming files behind Cloudflare!
-
-
-
-## Support & Community
-
-### Troubleshooting & FAQ
-
-#### **Initial Setup**
-
-**Q: Why isn't my bot responding after setup?**
-A: This is usually a configuration issue. Please check the following:
-
-1. **Verify `config.env`**: Make sure all essential variables (`API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `DATABASE_URL`) are filled in correctly.
-2. **Use `config.env` Only**: Do not edit `vars.py` or `config_sample.env`. The bot is designed to only read your settings from `config.env`.
-3. **Check Logs**: Review the console logs on your server or hosting platform (Koyeb, Render, Heroku) for any startup errors.
-
-**Q: What do I use for the `FQDN` variable?**
-A: It's the public URL or IP address of your bot.
-
-- **With a Domain**: Use your subdomain (e.g., `f2l.thunder.com`).
-- **On Koyeb/Render/Heroku**: Use the public URL provided by the platform.
-- **On a VPS**: Use your server's public IP address.
-
-**Q: Why are my links not working on a VPS?**
-A: For links to work on a VPS, the URL must include the port number (e.g., `http://YOUR_VPS_IP:8080`). Ensure that `NO_PORT` is set to `False` in `config.env` and that your server is configured to allow traffic through that port.
-
-#### **Common Errors**
-
-**Q: Why are my links showing a "Resource Not Found" error or not working?**
-A: This error means the bot can't access the file. Check these three things:
-
-1. **Invalid Token**: Your `BOT_TOKEN` or one of the `MULTI_TOKEN`s might be wrong. Double-check them with @BotFather.
-2. **Missing Admin Rights**: The bot and **all** your client accounts must be **administrators** in the `BIN_CHANNEL`.
-3. **File Deleted**: The link will break if the file was deleted from your `BIN_CHANNEL`.
-
-**Q: Why isn't video or audio playing correctly in my browser?**
-A: Your browser likely doesn't support the file's audio or video format (codec). This is a browser limitation, not a bot issue.
-
-- **Solution**: For perfect playback, copy the link and play it in a dedicated media player. Recommended players include **VLC Media Player**, **MX Player**, **PotPlayer**, **IINA**, and **MPV**.
-
-**Q: Why does the bot sometimes become unresponsive?**
-A: This is likely a **Telegram Flood Wait**. To prevent spam, Telegram temporarily limits accounts that make too many requests. The bot is designed to handle this automatically by pausing and will resume on its own once the limit is lifted.
-
-#### **Performance**
-
-**Q: How can I fix slow download and streaming speeds?**
-A: If your speeds are slow, hereβs how to fix it:
-
-- **Add More Clients**: This is the best solution. Add `MULTI_TOKEN`s to your `config.env` to distribute the workload and increase throughput.
-- **Use DC4 Accounts**: For top performance, use Telegram accounts from **Data Center 4 (DC4)**, as they often have the fastest connection. Use `/dc` to check an account's data center.
-- **Upgrade Your Server**: A server with a slow network will bottleneck your speeds. Consider upgrading your VPS plan.
-
-#### **Bot Usage**
-
-**Q: How do I generate links for multiple files at once?**
-A: The `/link` command can process multiple files sent in sequence. To use it, **reply to the first file** of the series with the command and the total count.
-
-- **Example**: For a series of 5 files, reply to the very first file with `/link 5`.
-
-**Q: Can I mix tokens from different accounts and data centers?**
-A: Yes. Mixing clients from different accounts and data centers (like DC1, DC4, and DC5) is a great way to improve bot performance and reliability.
-
-### Contributing
-
-Contributions are welcome! Please follow these steps:
-
-1. Fork the repository.
-2. Create a new feature branch (`git checkout -b feature/amazing-feature`).
-3. Commit your changes (`git commit -m 'Add some amazing feature'`).
-4. Push to the branch (`git push origin feature/amazing-feature`).
-5. Open a Pull Request.
-
-## License
-
-Licensed under the [Apache License 2.0](LICENSE). See the `LICENSE` file for details.
-
-## Acknowledgments
-
-- [Pyrofork](https://github.com/Mayuri-Chan/pyrofork) - Telegram MTProto API Framework
-- [aiohttp](https://github.com/aio-libs/aiohttp) - Asynchronous HTTP Client/Server
-- [PyMongo](https://github.com/mongodb/mongo-python-driver) - Asynchronous MongoDB Driver
-- [TgCrypto](https://github.com/pyrogram/tgcrypto) - High-performance cryptography library
-
-## β οΈ Disclaimer
-
-This project is not affiliated with Telegram. Use it responsibly and in compliance with Telegram's Terms of Service and all applicable local regulations.
-
----
-
-
+ High-Performance Telegram File-to-Link Bot for Direct Links & Streaming
+
+
+
+
+
+
+
+
+
+
+
+## π Table of Contents
+
+- [About The Project](#about-the-project)
+- [How It Works](#how-it-works)
+- [Features](#features)
+- [Configuration](#configuration)
+ - [Essential Configuration](#essential-configuration)
+ - [Optional Configuration](#optional-configuration)
+- [Usage and Commands](#usage-and-commands)
+ - [Basic Usage](#basic-usage)
+ - [Commands Reference](#commands-reference)
+- [Advanced Feature Setup](#advanced-feature-setup)
+ - [Token System](#token-system)
+ - [URL Shortening](#url-shortening)
+ - [Rate Limiting System](#rate-limiting-system)
+ - [Network Speed Testing](#network-speed-testing)
+- [Deployment Guide](#deployment-guide)
+ - [Prerequisites](#prerequisites)
+ - [Installation](#installation)
+ - [Quick Deploy](#quick-deploy)
+ - [Deploy to Koyeb](#deploy-to-koyeb)
+ - [Deploy to Render](#deploy-to-render)
+ - [Deploy to Railway](#deploy-to-railway)
+ - [Deploy to Heroku](#deploy-to-heroku)
+ - [Reverse Proxy Setup](#reverse-proxy-setup)
+- [Support & Community](#support--community)
+ - [Troubleshooting & FAQ](#troubleshooting--faq)
+ - [Contributing](#contributing)
+- [License](#license)
+- [Acknowledgments](#acknowledgments)
+
+
+
+## About The Project
+
+**Thunder** is a powerful Telegram bot that transforms Telegram files into high-speed direct links, perfect for both streaming and rapid downloading. Share files via HTTP(S) links without needing to download them from the Telegram client first.
+
+### π‘ Perfect For
+
+- π Bypassing Telegram's built-in download speed limits
+- βοΈ Unlimited cloud storage with fast streaming and download links
+- π¬ Content creators sharing media files
+- π₯ Communities distributing resources
+- π Educational platforms sharing materials
+
+## How It Works
+
+```
+User Uploads File β Telegram Bot β Forwards to Channel β Generates Direct Link β Direct Download / Streaming
+```
+
+1. **Upload** β User sends any file to the bot.
+2. **Store** β The bot forwards the file to your private storage channel (`BIN_CHANNEL`), where it is permanently saved to generate the link.
+3. **Generate** β A unique, permanent link is created.
+4. **Stream/Download** β Anyone with the link can stream or download the file directly in their browser.
+5. **Balance** β Multi-client support distributes the load for high availability.
+
+## Features
+
+#### Core Functionality
+
+- β **Direct Link Generation** - Convert any Telegram file into a direct HTTP(S) link.
+- β **Permanent Links** - Links remain active as long as the file exists in the storage channel.
+- β **Browser Streaming & Downloading** - Stream media directly or download files at high speed without a Telegram client.
+- β **All File Types** - Supports video, audio, documents, images, and any other file format.
+- β **Batch Processing** - Generate links for multiple files at once with a single command.
+
+#### Performance & Scalability
+
+- β **Multi-Client Support** - Distributes traffic across multiple Telegram bots to avoid limits and increase throughput.
+- β **Async Architecture** - Built with `aiohttp` and `asyncio` for non-blocking, high-performance operations.
+- β **MongoDB Integration** - Ensures persistent and reliable data storage.
+
+#### Security & Control
+
+- π **Token Authentication** - Secure user access with a time-limited token system.
+- π‘οΈ **Admin Controls** - Full suite of commands for user and bot management.
+- π€ **User Authentication** - Require users to join a specific channel before they can use the bot.
+- β **Channel/Group Support** - Fully functional in private chats, groups, and channels.
+
+#### Customization
+
+- π **Custom Domain** - Serve files from your own domain for a professional look.
+- π **URL Shortening** - Integrate with URL shortener services for clean, shareable links.
+- π¨ **Custom Templates** - Personalize messages sent by the bot to match your brand.
+- π **Media Info Display** - Shows file size, duration, and format details in the response message.
+
+## Configuration
+
+Copy `config_sample.env` to `config.env` and fill in your values.
+
+### Essential Configuration
+
+| Variable | Description | Example |
+| :--- | :--- | :--- |
+| `API_ID` | Telegram API ID | `12345678` |
+| `API_HASH` | Telegram API Hash | `abc123def456` |
+| `BOT_TOKEN` | Bot token from @BotFather | `123456:ABCdefGHI` |
+| `BIN_CHANNEL` | Storage channel ID | `-1001234567890` |
+| `OWNER_ID` | Owner user ID | `12345678` |
+| `DATABASE_URL` | MongoDB connection | `mongodb+srv://...` |
+| `FQDN` | Domain/IP address | `f2l.thunder.com` |
+| `HAS_SSL` | HTTPS enabled | `True` or `False` |
+| `PORT` | Server port | `8080` |
+| `NO_PORT` | Hide port in URLs | `True` or `False` |
+
+### Optional Configuration
+
+
+Optional Configuration Details
+
+| Variable | Description | Default |
+| :--- | :--- | :--- |
+| `MULTI_TOKEN1` | Additional bot token 1 (use MULTI_TOKEN1, MULTI_TOKEN2, etc.) | *(empty)* |
+| `FORCE_CHANNEL_ID` | Required channel join | *(empty)* |
+| `MAX_BATCH_FILES` | Maximum files in batch processing | `50` |
+| `CHANNEL` | Allow processing messages from channels | `False` |
+| `BANNED_CHANNELS` | Blocked channel IDs | *(empty)* |
+| `SLEEP_THRESHOLD` | Client switch threshold | `300` |
+| `WORKERS` | Async workers | `8` |
+| `NAME` | Bot name | `ThunderF2L` |
+| `BIND_ADDRESS` | Bind address | `0.0.0.0` |
+| `PING_INTERVAL` | Ping interval (seconds) | `840` |
+| `TOKEN_ENABLED` | Enable tokens | `False` |
+| `SHORTEN_ENABLED` | URL shortening for tokens | `False` |
+| `SHORTEN_MEDIA_LINKS` | URL shortening for media | `False` |
+| `TOKEN_TTL_HOURS` | Token validity duration in hours | `24` |
+| `URL_SHORTENER_API_KEY` | Shortener API key | *(empty)* |
+| `URL_SHORTENER_SITE` | Shortener service | *(empty)* |
+| `SET_COMMANDS` | Auto-set bot commands | `True` |
+| `RATE_LIMIT_ENABLED` | Enable rate limiting | `False` |
+| `MAX_FILES_PER_PERIOD` | Files per window | `2` |
+| `RATE_LIMIT_PERIOD_MINUTES` | Time window | `1` |
+| `MAX_QUEUE_SIZE` | Queue size | `100` |
+| `GLOBAL_RATE_LIMIT` | Global limiting | `True` |
+| `MAX_GLOBAL_REQUESTS_PER_MINUTE` | Global limit | `4` |
+
+
+
+## Usage and Commands
+
+### Basic Usage
+
+1. **Start** β Send `/start` to the bot.
+2. **Authenticate** β Join required channels (if configured).
+3. **Upload** β Send any media file.
+4. **Receive** β Get a direct streaming and download link.
+5. **Share** β Anyone can access the file via the link.
+
+### Commands Reference
+
+#### User Commands
+
+| Command | Description |
+| :--- | :--- |
+| `/start` | Start the bot and get a welcome message. Also used for token activation. |
+| `/link` | Generates a link. For batches, **reply to the first file** of a group and specify the count. **Example:** `/link 5` will process that file and the next four. |
+| `/dc` | Get the data center (DC) of a user or file. Use `/dc id`, or reply to a file or user. |
+| `/ping` | Check if the bot is online and measure response time. |
+| `/about` | Get information about the bot. |
+| `/help` | Show help and usage instructions. |
+
+#### Admin Commands
+
+| Command | Description |
+| :--- | :--- |
+| `/status` | Check bot status, uptime, and resource usage. |
+| `/broadcast` | Send a message to all users (supports text, media, buttons). |
+| `/stats` | View usage statistics and analytics. |
+| `/ban` | Ban a user or channel (reply to message or use user/channel ID). |
+| `/unban` | Unban a user or channel. |
+| `/log` | Send bot logs. |
+| `/restart` | Restart the bot. |
+| `/shell` | Execute a shell command. |
+| `/speedtest` | Run network speed test and display comprehensive results. |
+| `/users` | Show total number of users. |
+| `/authorize` | Permanently authorize a user to use the bot (bypasses token system). |
+| `/deauthorize` | Remove permanent authorization from a user. |
+| `/listauth` | List all permanently authorized users. |
+
+
+
BotFather Commands Setup
+
+```text
+start - Initialize bot
+link - Generate direct link
+dc - Get data center info
+ping - Check bot status
+about - Bot information
+help - Show help guide
+status - [Admin] System status
+stats - [Admin] Usage statistics
+broadcast - [Admin] Message all users
+ban - [Admin] Ban user
+unban - [Admin] Unban user
+users - [Admin] User count
+authorize - [Admin] Grant access
+deauthorize - [Admin] Revoke access
+listauth - [Admin] List authorized
+log - [Admin] Send bot logs
+restart - [Admin] Restart the bot
+shell - [Admin] Execute shell command
+speedtest - [Admin] Run network speed test
+```
+
+
+
+## Advanced Feature Setup
+
+### Token System
+
+Enable controlled access with tokens:
+
+1. Set `TOKEN_ENABLED=True` in your `config.env`.
+2. Users receive automatic tokens on first use.
+3. Admins can grant permanent authorization with `/authorize` to bypass tokens.
+4. Tokens include activation links for secure access.
+
+### URL Shortening
+
+Configure URL shortening for cleaner links:
+
+```env
+SHORTEN_ENABLED=True
+SHORTEN_MEDIA_LINKS=True
+URL_SHORTENER_API_KEY=your_api_key
+URL_SHORTENER_SITE=shortener.example.com
+```
+
+### Rate Limiting System
+
+Thunder implements a sophisticated multi-tier rate limiting system designed for high-performance file sharing:
+
+#### **Priority Queue Architecture**
+
+- **Owner Priority**: Complete bypass of all rate limits.
+- **Authorized Users**: Dedicated priority queue with faster processing.
+- **Regular Users**: Standard queue with fair scheduling.
+
+#### **Multi-Level Rate Limiting**
+
+- **Per-User Limits**: Configurable files per time window.
+- **Global Limits**: System-wide request throttling.
+- **Sliding Window**: Time-based rate limiting with automatic cleanup.
+
+#### **Smart Queue Management**
+
+- **Automatic Re-queuing**: Failed requests due to rate limits are intelligently re-queued.
+- **Queue Size Limits**: Configurable maximum queue size.
+- **Flood Protection**: Built-in protection against Telegram flood waits.
+
+### Network Speed Testing
+
+Monitor server performance with built-in speed testing:
+
+```bash
+/speedtest
+```
+
+Features include download/upload speeds, latency measurements, and shareable result images for performance monitoring.
+
+## Deployment Guide
+
+This section covers the complete setup process for deploying Thunder, from prerequisites to production deployment.
+
+### Prerequisites
+
+| Requirement | Description | Source |
+| :--- | :--- | :--- |
+| Python 3.13 | Programming language | [python.org](https://python.org) |
+| MongoDB | Database | [mongodb.com](https://mongodb.com) |
+| Telegram API | API credentials | [my.telegram.org](https://my.telegram.org/apps) |
+| Bot Token | From @BotFather | [@BotFather](https://t.me/BotFather) |
+| Public Server | VPS/Dedicated server | Any provider |
+| Storage Channel | For file storage | Create in Telegram |
+
+### Installation
+
+#### Docker Installation (Recommended)
+
+```bash
+# 1. Clone repository
+git clone https://github.com/fyaz05/FileToLink.git
+cd FileToLink
+
+# 2. Configure
+cp config_sample.env config.env
+nano config.env # Edit your settings
+
+# 3. Build and run
+docker build -t thunder .
+docker run -d --name thunder -p 8080:8080 thunder
+```
+
+
+Manual Installation
+
+```bash
+# 1. Clone repository
+git clone https://github.com/fyaz05/FileToLink.git
+cd FileToLink
+
+# 2. Setup virtual environment
+python3 -m venv venv
+source venv/bin/activate # Windows: venv\Scripts\activate
+
+# 3. Install dependencies
+pip install -r requirements.txt
+
+# 4. Configure
+cp config_sample.env config.env
+nano config.env
+
+# 5. Run bot
+python -m Thunder
+```
+
+> **Tip:** Start with the essential configuration to get Thunder running, then add optional features as needed.
+
+
+
+## Quick Deploy
+
+### Deploy to Koyeb
+
+[](https://app.koyeb.com/deploy?type=docker&image=docker.io/fyaz05/thunder:latest&name=thunder&ports=8080;http;/&env[API_ID]=&env[API_HASH]=&env[BOT_TOKEN]=&env[BIN_CHANNEL]=&env[OWNER_ID]=&env[DATABASE_URL]=&env[FQDN]=)
+
+After deployment, to add any additional environment variables, use the Koyeb dashboard under **Settings** β **Environment Variables**.
+
+### Deploy to Render
+
+1. Open [Render Dashboard](https://dashboard.render.com) β **New** β **Web Service**
+2. Choose **Existing Image**: `fyaz05/thunder:latest`
+3. Add your environment variables
+4. Click **Deploy**
+
+### Deploy to Railway
+
+1. Open [Railway](https://railway.app) β **New Project** β **Deploy Service**
+2. Choose **Docker Image**: `fyaz05/thunder:latest`
+3. Add your environment variables
+4. Click **Deploy**
+
+### Deploy to Heroku
+
+1. Install the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) and login.
+2. Create an app in the EU region (required for GDPR compliance):
+ ```bash
+ heroku create your-app-name --region eu
+ heroku stack:set container
+ ```
+3. Set your config vars:
+ ```bash
+ heroku config:set API_ID="your_id" API_HASH="your_hash" BOT_TOKEN="your_token" \
+ BIN_CHANNEL="-100xxx" OWNER_ID="your_id" FQDN="your-app-name.herokuapp.com" \
+ HAS_SSL="True" NO_PORT="True" PORT="8080"
+ ```
+4. Set `DATABASE_URL` via the Heroku Dashboard or API (ampersand in MongoDB URL causes shell issues).
+5. Deploy via source upload (Heroku does not support direct git push with API key auth):
+ ```bash
+ # Create source tarball
+ tar -czf source.tar.gz --exclude='.git' --exclude='__pycache__' .
+ # Upload via Heroku Builds API β see devcenter.heroku.com/articles/build-and-release-using-the-api
+ ```
+6. Scale the dyno:
+ ```bash
+ heroku ps:scale web=1
+ ```
+7. Set `UPSTREAM_REPO` for auto-updates on dyno restart:
+ ```bash
+ heroku config:set UPSTREAM_REPO="https://github.com/fyaz05/FileToLink" UPSTREAM_BRANCH="main"
+ ```
+
+> **Note:** Heroku provides HTTPS automatically. Set `FQDN` to `your-app-name.herokuapp.com` and `HAS_SSL` to `True`.
+
+> **Note:** See the [Configuration](#configuration) section for required environment variables.
+
+## Reverse Proxy Setup
+
+
+Reverse Proxy Guide
+
+This guide will help you set up a secure reverse proxy using **NGINX** for your file streaming bot with **Cloudflare SSL protection**.
+
+---
+
+#### β What You Need
+
+- A **VPS or server** running Ubuntu/Debian with NGINX installed.
+- Your **file streaming bot** running on a local port (e.g., `8080`).
+- A **subdomain** (e.g., `f2l.thunder.com`) set up in **Cloudflare**.
+- **Cloudflare Origin Certificate** files: `cert.pem` and `key.key`.
+
+---
+
+#### π Step 1: Configure Cloudflare
+
+- **DNS**: Add an `A` record for your subdomain pointing to your server's IP. Ensure **Proxy Status** is **Proxied (orange cloud)**.
+- **SSL**: In the **SSL/TLS** tab, set the encryption mode to **Full (strict)**.
+
+---
+
+#### π‘οΈ Step 2: Set Up SSL Certificates on Server
+
+Create a folder for your certificates and place `cert.pem` and `key.key` inside. Secure the private key.
+
+```bash
+sudo mkdir -p /etc/ssl/cloudflare/f2l.thunder.com
+# Move/copy your cert.pem and key.key files into this directory
+sudo chmod 600 /etc/ssl/cloudflare/f2l.thunder.com/key.key
+sudo chmod 644 /etc/ssl/cloudflare/f2l.thunder.com/cert.pem
+```
+
+---
+
+#### π οΈ Step 3: Create NGINX Configuration
+
+Create a new file at `/etc/nginx/sites-available/f2l.thunder.conf` and paste the following, replacing `f2l.thunder.com` and `8080` with your values.
+
+```nginx
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ server_name f2l.thunder.com;
+
+ # SSL Configuration
+ ssl_certificate /etc/ssl/cloudflare/f2l.thunder.com/cert.pem;
+ ssl_certificate_key /etc/ssl/cloudflare/f2l.thunder.com/key.key;
+
+ # Basic security
+ add_header X-Frame-Options DENY;
+ add_header X-Content-Type-Options nosniff;
+
+ location / {
+ # Forward requests to your bot
+ proxy_pass http://localhost:8080;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # Settings for file streaming
+ proxy_buffering off;
+ proxy_request_buffering off;
+ client_max_body_size 0;
+ }
+}
+
+# Redirect HTTP to HTTPS
+server {
+ listen 80;
+ listen [::]:80;
+ server_name f2l.thunder.com;
+ return 301 https://$host$request_uri;
+}
+```
+
+---
+
+#### π Step 4: Test and Apply Changes
+
+Enable the configuration, test it, and reload NGINX.
+
+```bash
+sudo ln -s /etc/nginx/sites-available/f2l.thunder.conf /etc/nginx/sites-enabled/
+sudo nginx -t
+sudo systemctl reload nginx
+```
+
+Your reverse proxy is now securely streaming files behind Cloudflare!
+
+
+
+## Support & Community
+
+### Troubleshooting & FAQ
+
+#### **Initial Setup**
+
+**Q: Why isn't my bot responding after setup?**
+A: This is usually a configuration issue. Please check the following:
+
+1. **Verify `config.env`**: Make sure all essential variables (`API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `DATABASE_URL`) are filled in correctly.
+2. **Use `config.env` Only**: Do not edit `vars.py` or `config_sample.env`. The bot is designed to only read your settings from `config.env`.
+3. **Check Logs**: Review the console logs on your server or hosting platform (Koyeb, Render, Heroku) for any startup errors.
+
+**Q: What do I use for the `FQDN` variable?**
+A: It's the public URL or IP address of your bot.
+
+- **With a Domain**: Use your subdomain (e.g., `f2l.thunder.com`).
+- **On Koyeb/Render/Heroku**: Use the public URL provided by the platform.
+- **On a VPS**: Use your server's public IP address.
+
+**Q: Why are my links not working on a VPS?**
+A: For links to work on a VPS, the URL must include the port number (e.g., `http://YOUR_VPS_IP:8080`). Ensure that `NO_PORT` is set to `False` in `config.env` and that your server is configured to allow traffic through that port.
+
+#### **Common Errors**
+
+**Q: Why are my links showing a "Resource Not Found" error or not working?**
+A: This error means the bot can't access the file. Check these three things:
+
+1. **Invalid Token**: Your `BOT_TOKEN` or one of the `MULTI_TOKEN`s might be wrong. Double-check them with @BotFather.
+2. **Missing Admin Rights**: The bot and **all** your client accounts must be **administrators** in the `BIN_CHANNEL`.
+3. **File Deleted**: The link will break if the file was deleted from your `BIN_CHANNEL`.
+
+**Q: Why isn't video or audio playing correctly in my browser?**
+A: Your browser likely doesn't support the file's audio or video format (codec). This is a browser limitation, not a bot issue.
+
+- **Solution**: For perfect playback, copy the link and play it in a dedicated media player. Recommended players include **VLC Media Player**, **MX Player**, **PotPlayer**, **IINA**, and **MPV**.
+
+**Q: Why does the bot sometimes become unresponsive?**
+A: This is likely a **Telegram Flood Wait**. To prevent spam, Telegram temporarily limits accounts that make too many requests. The bot is designed to handle this automatically by pausing and will resume on its own once the limit is lifted.
+
+#### **Performance**
+
+**Q: How can I fix slow download and streaming speeds?**
+A: If your speeds are slow, hereβs how to fix it:
+
+- **Add More Clients**: This is the best solution. Add `MULTI_TOKEN`s to your `config.env` to distribute the workload and increase throughput.
+- **Use DC4 Accounts**: For top performance, use Telegram accounts from **Data Center 4 (DC4)**, as they often have the fastest connection. Use `/dc` to check an account's data center.
+- **Upgrade Your Server**: A server with a slow network will bottleneck your speeds. Consider upgrading your VPS plan.
+
+#### **Bot Usage**
+
+**Q: How do I generate links for multiple files at once?**
+A: The `/link` command can process multiple files sent in sequence. To use it, **reply to the first file** of the series with the command and the total count.
+
+- **Example**: For a series of 5 files, reply to the very first file with `/link 5`.
+
+**Q: Can I mix tokens from different accounts and data centers?**
+A: Yes. Mixing clients from different accounts and data centers (like DC1, DC4, and DC5) is a great way to improve bot performance and reliability.
+
+### Contributing
+
+Contributions are welcome! Please follow these steps:
+
+1. Fork the repository.
+2. Create a new feature branch (`git checkout -b feature/amazing-feature`).
+3. Commit your changes (`git commit -m 'Add some amazing feature'`).
+4. Push to the branch (`git push origin feature/amazing-feature`).
+5. Open a Pull Request.
+
+## License
+
+Licensed under the [Apache License 2.0](LICENSE). See the `LICENSE` file for details.
+
+## Acknowledgments
+
+- [Pyrofork](https://github.com/Mayuri-Chan/pyrofork) - Telegram MTProto API Framework
+- [aiohttp](https://github.com/aio-libs/aiohttp) - Asynchronous HTTP Client/Server
+- [PyMongo](https://github.com/mongodb/mongo-python-driver) - Asynchronous MongoDB Driver
+- [TgCrypto](https://github.com/pyrogram/tgcrypto) - High-performance cryptography library
+
+## β οΈ Disclaimer
+
+This project is not affiliated with Telegram. Use it responsibly and in compliance with Telegram's Terms of Service and all applicable local regulations.
+
+---
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py
old mode 100644
new mode 100755
index 10bf3c0..4403f03
--- a/Thunder/utils/bot_utils.py
+++ b/Thunder/utils/bot_utils.py
@@ -1,21 +1,21 @@
-# Thunder/utils/bot_utils.py
-
+# Thunder/utils/bot_utils.py
+
import asyncio
from typing import Any, Dict, Optional
from urllib.parse import quote
-
-from pyrogram import Client
-from pyrogram.enums import ChatMemberStatus
-from pyrogram.errors import FloodWait
-from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup,
- Message, User)
-
-from Thunder.utils.database import db
-from Thunder.utils.file_properties import get_fname, get_fsize, get_hash
-from Thunder.utils.human_readable import humanbytes
-from Thunder.utils.logger import logger
-from Thunder.utils.messages import (MSG_BUTTON_GET_HELP, MSG_DC_UNKNOWN,
- MSG_DC_USER_INFO, MSG_NEW_USER)
+
+from pyrogram import Client
+from pyrogram.enums import ChatMemberStatus
+from pyrogram.errors import FloodWait
+from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup,
+ Message, User)
+
+from Thunder.utils.database import db
+from Thunder.utils.file_properties import get_fname, get_fsize, get_hash
+from Thunder.utils.human_readable import humanbytes
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import (MSG_BUTTON_GET_HELP, MSG_DC_UNKNOWN,
+ MSG_DC_USER_INFO, MSG_NEW_USER)
from Thunder.utils.shortener import shorten
from Thunder.vars import Var
@@ -74,62 +74,62 @@ async def gen_canonical_links(
async def notify_ch(cli: Client, txt: str):
- if not (hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0):
- return
- try:
- await cli.send_message(chat_id=Var.BIN_CHANNEL, text=txt)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await cli.send_message(chat_id=Var.BIN_CHANNEL, text=txt)
-
-
-async def notify_own(cli: Client, txt: str):
- o_ids = Var.OWNER_ID if isinstance(Var.OWNER_ID, (list, tuple, set)) else [Var.OWNER_ID]
-
- async def send_with_flood_wait(chat_id: int):
- try:
- await cli.send_message(chat_id=chat_id, text=txt)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await cli.send_message(chat_id=chat_id, text=txt)
-
- tasks = [send_with_flood_wait(oid) for oid in o_ids]
- if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
- tasks.append(send_with_flood_wait(Var.BIN_CHANNEL))
- await asyncio.gather(*tasks, return_exceptions=True)
-
-
-async def reply_user_err(msg: Message, err_txt: str):
- try:
- await msg.reply_text(
- text=err_txt,
- reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]]),
- disable_web_page_preview=True
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await msg.reply_text(
- text=err_txt,
- reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]]),
- disable_web_page_preview=True
- )
-
-
-async def log_newusr(cli: Client, uid: int, fname: str):
- try:
- is_new = await db.add_user(uid)
- if not is_new:
- return
- if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
- try:
- await cli.send_message(chat_id=Var.BIN_CHANNEL, text=MSG_NEW_USER.format(first_name=fname, user_id=uid))
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await cli.send_message(chat_id=Var.BIN_CHANNEL, text=MSG_NEW_USER.format(first_name=fname, user_id=uid))
- except Exception as e:
- logger.error(f"Database error in log_newusr for user {uid}: {e}")
-
-
+ if not (hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0):
+ return
+ try:
+ await cli.send_message(chat_id=Var.BIN_CHANNEL, text=txt)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await cli.send_message(chat_id=Var.BIN_CHANNEL, text=txt)
+
+
+async def notify_own(cli: Client, txt: str):
+ o_ids = Var.OWNER_ID if isinstance(Var.OWNER_ID, (list, tuple, set)) else [Var.OWNER_ID]
+
+ async def send_with_flood_wait(chat_id: int):
+ try:
+ await cli.send_message(chat_id=chat_id, text=txt)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await cli.send_message(chat_id=chat_id, text=txt)
+
+ tasks = [send_with_flood_wait(oid) for oid in o_ids]
+ if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
+ tasks.append(send_with_flood_wait(Var.BIN_CHANNEL))
+ await asyncio.gather(*tasks, return_exceptions=True)
+
+
+async def reply_user_err(msg: Message, err_txt: str):
+ try:
+ await msg.reply_text(
+ text=err_txt,
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]]),
+ disable_web_page_preview=True
+ )
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await msg.reply_text(
+ text=err_txt,
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]]),
+ disable_web_page_preview=True
+ )
+
+
+async def log_newusr(cli: Client, uid: int, fname: str):
+ try:
+ is_new = await db.add_user(uid)
+ if not is_new:
+ return
+ if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
+ try:
+ await cli.send_message(chat_id=Var.BIN_CHANNEL, text=MSG_NEW_USER.format(first_name=fname, user_id=uid))
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await cli.send_message(chat_id=Var.BIN_CHANNEL, text=MSG_NEW_USER.format(first_name=fname, user_id=uid))
+ except Exception as e:
+ logger.error(f"Database error in log_newusr for user {uid}: {e}")
+
+
async def gen_links(fwd_msg: Message, shortener: bool = True) -> Dict[str, str]:
fid = fwd_msg.id
m_name_raw = get_fname(fwd_msg)
@@ -144,55 +144,55 @@ async def gen_links(fwd_msg: Message, shortener: bool = True) -> Dict[str, str]:
media_size=m_size_hr,
shortener=shortener
)
-
-
-async def gen_dc_txt(usr: User) -> str:
- dc_id_val = usr.dc_id if usr.dc_id is not None else MSG_DC_UNKNOWN
- return MSG_DC_USER_INFO.format(user_name=usr.first_name or 'User', user_id=usr.id, dc_id=dc_id_val)
-
-
-async def get_user(cli: Client, qry: Any) -> Optional[User]:
- if isinstance(qry, str):
- if qry.startswith('@'):
- try:
- return await cli.get_users(qry)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await cli.get_users(qry)
- elif qry.isdigit():
- try:
- return await cli.get_users(int(qry))
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await cli.get_users(int(qry))
- elif isinstance(qry, int):
- try:
- return await cli.get_users(qry)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await cli.get_users(qry)
- return None
-
-
-async def is_admin(cli: Client, chat_id_val: int) -> bool:
- try:
- member = await cli.get_chat_member(chat_id_val, cli.me.id)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- try:
- member = await cli.get_chat_member(chat_id_val, cli.me.id)
- except Exception:
- return False
- except Exception:
- return False
- if member is None:
- return False
- return member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]
-
-
-async def reply(msg: Message, **kwargs):
- try:
- return await msg.reply_text(**kwargs, quote=True, disable_web_page_preview=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- return await msg.reply_text(**kwargs, quote=True, disable_web_page_preview=True)
+
+
+async def gen_dc_txt(usr: User) -> str:
+ dc_id_val = usr.dc_id if usr.dc_id is not None else MSG_DC_UNKNOWN
+ return MSG_DC_USER_INFO.format(user_name=usr.first_name or 'User', user_id=usr.id, dc_id=dc_id_val)
+
+
+async def get_user(cli: Client, qry: Any) -> Optional[User]:
+ if isinstance(qry, str):
+ if qry.startswith('@'):
+ try:
+ return await cli.get_users(qry)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ return await cli.get_users(qry)
+ elif qry.isdigit():
+ try:
+ return await cli.get_users(int(qry))
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ return await cli.get_users(int(qry))
+ elif isinstance(qry, int):
+ try:
+ return await cli.get_users(qry)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ return await cli.get_users(qry)
+ return None
+
+
+async def is_admin(cli: Client, chat_id_val: int) -> bool:
+ try:
+ member = await cli.get_chat_member(chat_id_val, cli.me.id)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ try:
+ member = await cli.get_chat_member(chat_id_val, cli.me.id)
+ except Exception:
+ return False
+ except Exception:
+ return False
+ if member is None:
+ return False
+ return member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]
+
+
+async def reply(msg: Message, **kwargs):
+ try:
+ return await msg.reply_text(**kwargs, quote=True, disable_web_page_preview=True)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ return await msg.reply_text(**kwargs, quote=True, disable_web_page_preview=True)
diff --git a/Thunder/utils/broadcast.py b/Thunder/utils/broadcast.py
old mode 100644
new mode 100755
index 044dcd4..c15cfc8
--- a/Thunder/utils/broadcast.py
+++ b/Thunder/utils/broadcast.py
@@ -1,189 +1,189 @@
-# Thunder/utils/broadcast.py
-
-import asyncio
-import os
-import time
-
-from pyrogram.client import Client
-from pyrogram.enums import ParseMode
-from pyrogram.errors import (ChatWriteForbidden, FloodWait, PeerIdInvalid, UserDeactivated,
- UserIsBlocked, ChannelInvalid, InputUserDeactivated)
-from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup,
- Message)
-
-from Thunder.utils.database import db
-from Thunder.utils.logger import logger
-from Thunder.utils.messages import (
- MSG_INVALID_BROADCAST_CMD,
- MSG_BROADCAST_START,
- MSG_BUTTON_CANCEL_BROADCAST,
- MSG_BROADCAST_COMPLETE
-)
-from Thunder.utils.time_format import get_readable_time
-
-
-broadcast_ids = {}
-
-async def broadcast_message(client: Client, message: Message, mode: str = "all"):
- if not message.reply_to_message:
- try:
- await message.reply_text(MSG_INVALID_BROADCAST_CMD)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(MSG_INVALID_BROADCAST_CMD)
- except Exception as e:
- logger.error(f"Error sending invalid broadcast message: {e}", exc_info=True)
- return
-
- broadcast_id = os.urandom(3).hex()
- stats = {"total": 0, "success": 0, "failed": 0, "deleted": 0, "cancelled": False}
- broadcast_ids[broadcast_id] = stats
-
- try:
- status_msg = await message.reply_text(
- MSG_BROADCAST_START,
- reply_markup=InlineKeyboardMarkup([[
- InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_{broadcast_id}")
- ]])
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- status_msg = await message.reply_text(
- MSG_BROADCAST_START,
- reply_markup=InlineKeyboardMarkup([[
- InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_{broadcast_id}")
- ]])
- )
- except Exception as e:
- logger.error(f"Error starting broadcast: {e}", exc_info=True)
- del broadcast_ids[broadcast_id]
- return
-
- start_time = time.time()
-
- try:
- if mode == "authorized":
- stats["total"] = await db.get_authorized_users_count()
- cursor = await db.get_authorized_users_cursor()
- elif mode == "regular":
- stats["total"] = await db.get_regular_users_count()
- cursor = await db.get_regular_users_cursor()
- else:
- stats["total"] = await db.total_users_count()
- cursor = await db.get_all_users()
- except Exception as e:
- logger.error(f"Error getting user cursor for mode '{mode}': {e}", exc_info=True)
- try:
- await status_msg.edit_text(f"β **Broadcast Failed:** Unable to fetch users for mode '{mode}'.")
- except Exception:
- pass
- del broadcast_ids[broadcast_id]
- return
-
- if stats["total"] == 0:
- try:
- await status_msg.edit_text(f"βΉοΈ **No users found for broadcast mode:** `{mode}`")
- except Exception:
- pass
- del broadcast_ids[broadcast_id]
- return
-
- async def do_broadcast():
- async for user in cursor:
- if stats["cancelled"]:
- break
-
- user_id = user.get('id') or user.get('user_id')
- if not user_id:
- logger.warning(f"Skipping user with no ID: {user}")
- continue
-
- try:
- success = False
- for attempt in range(3):
- try:
- await message.reply_to_message.copy(user_id)
- stats["success"] += 1
- success = True
- break
- except FloodWait as e:
- if attempt < 2:
- await asyncio.sleep(e.value)
- else:
- logger.warning(f"FloodWait persisted for user {user_id} after 3 attempts, last wait: {e.value}s")
- stats["failed"] += 1
- break
-
- except (UserDeactivated, UserIsBlocked, PeerIdInvalid, ChatWriteForbidden, ChannelInvalid, InputUserDeactivated) as e:
- if isinstance(e, ChannelInvalid):
- recipient_type = "Channel"
- reason = "invalid channel"
- elif isinstance(e, InputUserDeactivated):
- recipient_type = "User"
- reason = "deactivated account"
- elif isinstance(e, UserIsBlocked):
- recipient_type = "User"
- reason = "blocked the bot"
- elif isinstance(e, UserDeactivated):
- recipient_type = "User"
- reason = "deactivated account"
- elif isinstance(e, PeerIdInvalid):
- recipient_type = "Recipient"
- reason = "invalid ID"
- elif isinstance(e, ChatWriteForbidden):
- recipient_type = "Chat"
- reason = "write forbidden"
- else:
- recipient_type = "Recipient"
- reason = f"error: {type(e).__name__}"
-
- logger.warning(f"{recipient_type} {user_id} removed due to {reason}")
-
- is_authorized = await db.is_user_authorized(user_id)
- if not is_authorized:
- await db.delete_user(user_id)
- stats["deleted"] += 1
- else:
- stats["failed"] += 1
-
- except Exception as e:
- logger.error(f"Error copying message to user {user_id}: {e}", exc_info=True)
- stats["failed"] += 1
-
- try:
- await status_msg.delete()
- except FloodWait as e:
- await asyncio.sleep(e.value)
- try:
- await status_msg.delete()
- except Exception:
- pass
- except Exception as e:
- logger.debug(f"Could not delete status message: {e}")
-
- completion_msg = MSG_BROADCAST_COMPLETE.format(
- elapsed_time=get_readable_time(int(time.time() - start_time)),
- total_users=stats["total"],
- successes=stats["success"],
- failures=stats["failed"],
- deleted_accounts=stats["deleted"]
- )
-
- if stats["cancelled"]:
- completion_msg = "π **Broadcast Cancelled**\n\n" + completion_msg
-
- try:
- await message.reply_text(completion_msg, parse_mode=ParseMode.MARKDOWN)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- try:
- await message.reply_text(completion_msg, parse_mode=ParseMode.MARKDOWN)
- except Exception as e:
- logger.error(f"Failed to send completion message after FloodWait: {e}", exc_info=True)
- except Exception as e:
- logger.error(f"Failed to send broadcast completion message: {e}", exc_info=True)
-
- if broadcast_id in broadcast_ids:
- del broadcast_ids[broadcast_id]
-
- asyncio.create_task(do_broadcast())
+# Thunder/utils/broadcast.py
+
+import asyncio
+import os
+import time
+
+from pyrogram.client import Client
+from pyrogram.enums import ParseMode
+from pyrogram.errors import (ChatWriteForbidden, FloodWait, PeerIdInvalid, UserDeactivated,
+ UserIsBlocked, ChannelInvalid, InputUserDeactivated)
+from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup,
+ Message)
+
+from Thunder.utils.database import db
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import (
+ MSG_INVALID_BROADCAST_CMD,
+ MSG_BROADCAST_START,
+ MSG_BUTTON_CANCEL_BROADCAST,
+ MSG_BROADCAST_COMPLETE
+)
+from Thunder.utils.time_format import get_readable_time
+
+
+broadcast_ids = {}
+
+async def broadcast_message(client: Client, message: Message, mode: str = "all"):
+ if not message.reply_to_message:
+ try:
+ await message.reply_text(MSG_INVALID_BROADCAST_CMD)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text(MSG_INVALID_BROADCAST_CMD)
+ except Exception as e:
+ logger.error(f"Error sending invalid broadcast message: {e}", exc_info=True)
+ return
+
+ broadcast_id = os.urandom(3).hex()
+ stats = {"total": 0, "success": 0, "failed": 0, "deleted": 0, "cancelled": False}
+ broadcast_ids[broadcast_id] = stats
+
+ try:
+ status_msg = await message.reply_text(
+ MSG_BROADCAST_START,
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_{broadcast_id}")
+ ]])
+ )
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ status_msg = await message.reply_text(
+ MSG_BROADCAST_START,
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_{broadcast_id}")
+ ]])
+ )
+ except Exception as e:
+ logger.error(f"Error starting broadcast: {e}", exc_info=True)
+ del broadcast_ids[broadcast_id]
+ return
+
+ start_time = time.time()
+
+ try:
+ if mode == "authorized":
+ stats["total"] = await db.get_authorized_users_count()
+ cursor = await db.get_authorized_users_cursor()
+ elif mode == "regular":
+ stats["total"] = await db.get_regular_users_count()
+ cursor = await db.get_regular_users_cursor()
+ else:
+ stats["total"] = await db.total_users_count()
+ cursor = await db.get_all_users()
+ except Exception as e:
+ logger.error(f"Error getting user cursor for mode '{mode}': {e}", exc_info=True)
+ try:
+ await status_msg.edit_text(f"β **Broadcast Failed:** Unable to fetch users for mode '{mode}'.")
+ except Exception:
+ pass
+ del broadcast_ids[broadcast_id]
+ return
+
+ if stats["total"] == 0:
+ try:
+ await status_msg.edit_text(f"βΉοΈ **No users found for broadcast mode:** `{mode}`")
+ except Exception:
+ pass
+ del broadcast_ids[broadcast_id]
+ return
+
+ async def do_broadcast():
+ async for user in cursor:
+ if stats["cancelled"]:
+ break
+
+ user_id = user.get('id') or user.get('user_id')
+ if not user_id:
+ logger.warning(f"Skipping user with no ID: {user}")
+ continue
+
+ try:
+ success = False
+ for attempt in range(3):
+ try:
+ await message.reply_to_message.copy(user_id)
+ stats["success"] += 1
+ success = True
+ break
+ except FloodWait as e:
+ if attempt < 2:
+ await asyncio.sleep(e.value)
+ else:
+ logger.warning(f"FloodWait persisted for user {user_id} after 3 attempts, last wait: {e.value}s")
+ stats["failed"] += 1
+ break
+
+ except (UserDeactivated, UserIsBlocked, PeerIdInvalid, ChatWriteForbidden, ChannelInvalid, InputUserDeactivated) as e:
+ if isinstance(e, ChannelInvalid):
+ recipient_type = "Channel"
+ reason = "invalid channel"
+ elif isinstance(e, InputUserDeactivated):
+ recipient_type = "User"
+ reason = "deactivated account"
+ elif isinstance(e, UserIsBlocked):
+ recipient_type = "User"
+ reason = "blocked the bot"
+ elif isinstance(e, UserDeactivated):
+ recipient_type = "User"
+ reason = "deactivated account"
+ elif isinstance(e, PeerIdInvalid):
+ recipient_type = "Recipient"
+ reason = "invalid ID"
+ elif isinstance(e, ChatWriteForbidden):
+ recipient_type = "Chat"
+ reason = "write forbidden"
+ else:
+ recipient_type = "Recipient"
+ reason = f"error: {type(e).__name__}"
+
+ logger.warning(f"{recipient_type} {user_id} removed due to {reason}")
+
+ is_authorized = await db.is_user_authorized(user_id)
+ if not is_authorized:
+ await db.delete_user(user_id)
+ stats["deleted"] += 1
+ else:
+ stats["failed"] += 1
+
+ except Exception as e:
+ logger.error(f"Error copying message to user {user_id}: {e}", exc_info=True)
+ stats["failed"] += 1
+
+ try:
+ await status_msg.delete()
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ try:
+ await status_msg.delete()
+ except Exception:
+ pass
+ except Exception as e:
+ logger.debug(f"Could not delete status message: {e}")
+
+ completion_msg = MSG_BROADCAST_COMPLETE.format(
+ elapsed_time=get_readable_time(int(time.time() - start_time)),
+ total_users=stats["total"],
+ successes=stats["success"],
+ failures=stats["failed"],
+ deleted_accounts=stats["deleted"]
+ )
+
+ if stats["cancelled"]:
+ completion_msg = "π **Broadcast Cancelled**\n\n" + completion_msg
+
+ try:
+ await message.reply_text(completion_msg, parse_mode=ParseMode.MARKDOWN)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ try:
+ await message.reply_text(completion_msg, parse_mode=ParseMode.MARKDOWN)
+ except Exception as e:
+ logger.error(f"Failed to send completion message after FloodWait: {e}", exc_info=True)
+ except Exception as e:
+ logger.error(f"Failed to send broadcast completion message: {e}", exc_info=True)
+
+ if broadcast_id in broadcast_ids:
+ del broadcast_ids[broadcast_id]
+
+ asyncio.create_task(do_broadcast())
diff --git a/Thunder/utils/canonical_files.py b/Thunder/utils/canonical_files.py
old mode 100644
new mode 100755
index 367171d..7ef66f5
--- a/Thunder/utils/canonical_files.py
+++ b/Thunder/utils/canonical_files.py
@@ -1,463 +1,463 @@
-import asyncio
-import datetime
-import hashlib
-from collections import OrderedDict
-from contextlib import asynccontextmanager
-from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
-
-from pyrogram.errors import FloodWait
-from pyrogram.types import Message
-from pymongo.errors import DuplicateKeyError
-
-from Thunder.bot import StreamBot
-from Thunder.utils.database import db
-from Thunder.utils.file_properties import get_fname, get_media, get_uniqid
-from Thunder.utils.logger import logger
-from Thunder.vars import Var
-
-PUBLIC_HASH_LENGTH = 20
-_CACHE_TTL_SECONDS = 600
-_CACHE_MAX_ITEMS = 4096
-_INGEST_CLAIM_TTL_SECONDS = 60
-_INGEST_CLAIM_WAIT_SECONDS = 15
-_INGEST_CLAIM_POLL_SECONDS = 0.5
-_MAX_INGEST_RETRIES = 10
-_CACHE_PRUNE_INTERVAL = 50
-
-_cache_by_unique_id: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
-_cache_by_hash: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
-_cache_by_message_id: "OrderedDict[int, Tuple[float, Dict[str, Any]]]" = OrderedDict()
-
-_upload_locks: dict[str, asyncio.Lock] = {}
-_upload_lock_counts: dict[str, int] = {}
-_upload_locks_guard = asyncio.Lock()
-_insert_counter: int = 0
-_pending_touches: Dict[str, Tuple[Dict[str, Any], bool]] = {}
-_flush_task: Optional[asyncio.Task] = None
-_FLUSH_DELAY_SECONDS = 10
-
-
-def build_public_hash(file_unique_id: str) -> str:
- return hashlib.sha256(file_unique_id.encode("utf-8")).hexdigest()[:PUBLIC_HASH_LENGTH]
-
-
-def _infer_mime_type(media: Any) -> str:
- mime_type = getattr(media, "mime_type", None)
- if mime_type:
- return mime_type
-
- mime_map = {
- "photo": "image/jpeg",
- "voice": "audio/ogg",
- "videonote": "video/mp4",
- }
- return mime_map.get(type(media).__name__.lower(), "application/octet-stream")
-
-
-def build_file_record(
- stored_message: Message,
- *,
- source_chat_id: Optional[int] = None,
- source_message_id: Optional[int] = None
-) -> Optional[Dict[str, Any]]:
- media = get_media(stored_message)
- file_unique_id = get_uniqid(stored_message)
- if not media or not file_unique_id:
- return None
-
- now = datetime.datetime.now(datetime.timezone.utc)
- return {
- "file_unique_id": file_unique_id,
- "public_hash": build_public_hash(file_unique_id),
- "canonical_message_id": stored_message.id,
- "file_id": getattr(media, "file_id", None),
- "file_name": get_fname(stored_message),
- "mime_type": _infer_mime_type(media),
- "file_size": getattr(media, "file_size", 0) or 0,
- "media_type": type(media).__name__.lower(),
- "first_source_chat_id": source_chat_id,
- "first_source_message_id": source_message_id,
- "created_at": now,
- "last_seen_at": now,
- "seen_count": 1,
- "reuse_count": 0
- }
-
-
-def _prune_cache(cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]") -> None:
- now = asyncio.get_running_loop().time()
- expired_keys = [key for key, (ts, _) in cache.items() if now - ts > _CACHE_TTL_SECONDS]
- for key in expired_keys:
- cache.pop(key, None)
- while len(cache) > _CACHE_MAX_ITEMS:
- cache.popitem(last=False)
-
-
-def _cache_get(
- cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]",
- key: Any
-) -> Optional[Dict[str, Any]]:
- if key not in cache:
- return None
- ts, value = cache[key]
- now = asyncio.get_running_loop().time()
- if now - ts > _CACHE_TTL_SECONDS:
- cache.pop(key, None)
- return None
- cache.move_to_end(key)
- return value
-
-
-def _remember(record: Dict[str, Any]) -> Dict[str, Any]:
- global _insert_counter
- now = asyncio.get_running_loop().time()
- file_unique_id = record.get("file_unique_id")
- public_hash = record.get("public_hash")
- canonical_message_id = record.get("canonical_message_id")
-
- _insert_counter += 1
- should_prune = (_insert_counter % _CACHE_PRUNE_INTERVAL == 0)
-
- if file_unique_id:
- _cache_by_unique_id[file_unique_id] = (now, record)
- _cache_by_unique_id.move_to_end(file_unique_id)
- if should_prune:
- _prune_cache(_cache_by_unique_id)
- if public_hash:
- _cache_by_hash[public_hash] = (now, record)
- _cache_by_hash.move_to_end(public_hash)
- if should_prune:
- _prune_cache(_cache_by_hash)
- if canonical_message_id is not None:
- _cache_by_message_id[canonical_message_id] = (now, record)
- _cache_by_message_id.move_to_end(canonical_message_id)
- if should_prune:
- _prune_cache(_cache_by_message_id)
- return record
-
-
-def _forget(record: Dict[str, Any]) -> None:
- file_unique_id = record.get("file_unique_id")
- public_hash = record.get("public_hash")
- canonical_message_id = record.get("canonical_message_id")
-
- if file_unique_id:
- _cache_by_unique_id.pop(file_unique_id, None)
- if public_hash:
- _cache_by_hash.pop(public_hash, None)
- if canonical_message_id is not None:
- _cache_by_message_id.pop(canonical_message_id, None)
-
-
-async def get_file_by_unique_id(file_unique_id: str) -> Optional[Dict[str, Any]]:
- cached = _cache_get(_cache_by_unique_id, file_unique_id)
- if cached:
- return cached
- record = await db.get_file_by_unique_id(file_unique_id)
- return _remember(record) if record else None
-
-
-async def get_file_by_hash(
- public_hash: str,
- *,
- raise_on_error: bool = True
-) -> Optional[Dict[str, Any]]:
- cached = _cache_get(_cache_by_hash, public_hash)
- if cached:
- return cached
- record = await db.get_file_by_hash(public_hash, raise_on_error=raise_on_error)
- return _remember(record) if record else None
-
-
-async def get_file_by_message_id(canonical_message_id: int) -> Optional[Dict[str, Any]]:
- cached = _cache_get(_cache_by_message_id, canonical_message_id)
- if cached:
- return cached
- record = await db.get_file_by_message_id(canonical_message_id)
- return _remember(record) if record else None
-
-
-async def touch_file_record(record: Dict[str, Any], *, reused: bool = False) -> None:
- if not record.get("public_hash"):
- return
- record["last_seen_at"] = datetime.datetime.now(datetime.timezone.utc)
- record["seen_count"] = int(record.get("seen_count", 0)) + 1
- if reused:
- record["reuse_count"] = int(record.get("reuse_count", 0)) + 1
- _remember(record)
- await db.touch_file_record(record["public_hash"], reused=reused, raise_on_error=True)
-
-
-async def _flush_pending_touches() -> None:
- global _flush_task
- flushed = False
- try:
- await asyncio.sleep(_FLUSH_DELAY_SECONDS)
-
- items = list(_pending_touches.items())
- _pending_touches.clear()
-
- for public_hash, (record, reused) in items:
- try:
- await db.touch_file_record(public_hash, reused=reused)
- except Exception as e:
- logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
- flushed = True
- except asyncio.CancelledError:
- pass
- finally:
- if not flushed and _pending_touches:
- items = list(_pending_touches.items())
- _pending_touches.clear()
-
- for public_hash, (record, reused) in items:
- try:
- await db.touch_file_record(public_hash, reused=reused)
- except Exception as e:
- logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
- _flush_task = None
-
-
-def schedule_touch_file_record(record: Dict[str, Any], *, reused: bool = False) -> None:
- global _flush_task
- if not record.get("public_hash"):
- return
-
- record["last_seen_at"] = datetime.datetime.now(datetime.timezone.utc)
- record["seen_count"] = int(record.get("seen_count", 0)) + 1
- if reused:
- record["reuse_count"] = int(record.get("reuse_count", 0)) + 1
- _remember(record)
-
- public_hash = record["public_hash"]
- if public_hash in _pending_touches:
- _, existing_reused = _pending_touches[public_hash]
- _pending_touches[public_hash] = (record, existing_reused or reused)
- else:
- _pending_touches[public_hash] = (record, reused)
-
- if _flush_task is None or _flush_task.done():
+import asyncio
+import datetime
+import hashlib
+from collections import OrderedDict
+from contextlib import asynccontextmanager
+from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
+
+from pyrogram.errors import FloodWait
+from pyrogram.types import Message
+from pymongo.errors import DuplicateKeyError
+
+from Thunder.bot import StreamBot
+from Thunder.utils.database import db
+from Thunder.utils.file_properties import get_fname, get_media, get_uniqid
+from Thunder.utils.logger import logger
+from Thunder.vars import Var
+
+PUBLIC_HASH_LENGTH = 20
+_CACHE_TTL_SECONDS = 600
+_CACHE_MAX_ITEMS = 4096
+_INGEST_CLAIM_TTL_SECONDS = 60
+_INGEST_CLAIM_WAIT_SECONDS = 15
+_INGEST_CLAIM_POLL_SECONDS = 0.5
+_MAX_INGEST_RETRIES = 10
+_CACHE_PRUNE_INTERVAL = 50
+
+_cache_by_unique_id: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
+_cache_by_hash: "OrderedDict[str, Tuple[float, Dict[str, Any]]]" = OrderedDict()
+_cache_by_message_id: "OrderedDict[int, Tuple[float, Dict[str, Any]]]" = OrderedDict()
+
+_upload_locks: dict[str, asyncio.Lock] = {}
+_upload_lock_counts: dict[str, int] = {}
+_upload_locks_guard = asyncio.Lock()
+_insert_counter: int = 0
+_pending_touches: Dict[str, Tuple[Dict[str, Any], bool]] = {}
+_flush_task: Optional[asyncio.Task] = None
+_FLUSH_DELAY_SECONDS = 10
+
+
+def build_public_hash(file_unique_id: str) -> str:
+ return hashlib.sha256(file_unique_id.encode("utf-8")).hexdigest()[:PUBLIC_HASH_LENGTH]
+
+
+def _infer_mime_type(media: Any) -> str:
+ mime_type = getattr(media, "mime_type", None)
+ if mime_type:
+ return mime_type
+
+ mime_map = {
+ "photo": "image/jpeg",
+ "voice": "audio/ogg",
+ "videonote": "video/mp4",
+ }
+ return mime_map.get(type(media).__name__.lower(), "application/octet-stream")
+
+
+def build_file_record(
+ stored_message: Message,
+ *,
+ source_chat_id: Optional[int] = None,
+ source_message_id: Optional[int] = None
+) -> Optional[Dict[str, Any]]:
+ media = get_media(stored_message)
+ file_unique_id = get_uniqid(stored_message)
+ if not media or not file_unique_id:
+ return None
+
+ now = datetime.datetime.now(datetime.timezone.utc)
+ return {
+ "file_unique_id": file_unique_id,
+ "public_hash": build_public_hash(file_unique_id),
+ "canonical_message_id": stored_message.id,
+ "file_id": getattr(media, "file_id", None),
+ "file_name": get_fname(stored_message),
+ "mime_type": _infer_mime_type(media),
+ "file_size": getattr(media, "file_size", 0) or 0,
+ "media_type": type(media).__name__.lower(),
+ "first_source_chat_id": source_chat_id,
+ "first_source_message_id": source_message_id,
+ "created_at": now,
+ "last_seen_at": now,
+ "seen_count": 1,
+ "reuse_count": 0
+ }
+
+
+def _prune_cache(cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]") -> None:
+ now = asyncio.get_running_loop().time()
+ expired_keys = [key for key, (ts, _) in cache.items() if now - ts > _CACHE_TTL_SECONDS]
+ for key in expired_keys:
+ cache.pop(key, None)
+ while len(cache) > _CACHE_MAX_ITEMS:
+ cache.popitem(last=False)
+
+
+def _cache_get(
+ cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]",
+ key: Any
+) -> Optional[Dict[str, Any]]:
+ if key not in cache:
+ return None
+ ts, value = cache[key]
+ now = asyncio.get_running_loop().time()
+ if now - ts > _CACHE_TTL_SECONDS:
+ cache.pop(key, None)
+ return None
+ cache.move_to_end(key)
+ return value
+
+
+def _remember(record: Dict[str, Any]) -> Dict[str, Any]:
+ global _insert_counter
+ now = asyncio.get_running_loop().time()
+ file_unique_id = record.get("file_unique_id")
+ public_hash = record.get("public_hash")
+ canonical_message_id = record.get("canonical_message_id")
+
+ _insert_counter += 1
+ should_prune = (_insert_counter % _CACHE_PRUNE_INTERVAL == 0)
+
+ if file_unique_id:
+ _cache_by_unique_id[file_unique_id] = (now, record)
+ _cache_by_unique_id.move_to_end(file_unique_id)
+ if should_prune:
+ _prune_cache(_cache_by_unique_id)
+ if public_hash:
+ _cache_by_hash[public_hash] = (now, record)
+ _cache_by_hash.move_to_end(public_hash)
+ if should_prune:
+ _prune_cache(_cache_by_hash)
+ if canonical_message_id is not None:
+ _cache_by_message_id[canonical_message_id] = (now, record)
+ _cache_by_message_id.move_to_end(canonical_message_id)
+ if should_prune:
+ _prune_cache(_cache_by_message_id)
+ return record
+
+
+def _forget(record: Dict[str, Any]) -> None:
+ file_unique_id = record.get("file_unique_id")
+ public_hash = record.get("public_hash")
+ canonical_message_id = record.get("canonical_message_id")
+
+ if file_unique_id:
+ _cache_by_unique_id.pop(file_unique_id, None)
+ if public_hash:
+ _cache_by_hash.pop(public_hash, None)
+ if canonical_message_id is not None:
+ _cache_by_message_id.pop(canonical_message_id, None)
+
+
+async def get_file_by_unique_id(file_unique_id: str) -> Optional[Dict[str, Any]]:
+ cached = _cache_get(_cache_by_unique_id, file_unique_id)
+ if cached:
+ return cached
+ record = await db.get_file_by_unique_id(file_unique_id)
+ return _remember(record) if record else None
+
+
+async def get_file_by_hash(
+ public_hash: str,
+ *,
+ raise_on_error: bool = True
+) -> Optional[Dict[str, Any]]:
+ cached = _cache_get(_cache_by_hash, public_hash)
+ if cached:
+ return cached
+ record = await db.get_file_by_hash(public_hash, raise_on_error=raise_on_error)
+ return _remember(record) if record else None
+
+
+async def get_file_by_message_id(canonical_message_id: int) -> Optional[Dict[str, Any]]:
+ cached = _cache_get(_cache_by_message_id, canonical_message_id)
+ if cached:
+ return cached
+ record = await db.get_file_by_message_id(canonical_message_id)
+ return _remember(record) if record else None
+
+
+async def touch_file_record(record: Dict[str, Any], *, reused: bool = False) -> None:
+ if not record.get("public_hash"):
+ return
+ record["last_seen_at"] = datetime.datetime.now(datetime.timezone.utc)
+ record["seen_count"] = int(record.get("seen_count", 0)) + 1
+ if reused:
+ record["reuse_count"] = int(record.get("reuse_count", 0)) + 1
+ _remember(record)
+ await db.touch_file_record(record["public_hash"], reused=reused, raise_on_error=True)
+
+
+async def _flush_pending_touches() -> None:
+ global _flush_task
+ flushed = False
+ try:
+ await asyncio.sleep(_FLUSH_DELAY_SECONDS)
+
+ items = list(_pending_touches.items())
+ _pending_touches.clear()
+
+ for public_hash, (record, reused) in items:
+ try:
+ await db.touch_file_record(public_hash, reused=reused)
+ except Exception as e:
+ logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
+ flushed = True
+ except asyncio.CancelledError:
+ pass
+ finally:
+ if not flushed and _pending_touches:
+ items = list(_pending_touches.items())
+ _pending_touches.clear()
+
+ for public_hash, (record, reused) in items:
+ try:
+ await db.touch_file_record(public_hash, reused=reused)
+ except Exception as e:
+ logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
+ _flush_task = None
+
+
+def schedule_touch_file_record(record: Dict[str, Any], *, reused: bool = False) -> None:
+ global _flush_task
+ if not record.get("public_hash"):
+ return
+
+ record["last_seen_at"] = datetime.datetime.now(datetime.timezone.utc)
+ record["seen_count"] = int(record.get("seen_count", 0)) + 1
+ if reused:
+ record["reuse_count"] = int(record.get("reuse_count", 0)) + 1
+ _remember(record)
+
+ public_hash = record["public_hash"]
+ if public_hash in _pending_touches:
+ _, existing_reused = _pending_touches[public_hash]
+ _pending_touches[public_hash] = (record, existing_reused or reused)
+ else:
+ _pending_touches[public_hash] = (record, reused)
+
+ if _flush_task is None or _flush_task.done():
_flush_task = asyncio.create_task(_flush_pending_touches())
-
-
-async def drain_background_touch_tasks() -> None:
- if _flush_task and not _flush_task.done():
- _flush_task.cancel()
- try:
- await _flush_task
- except asyncio.CancelledError:
- pass
-
- items = list(_pending_touches.items())
- _pending_touches.clear()
-
- for public_hash, (record, reused) in items:
- try:
- await db.touch_file_record(public_hash, reused=reused)
- except Exception as e:
- logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
-
-
-async def update_cached_file_id(record: Dict[str, Any], file_id: str) -> None:
- if not record.get("public_hash") or not file_id:
- return
- record["file_id"] = file_id
- _remember(record)
- await db.update_file_id(record["public_hash"], file_id, raise_on_error=True)
-
-
-async def _fetch_canonical_message(record: Dict[str, Any]) -> Optional[Message]:
- canonical_message_id = record.get("canonical_message_id")
- if canonical_message_id is None:
- return None
-
- try:
- try:
- message = await StreamBot.get_messages(
- chat_id=int(Var.BIN_CHANNEL),
- message_ids=int(canonical_message_id)
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- message = await StreamBot.get_messages(
- chat_id=int(Var.BIN_CHANNEL),
- message_ids=int(canonical_message_id)
- )
- except Exception as e:
- logger.warning(
- f"Error fetching canonical message {canonical_message_id}: {e}",
- exc_info=True
- )
- raise
-
- if not message or not message.media:
- return None
- return message
-
-
-async def _is_canonical_record_valid(record: Dict[str, Any], file_unique_id: str) -> bool:
- message = await _fetch_canonical_message(record)
- return bool(message and get_uniqid(message) == file_unique_id)
-
-
-async def _get_reusable_canonical_record(
- file_unique_id: str
-) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
- existing = await get_file_by_unique_id(file_unique_id)
- if not existing:
- return None, None
-
- try:
- is_valid = await _is_canonical_record_valid(existing, file_unique_id)
- except Exception as e:
- logger.warning(
- f"Falling back to BIN re-copy for {file_unique_id} after canonical validation failed: {e}",
- exc_info=True
- )
- is_valid = False
-
- if is_valid:
- return existing, None
-
- _forget(existing)
- return None, existing
-
-
-async def _wait_for_other_worker_canonical_record(file_unique_id: str) -> Optional[Dict[str, Any]]:
- loop = asyncio.get_running_loop()
- deadline = loop.time() + _INGEST_CLAIM_WAIT_SECONDS
-
- while loop.time() < deadline:
- reusable_record, _ = await _get_reusable_canonical_record(file_unique_id)
- if reusable_record:
- return reusable_record
-
- if not await db.is_file_ingest_claim_active(file_unique_id):
- break
-
- await asyncio.sleep(_INGEST_CLAIM_POLL_SECONDS)
-
- return None
-
-
-def _merge_replacement_record(
- existing: Dict[str, Any],
- refreshed: Dict[str, Any]
-) -> Dict[str, Any]:
- refreshed["created_at"] = existing.get("created_at", refreshed["created_at"])
- refreshed["seen_count"] = int(existing.get("seen_count", 0)) + 1
- refreshed["reuse_count"] = int(existing.get("reuse_count", 0))
- refreshed["first_source_chat_id"] = existing.get(
- "first_source_chat_id",
- refreshed.get("first_source_chat_id")
- )
- refreshed["first_source_message_id"] = existing.get(
- "first_source_message_id",
- refreshed.get("first_source_message_id")
- )
- return refreshed
-
-
-@asynccontextmanager
-async def file_ingest_lock(file_unique_id: str):
- async with _upload_locks_guard:
- lock = _upload_locks.get(file_unique_id)
- if lock is None:
- lock = asyncio.Lock()
- _upload_locks[file_unique_id] = lock
- _upload_lock_counts[file_unique_id] = 0
- _upload_lock_counts[file_unique_id] += 1
-
- acquired = False
- try:
- await lock.acquire()
- acquired = True
- yield
- finally:
- if acquired:
- lock.release()
- async with _upload_locks_guard:
- remaining = _upload_lock_counts.get(file_unique_id, 1) - 1
- if remaining <= 0:
- _upload_lock_counts.pop(file_unique_id, None)
- _upload_locks.pop(file_unique_id, None)
- else:
- _upload_lock_counts[file_unique_id] = remaining
-
-
-async def get_or_create_canonical_file(
- source_message: Message,
- copy_media: Callable[[Message], Awaitable[Optional[Message]]]
-) -> Tuple[Optional[Dict[str, Any]], Optional[Message], bool]:
- file_unique_id = get_uniqid(source_message)
- if not file_unique_id:
- return None, None, False
-
- async with file_ingest_lock(file_unique_id):
- for _attempt in range(_MAX_INGEST_RETRIES):
- if _attempt > 0:
- await asyncio.sleep(min(0.5 * (2 ** (_attempt - 1)), 5.0))
-
- reusable_record, stale_record = await _get_reusable_canonical_record(file_unique_id)
- if reusable_record:
- schedule_touch_file_record(reusable_record, reused=True)
- return reusable_record, None, True
-
- claim_acquired = await db.acquire_file_ingest_claim(
- file_unique_id,
- ttl_seconds=_INGEST_CLAIM_TTL_SECONDS
- )
- if not claim_acquired:
- reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id)
- if reusable_record:
- schedule_touch_file_record(reusable_record, reused=True)
- return reusable_record, None, True
- continue
-
- try:
- reusable_record, stale_record = await _get_reusable_canonical_record(file_unique_id)
- if reusable_record:
- schedule_touch_file_record(reusable_record, reused=True)
- return reusable_record, None, True
-
- stored_message = await copy_media(source_message)
- if not stored_message:
- return None, None, False
-
- record = build_file_record(
- stored_message,
- source_chat_id=source_message.chat.id if source_message.chat else None,
- source_message_id=source_message.id
- )
- if not record:
- return None, stored_message, False
-
- try:
- if stale_record:
- record = _merge_replacement_record(stale_record, record)
- await db.replace_file_record(record)
- else:
- await db.create_file_record(record)
- _remember(record)
- return record, stored_message, False
- except DuplicateKeyError:
- reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id)
- if reusable_record:
- schedule_touch_file_record(reusable_record, reused=True)
- return reusable_record, stored_message, True
- if stored_message:
- try:
- await stored_message.delete()
- except Exception as e:
- logger.warning(f"Failed to delete stored message {stored_message.id} in BIN_CHANNEL: {e}", exc_info=True)
- raise
- except FloodWait:
- raise
- except Exception as e:
- logger.error(f"Error creating canonical file for {file_unique_id}: {e}", exc_info=True)
- return None, stored_message, False
- finally:
- await db.release_file_ingest_claim(file_unique_id)
-
- logger.error(f"Max ingest retries ({_MAX_INGEST_RETRIES}) exhausted for {file_unique_id}")
- return None, None, False
+
+
+async def drain_background_touch_tasks() -> None:
+ if _flush_task and not _flush_task.done():
+ _flush_task.cancel()
+ try:
+ await _flush_task
+ except asyncio.CancelledError:
+ pass
+
+ items = list(_pending_touches.items())
+ _pending_touches.clear()
+
+ for public_hash, (record, reused) in items:
+ try:
+ await db.touch_file_record(public_hash, reused=reused)
+ except Exception as e:
+ logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True)
+
+
+async def update_cached_file_id(record: Dict[str, Any], file_id: str) -> None:
+ if not record.get("public_hash") or not file_id:
+ return
+ record["file_id"] = file_id
+ _remember(record)
+ await db.update_file_id(record["public_hash"], file_id, raise_on_error=True)
+
+
+async def _fetch_canonical_message(record: Dict[str, Any]) -> Optional[Message]:
+ canonical_message_id = record.get("canonical_message_id")
+ if canonical_message_id is None:
+ return None
+
+ try:
+ try:
+ message = await StreamBot.get_messages(
+ chat_id=int(Var.BIN_CHANNEL),
+ message_ids=int(canonical_message_id)
+ )
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ message = await StreamBot.get_messages(
+ chat_id=int(Var.BIN_CHANNEL),
+ message_ids=int(canonical_message_id)
+ )
+ except Exception as e:
+ logger.warning(
+ f"Error fetching canonical message {canonical_message_id}: {e}",
+ exc_info=True
+ )
+ raise
+
+ if not message or not message.media:
+ return None
+ return message
+
+
+async def _is_canonical_record_valid(record: Dict[str, Any], file_unique_id: str) -> bool:
+ message = await _fetch_canonical_message(record)
+ return bool(message and get_uniqid(message) == file_unique_id)
+
+
+async def _get_reusable_canonical_record(
+ file_unique_id: str
+) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]:
+ existing = await get_file_by_unique_id(file_unique_id)
+ if not existing:
+ return None, None
+
+ try:
+ is_valid = await _is_canonical_record_valid(existing, file_unique_id)
+ except Exception as e:
+ logger.warning(
+ f"Falling back to BIN re-copy for {file_unique_id} after canonical validation failed: {e}",
+ exc_info=True
+ )
+ is_valid = False
+
+ if is_valid:
+ return existing, None
+
+ _forget(existing)
+ return None, existing
+
+
+async def _wait_for_other_worker_canonical_record(file_unique_id: str) -> Optional[Dict[str, Any]]:
+ loop = asyncio.get_running_loop()
+ deadline = loop.time() + _INGEST_CLAIM_WAIT_SECONDS
+
+ while loop.time() < deadline:
+ reusable_record, _ = await _get_reusable_canonical_record(file_unique_id)
+ if reusable_record:
+ return reusable_record
+
+ if not await db.is_file_ingest_claim_active(file_unique_id):
+ break
+
+ await asyncio.sleep(_INGEST_CLAIM_POLL_SECONDS)
+
+ return None
+
+
+def _merge_replacement_record(
+ existing: Dict[str, Any],
+ refreshed: Dict[str, Any]
+) -> Dict[str, Any]:
+ refreshed["created_at"] = existing.get("created_at", refreshed["created_at"])
+ refreshed["seen_count"] = int(existing.get("seen_count", 0)) + 1
+ refreshed["reuse_count"] = int(existing.get("reuse_count", 0))
+ refreshed["first_source_chat_id"] = existing.get(
+ "first_source_chat_id",
+ refreshed.get("first_source_chat_id")
+ )
+ refreshed["first_source_message_id"] = existing.get(
+ "first_source_message_id",
+ refreshed.get("first_source_message_id")
+ )
+ return refreshed
+
+
+@asynccontextmanager
+async def file_ingest_lock(file_unique_id: str):
+ async with _upload_locks_guard:
+ lock = _upload_locks.get(file_unique_id)
+ if lock is None:
+ lock = asyncio.Lock()
+ _upload_locks[file_unique_id] = lock
+ _upload_lock_counts[file_unique_id] = 0
+ _upload_lock_counts[file_unique_id] += 1
+
+ acquired = False
+ try:
+ await lock.acquire()
+ acquired = True
+ yield
+ finally:
+ if acquired:
+ lock.release()
+ async with _upload_locks_guard:
+ remaining = _upload_lock_counts.get(file_unique_id, 1) - 1
+ if remaining <= 0:
+ _upload_lock_counts.pop(file_unique_id, None)
+ _upload_locks.pop(file_unique_id, None)
+ else:
+ _upload_lock_counts[file_unique_id] = remaining
+
+
+async def get_or_create_canonical_file(
+ source_message: Message,
+ copy_media: Callable[[Message], Awaitable[Optional[Message]]]
+) -> Tuple[Optional[Dict[str, Any]], Optional[Message], bool]:
+ file_unique_id = get_uniqid(source_message)
+ if not file_unique_id:
+ return None, None, False
+
+ async with file_ingest_lock(file_unique_id):
+ for _attempt in range(_MAX_INGEST_RETRIES):
+ if _attempt > 0:
+ await asyncio.sleep(min(0.5 * (2 ** (_attempt - 1)), 5.0))
+
+ reusable_record, stale_record = await _get_reusable_canonical_record(file_unique_id)
+ if reusable_record:
+ schedule_touch_file_record(reusable_record, reused=True)
+ return reusable_record, None, True
+
+ claim_acquired = await db.acquire_file_ingest_claim(
+ file_unique_id,
+ ttl_seconds=_INGEST_CLAIM_TTL_SECONDS
+ )
+ if not claim_acquired:
+ reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id)
+ if reusable_record:
+ schedule_touch_file_record(reusable_record, reused=True)
+ return reusable_record, None, True
+ continue
+
+ try:
+ reusable_record, stale_record = await _get_reusable_canonical_record(file_unique_id)
+ if reusable_record:
+ schedule_touch_file_record(reusable_record, reused=True)
+ return reusable_record, None, True
+
+ stored_message = await copy_media(source_message)
+ if not stored_message:
+ return None, None, False
+
+ record = build_file_record(
+ stored_message,
+ source_chat_id=source_message.chat.id if source_message.chat else None,
+ source_message_id=source_message.id
+ )
+ if not record:
+ return None, stored_message, False
+
+ try:
+ if stale_record:
+ record = _merge_replacement_record(stale_record, record)
+ await db.replace_file_record(record)
+ else:
+ await db.create_file_record(record)
+ _remember(record)
+ return record, stored_message, False
+ except DuplicateKeyError:
+ reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id)
+ if reusable_record:
+ schedule_touch_file_record(reusable_record, reused=True)
+ return reusable_record, stored_message, True
+ if stored_message:
+ try:
+ await stored_message.delete()
+ except Exception as e:
+ logger.warning(f"Failed to delete stored message {stored_message.id} in BIN_CHANNEL: {e}", exc_info=True)
+ raise
+ except FloodWait:
+ raise
+ except Exception as e:
+ logger.error(f"Error creating canonical file for {file_unique_id}: {e}", exc_info=True)
+ return None, stored_message, False
+ finally:
+ await db.release_file_ingest_claim(file_unique_id)
+
+ logger.error(f"Max ingest retries ({_MAX_INGEST_RETRIES}) exhausted for {file_unique_id}")
+ return None, None, False
diff --git a/Thunder/utils/commands.py b/Thunder/utils/commands.py
old mode 100644
new mode 100755
index d483149..b8e5742
--- a/Thunder/utils/commands.py
+++ b/Thunder/utils/commands.py
@@ -1,38 +1,38 @@
-from pyrogram.types import BotCommand
-
-from Thunder.bot import StreamBot
-from Thunder.utils.logger import logger
-from Thunder.vars import Var
-
-def get_commands():
- command_descriptions = {
- "start": "Start the bot and get a welcome message",
- "link": "(Group) Generate a direct link for a file or batch",
- "dc": "Retrieve the data center (DC) information of a user or file",
- "ping": "Check the bot's status and response time",
- "about": "Get information about the bot",
- "help": "Show help and usage instructions",
- "status": "(Admin) View bot details and current workload",
- "stats": "(Admin) View usage statistics and resource consumption",
- "broadcast": "(Admin) Send a message to all users",
- "ban": "(Admin) Ban a user",
- "unban": "(Admin) Unban a user",
- "log": "(Admin) Send bot logs",
- "restart": "(Admin) Update and restart the bot",
- "shell": "(Admin) Execute a shell command",
- "speedtest": "(Admin) Run network speed test",
- "users": "(Admin) Show the total number of users",
- "authorize": "(Admin) Grant permanent access to a user",
- "deauthorize": "(Admin) Remove permanent access from a user",
- "listauth": "(Admin) List all authorized users"
- }
- return [BotCommand(name, desc) for name, desc in command_descriptions.items()]
-
-async def set_commands():
- if Var.SET_COMMANDS:
- try:
- commands = get_commands()
- if commands:
- await StreamBot.set_bot_commands(commands)
- except Exception as e:
- logger.error(f"Failed to set bot commands: {e}", exc_info=True)
+from pyrogram.types import BotCommand
+
+from Thunder.bot import StreamBot
+from Thunder.utils.logger import logger
+from Thunder.vars import Var
+
+def get_commands():
+ command_descriptions = {
+ "start": "Start the bot and get a welcome message",
+ "link": "(Group) Generate a direct link for a file or batch",
+ "dc": "Retrieve the data center (DC) information of a user or file",
+ "ping": "Check the bot's status and response time",
+ "about": "Get information about the bot",
+ "help": "Show help and usage instructions",
+ "status": "(Admin) View bot details and current workload",
+ "stats": "(Admin) View usage statistics and resource consumption",
+ "broadcast": "(Admin) Send a message to all users",
+ "ban": "(Admin) Ban a user",
+ "unban": "(Admin) Unban a user",
+ "log": "(Admin) Send bot logs",
+ "restart": "(Admin) Update and restart the bot",
+ "shell": "(Admin) Execute a shell command",
+ "speedtest": "(Admin) Run network speed test",
+ "users": "(Admin) Show the total number of users",
+ "authorize": "(Admin) Grant permanent access to a user",
+ "deauthorize": "(Admin) Remove permanent access from a user",
+ "listauth": "(Admin) List all authorized users"
+ }
+ return [BotCommand(name, desc) for name, desc in command_descriptions.items()]
+
+async def set_commands():
+ if Var.SET_COMMANDS:
+ try:
+ commands = get_commands()
+ if commands:
+ await StreamBot.set_bot_commands(commands)
+ except Exception as e:
+ logger.error(f"Failed to set bot commands: {e}", exc_info=True)
diff --git a/Thunder/utils/config_parser.py b/Thunder/utils/config_parser.py
old mode 100644
new mode 100755
index d1bb0b4..e307b12
--- a/Thunder/utils/config_parser.py
+++ b/Thunder/utils/config_parser.py
@@ -1,36 +1,36 @@
-# Thunder/utils/config_parser.py
-
-import os
-from typing import Dict, Optional
-from Thunder.utils.logger import logger
-
-class TokenParser:
- def __init__(self, config_file: Optional[str] = None):
- self.tokens: Dict[int, str] = {}
- self.config_file = config_file
-
- def parse_from_env(self) -> Dict[int, str]:
- try:
- multi_tokens = {
- key: value.strip()
- for key, value in os.environ.items()
- if key.startswith("MULTI_TOKEN") and value.strip()
- }
-
- if not multi_tokens:
- return {}
-
- sorted_tokens = sorted(
- multi_tokens.items(),
- key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0)
- )
-
- self.tokens = {
- index + 1: token
- for index, (_, token) in enumerate(sorted_tokens)
- }
-
- return self.tokens
- except Exception as e:
- logger.error(f"Error in parse_from_env: {e}", exc_info=True)
- return {}
+# Thunder/utils/config_parser.py
+
+import os
+from typing import Dict, Optional
+from Thunder.utils.logger import logger
+
+class TokenParser:
+ def __init__(self, config_file: Optional[str] = None):
+ self.tokens: Dict[int, str] = {}
+ self.config_file = config_file
+
+ def parse_from_env(self) -> Dict[int, str]:
+ try:
+ multi_tokens = {
+ key: value.strip()
+ for key, value in os.environ.items()
+ if key.startswith("MULTI_TOKEN") and value.strip()
+ }
+
+ if not multi_tokens:
+ return {}
+
+ sorted_tokens = sorted(
+ multi_tokens.items(),
+ key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0)
+ )
+
+ self.tokens = {
+ index + 1: token
+ for index, (_, token) in enumerate(sorted_tokens)
+ }
+
+ return self.tokens
+ except Exception as e:
+ logger.error(f"Error in parse_from_env: {e}", exc_info=True)
+ return {}
diff --git a/Thunder/utils/custom_dl.py b/Thunder/utils/custom_dl.py
old mode 100644
new mode 100755
diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py
old mode 100644
new mode 100755
index d0bbe9a..d24277e
--- a/Thunder/utils/database.py
+++ b/Thunder/utils/database.py
@@ -1,453 +1,453 @@
-# Thunder/utils/database.py
-
-import datetime
-from typing import Any, Dict, Optional
-from pymongo import AsyncMongoClient
-from pymongo.asynchronous.collection import AsyncCollection
-from pymongo.errors import DuplicateKeyError
-from Thunder.vars import Var
-from Thunder.utils.logger import logger
-
-class Database:
- def __init__(self, uri: str, database_name: str, *args, **kwargs):
- self._client = AsyncMongoClient(uri, *args, **kwargs)
- self.db = self._client[database_name]
- self.col: AsyncCollection = self.db.users
- self.banned_users_col: AsyncCollection = self.db.banned_users
- self.banned_channels_col: AsyncCollection = self.db.banned_channels
- self.token_col: AsyncCollection = self.db.tokens
- self.authorized_users_col: AsyncCollection = self.db.authorized_users
- self.restart_message_col: AsyncCollection = self.db.restart_message
- self.files_col: AsyncCollection = self.db.files
- self.file_ingest_locks_col: AsyncCollection = self.db.file_ingest_locks
-
- async def _deduplicate_users(self) -> None:
- pipeline = [
- {"$sort": {"join_date": 1}},
- {"$group": {"_id": "$id", "doc_id": {"$first": "$_id"}}},
- {"$project": {"_id": "$doc_id"}}
- ]
- keep_ids = []
- async for doc in self.col.aggregate(pipeline):
- keep_ids.append(doc["_id"])
- if keep_ids:
- result = await self.col.delete_many({"_id": {"$nin": keep_ids}})
- if result.deleted_count > 0:
- logger.warning(f"Deduplicated {result.deleted_count} duplicate user documents.")
-
- async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool:
- try:
- await self.banned_users_col.create_index("user_id", unique=True)
- await self.banned_channels_col.create_index("channel_id", unique=True)
- await self.token_col.create_index("token", unique=True)
- await self.authorized_users_col.create_index("user_id", unique=True)
- try:
- await self.col.create_index("id", unique=True)
- except DuplicateKeyError:
- logger.warning("Duplicate users found, deduplicating...")
- await self._deduplicate_users()
- await self.col.create_index("id", unique=True)
- await self.token_col.create_index("expires_at", expireAfterSeconds=0)
- await self.token_col.create_index("activated")
- await self.restart_message_col.create_index("message_id", unique=True)
- await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600)
- await self.files_col.create_index("file_unique_id", unique=True)
- await self.files_col.create_index("public_hash", unique=True)
- await self.files_col.create_index("canonical_message_id", unique=True)
- await self.files_col.create_index("created_at")
- await self.files_col.create_index("last_seen_at")
- await self.file_ingest_locks_col.create_index("expires_at", expireAfterSeconds=0)
-
- logger.debug("Database indexes ensured.")
- return True
- except Exception as e:
- logger.error(f"Error in ensure_indexes: {e}", exc_info=True)
- if raise_on_error:
- raise
- return False
-
- def new_user(self, user_id: int) -> dict:
- try:
- return {
- 'id': user_id,
- 'join_date': datetime.datetime.now(datetime.timezone.utc)
- }
- except Exception as e:
- logger.error(f"Error in new_user for user {user_id}: {e}", exc_info=True)
- raise
-
- async def add_user(self, user_id: int) -> bool:
- try:
- result = await self.col.update_one(
- {'id': user_id},
- {'$setOnInsert': self.new_user(user_id)},
- upsert=True
- )
- if result.upserted_id:
- logger.debug(f"Added new user {user_id} to database.")
- return True
- return False
- except Exception as e:
- logger.error(f"Error in add_user for user {user_id}: {e}", exc_info=True)
- raise
-
-
- async def is_user_exist(self, user_id: int) -> bool:
- """Read-only existence check. For user registration, use add_user() instead."""
- try:
- user = await self.col.find_one({'id': user_id}, {'_id': 1})
- return bool(user)
- except Exception as e:
- logger.error(f"Error in is_user_exist for user {user_id}: {e}", exc_info=True)
- raise
-
- async def total_users_count(self) -> int:
- try:
- return await self.col.count_documents({})
- except Exception as e:
- logger.error(f"Error in total_users_count: {e}", exc_info=True)
- return 0
-
- async def get_authorized_users_count(self) -> int:
- try:
- return await self.authorized_users_col.count_documents({})
- except Exception as e:
- logger.error(f"Error in get_authorized_users_count: {e}", exc_info=True)
- return 0
-
- async def get_regular_users_count(self) -> int:
- try:
- auth_ids = await self.authorized_users_col.distinct("user_id")
- return await self.col.count_documents({"id": {"$nin": auth_ids}})
- except Exception as e:
- logger.error(f"Error in get_regular_users_count: {e}", exc_info=True)
- return 0
-
- async def get_all_users(self):
- try:
- return self.col.find({})
- except Exception as e:
- logger.error(f"Error in get_all_users: {e}", exc_info=True)
- return self.col.find({"_id": {"$exists": False}})
-
- async def get_authorized_users_cursor(self):
- try:
- return self.authorized_users_col.find({})
- except Exception as e:
- logger.error(f"Error in get_authorized_users_cursor: {e}", exc_info=True)
- return self.authorized_users_col.find({"_id": {"$exists": False}})
-
- async def get_regular_users_cursor(self):
- try:
- auth_ids = await self.authorized_users_col.distinct("user_id")
- return self.col.find({"id": {"$nin": auth_ids}})
- except Exception as e:
- logger.error(f"Error in get_regular_users_cursor: {e}", exc_info=True)
- return self.col.find({"_id": {"$exists": False}})
-
- async def delete_user(self, user_id: int):
- try:
- await self.col.delete_one({'id': user_id})
- logger.debug(f"Deleted user {user_id}.")
- except Exception as e:
- logger.error(f"Error in delete_user for user {user_id}: {e}", exc_info=True)
- raise
-
-
- async def add_banned_user(
- self, user_id: int, banned_by: Optional[int] = None,
- reason: Optional[str] = None
- ):
- try:
- ban_data = {
- "user_id": user_id,
- "banned_at": datetime.datetime.now(datetime.timezone.utc),
- "banned_by": banned_by,
- "reason": reason
- }
- await self.banned_users_col.update_one(
- {"user_id": user_id},
- {"$set": ban_data},
- upsert=True
- )
- logger.debug(f"Added/Updated banned user {user_id}. Reason: {reason}")
- except Exception as e:
- logger.error(f"Error in add_banned_user for user {user_id}: {e}", exc_info=True)
- raise
-
- async def remove_banned_user(self, user_id: int) -> bool:
- try:
- result = await self.banned_users_col.delete_one({"user_id": user_id})
- if result.deleted_count > 0:
- logger.debug(f"Removed banned user {user_id}.")
- return True
- return False
- except Exception as e:
- logger.error(f"Error in remove_banned_user for user {user_id}: {e}", exc_info=True)
- return False
-
- async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
- try:
- return await self.banned_users_col.find_one({"user_id": user_id})
- except Exception as e:
- logger.error(f"Error in is_user_banned for user {user_id}: {e}", exc_info=True)
- return None
-
- async def add_banned_channel(
- self, channel_id: int, banned_by: Optional[int] = None,
- reason: Optional[str] = None
- ):
- try:
- ban_data = {
- "channel_id": channel_id,
- "banned_at": datetime.datetime.now(datetime.timezone.utc),
- "banned_by": banned_by,
- "reason": reason
- }
- await self.banned_channels_col.update_one(
- {"channel_id": channel_id},
- {"$set": ban_data},
- upsert=True
- )
- logger.debug(f"Added/Updated banned channel {channel_id}. Reason: {reason}")
- except Exception as e:
- logger.error(f"Error in add_banned_channel for channel {channel_id}: {e}", exc_info=True)
- raise
-
- async def remove_banned_channel(self, channel_id: int) -> bool:
- try:
- result = await self.banned_channels_col.delete_one({"channel_id": channel_id})
- if result.deleted_count > 0:
- logger.debug(f"Removed banned channel {channel_id}.")
- return True
- return False
- except Exception as e:
- logger.error(f"Error in remove_banned_channel for channel {channel_id}: {e}", exc_info=True)
- return False
-
- async def is_channel_banned(self, channel_id: int) -> Optional[Dict[str, Any]]:
- try:
- return await self.banned_channels_col.find_one({"channel_id": channel_id})
- except Exception as e:
- logger.error(f"Error in is_channel_banned for channel {channel_id}: {e}", exc_info=True)
- return None
-
- async def save_main_token(self, user_id: int, token_value: str, expires_at: datetime.datetime, created_at: datetime.datetime, activated: bool) -> None:
- try:
- await self.token_col.update_one(
- {"user_id": user_id, "token": token_value},
- {"$set": {
- "expires_at": expires_at,
- "created_at": created_at,
- "activated": activated
- }
- },
- upsert=True
- )
- logger.debug(f"Saved main token {token_value} for user {user_id} with activated status {activated}.")
- except Exception as e:
- logger.error(f"Error saving main token for user {user_id}: {e}", exc_info=True)
- raise
-
-
- async def add_restart_message(self, message_id: int, chat_id: int) -> None:
- try:
- await self.restart_message_col.insert_one({
- "message_id": message_id,
- "chat_id": chat_id,
- "timestamp": datetime.datetime.now(datetime.timezone.utc)
- })
- logger.debug(f"Added restart message {message_id} for chat {chat_id}.")
- except Exception as e:
- logger.error(f"Error adding restart message {message_id}: {e}", exc_info=True)
-
- async def get_restart_message(self) -> Optional[Dict[str, Any]]:
- try:
- return await self.restart_message_col.find_one(sort=[("timestamp", -1)])
- except Exception as e:
- logger.error(f"Error getting restart message: {e}", exc_info=True)
- return None
-
- async def delete_restart_message(self, message_id: int) -> None:
- try:
- await self.restart_message_col.delete_one({"message_id": message_id})
- logger.debug(f"Deleted restart message {message_id}.")
- except Exception as e:
- logger.error(f"Error deleting restart message {message_id}: {e}", exc_info=True)
-
- async def is_user_authorized(self, user_id: int) -> bool:
- try:
- user = await self.authorized_users_col.find_one({'user_id': user_id}, {'_id': 1})
- return bool(user)
- except Exception as e:
- logger.error(f"Error in is_user_authorized for user {user_id}: {e}", exc_info=True)
- return False
-
- async def get_file_by_unique_id(self, file_unique_id: str) -> Optional[Dict[str, Any]]:
- try:
- return await self.files_col.find_one({"file_unique_id": file_unique_id})
- except Exception as e:
- logger.error(f"Error getting file by unique_id {file_unique_id}: {e}", exc_info=True)
- return None
-
- async def get_file_by_hash(
- self,
- public_hash: str,
- *,
- raise_on_error: bool = True
- ) -> Optional[Dict[str, Any]]:
- try:
- return await self.files_col.find_one({"public_hash": public_hash})
- except Exception as e:
- logger.error(f"Error getting file by hash {public_hash}: {e}", exc_info=True)
- if raise_on_error:
- raise
- return None
-
- async def get_file_by_message_id(self, canonical_message_id: int) -> Optional[Dict[str, Any]]:
- try:
- return await self.files_col.find_one({"canonical_message_id": canonical_message_id})
- except Exception as e:
- logger.error(
- f"Error getting file by message_id {canonical_message_id}: {e}",
- exc_info=True
- )
- return None
-
- async def create_file_record(self, file_record: Dict[str, Any]) -> None:
- try:
- await self.files_col.insert_one(file_record)
- except Exception as e:
- logger.error(
- f"Error creating canonical file record for {file_record.get('file_unique_id')}: {e}",
- exc_info=True
- )
- raise
-
- async def replace_file_record(self, file_record: Dict[str, Any]) -> None:
- try:
- await self.files_col.replace_one(
- {"file_unique_id": file_record["file_unique_id"]},
- file_record,
- upsert=True
- )
- except Exception as e:
- logger.error(
- f"Error replacing canonical file record for {file_record.get('file_unique_id')}: {e}",
- exc_info=True
- )
- raise
-
- async def touch_file_record(
- self,
- public_hash: str,
- *,
- reused: bool = False,
- raise_on_error: bool = False
- ) -> bool:
- try:
- update_doc: Dict[str, Any] = {
- "$set": {"last_seen_at": datetime.datetime.now(datetime.timezone.utc)},
- "$inc": {"seen_count": 1}
- }
- if reused:
- update_doc["$inc"]["reuse_count"] = 1
- await self.files_col.update_one({"public_hash": public_hash}, update_doc)
- return True
- except Exception as e:
- logger.error(f"Error touching canonical file {public_hash}: {e}", exc_info=True)
- if raise_on_error:
- raise
- return False
-
- async def update_file_id(
- self,
- public_hash: str,
- file_id: str,
- *,
- raise_on_error: bool = False
- ) -> bool:
- try:
- await self.files_col.update_one(
- {"public_hash": public_hash},
- {
- "$set": {
- "file_id": file_id,
- "last_seen_at": datetime.datetime.now(datetime.timezone.utc)
- }
- }
- )
- return True
- except Exception as e:
- logger.error(f"Error updating file_id for {public_hash}: {e}", exc_info=True)
- if raise_on_error:
- raise
- return False
-
- async def acquire_file_ingest_claim(
- self,
- file_unique_id: str,
- *,
- ttl_seconds: int = 60
- ) -> bool:
- now = datetime.datetime.now(datetime.timezone.utc)
- claim_fields = {
- "created_at": now,
- "expires_at": now + datetime.timedelta(seconds=ttl_seconds)
- }
- try:
- await self.file_ingest_locks_col.insert_one({
- "_id": file_unique_id,
- **claim_fields
- })
- return True
- except DuplicateKeyError:
- try:
- result = await self.file_ingest_locks_col.find_one_and_update(
- {
- "_id": file_unique_id,
- "$or": [
- {"expires_at": {"$lte": now}},
- {"expires_at": {"$exists": False}}
- ]
- },
- {
- "$set": claim_fields
- },
- return_document=False
- )
- return bool(result)
- except Exception as e:
- logger.error(f"Error updating ingest claim for {file_unique_id}: {e}", exc_info=True)
- raise
- except Exception as e:
- logger.error(f"Error acquiring ingest claim for {file_unique_id}: {e}", exc_info=True)
- raise
-
- async def release_file_ingest_claim(self, file_unique_id: str) -> bool:
- try:
- await self.file_ingest_locks_col.delete_one({"_id": file_unique_id})
- return True
- except Exception as e:
- logger.error(f"Error releasing ingest claim for {file_unique_id}: {e}", exc_info=True)
- return False
-
- async def is_file_ingest_claim_active(self, file_unique_id: str) -> bool:
- try:
- claim = await self.file_ingest_locks_col.find_one(
- {
- "_id": file_unique_id,
- "expires_at": {"$gt": datetime.datetime.now(datetime.timezone.utc)}
- },
- {"_id": 1}
- )
- return bool(claim)
- except Exception as e:
- logger.error(f"Error checking ingest claim for {file_unique_id}: {e}", exc_info=True)
- raise
-
- async def close(self):
- if self._client:
- await self._client.close()
-
-db = Database(Var.DATABASE_URL, Var.NAME)
+# Thunder/utils/database.py
+
+import datetime
+from typing import Any, Dict, Optional
+from pymongo import AsyncMongoClient
+from pymongo.asynchronous.collection import AsyncCollection
+from pymongo.errors import DuplicateKeyError
+from Thunder.vars import Var
+from Thunder.utils.logger import logger
+
+class Database:
+ def __init__(self, uri: str, database_name: str, *args, **kwargs):
+ self._client = AsyncMongoClient(uri, *args, **kwargs)
+ self.db = self._client[database_name]
+ self.col: AsyncCollection = self.db.users
+ self.banned_users_col: AsyncCollection = self.db.banned_users
+ self.banned_channels_col: AsyncCollection = self.db.banned_channels
+ self.token_col: AsyncCollection = self.db.tokens
+ self.authorized_users_col: AsyncCollection = self.db.authorized_users
+ self.restart_message_col: AsyncCollection = self.db.restart_message
+ self.files_col: AsyncCollection = self.db.files
+ self.file_ingest_locks_col: AsyncCollection = self.db.file_ingest_locks
+
+ async def _deduplicate_users(self) -> None:
+ pipeline = [
+ {"$sort": {"join_date": 1}},
+ {"$group": {"_id": "$id", "doc_id": {"$first": "$_id"}}},
+ {"$project": {"_id": "$doc_id"}}
+ ]
+ keep_ids = []
+ async for doc in self.col.aggregate(pipeline):
+ keep_ids.append(doc["_id"])
+ if keep_ids:
+ result = await self.col.delete_many({"_id": {"$nin": keep_ids}})
+ if result.deleted_count > 0:
+ logger.warning(f"Deduplicated {result.deleted_count} duplicate user documents.")
+
+ async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool:
+ try:
+ await self.banned_users_col.create_index("user_id", unique=True)
+ await self.banned_channels_col.create_index("channel_id", unique=True)
+ await self.token_col.create_index("token", unique=True)
+ await self.authorized_users_col.create_index("user_id", unique=True)
+ try:
+ await self.col.create_index("id", unique=True)
+ except DuplicateKeyError:
+ logger.warning("Duplicate users found, deduplicating...")
+ await self._deduplicate_users()
+ await self.col.create_index("id", unique=True)
+ await self.token_col.create_index("expires_at", expireAfterSeconds=0)
+ await self.token_col.create_index("activated")
+ await self.restart_message_col.create_index("message_id", unique=True)
+ await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600)
+ await self.files_col.create_index("file_unique_id", unique=True)
+ await self.files_col.create_index("public_hash", unique=True)
+ await self.files_col.create_index("canonical_message_id", unique=True)
+ await self.files_col.create_index("created_at")
+ await self.files_col.create_index("last_seen_at")
+ await self.file_ingest_locks_col.create_index("expires_at", expireAfterSeconds=0)
+
+ logger.debug("Database indexes ensured.")
+ return True
+ except Exception as e:
+ logger.error(f"Error in ensure_indexes: {e}", exc_info=True)
+ if raise_on_error:
+ raise
+ return False
+
+ def new_user(self, user_id: int) -> dict:
+ try:
+ return {
+ 'id': user_id,
+ 'join_date': datetime.datetime.now(datetime.timezone.utc)
+ }
+ except Exception as e:
+ logger.error(f"Error in new_user for user {user_id}: {e}", exc_info=True)
+ raise
+
+ async def add_user(self, user_id: int) -> bool:
+ try:
+ result = await self.col.update_one(
+ {'id': user_id},
+ {'$setOnInsert': self.new_user(user_id)},
+ upsert=True
+ )
+ if result.upserted_id:
+ logger.debug(f"Added new user {user_id} to database.")
+ return True
+ return False
+ except Exception as e:
+ logger.error(f"Error in add_user for user {user_id}: {e}", exc_info=True)
+ raise
+
+
+ async def is_user_exist(self, user_id: int) -> bool:
+ """Read-only existence check. For user registration, use add_user() instead."""
+ try:
+ user = await self.col.find_one({'id': user_id}, {'_id': 1})
+ return bool(user)
+ except Exception as e:
+ logger.error(f"Error in is_user_exist for user {user_id}: {e}", exc_info=True)
+ raise
+
+ async def total_users_count(self) -> int:
+ try:
+ return await self.col.count_documents({})
+ except Exception as e:
+ logger.error(f"Error in total_users_count: {e}", exc_info=True)
+ return 0
+
+ async def get_authorized_users_count(self) -> int:
+ try:
+ return await self.authorized_users_col.count_documents({})
+ except Exception as e:
+ logger.error(f"Error in get_authorized_users_count: {e}", exc_info=True)
+ return 0
+
+ async def get_regular_users_count(self) -> int:
+ try:
+ auth_ids = await self.authorized_users_col.distinct("user_id")
+ return await self.col.count_documents({"id": {"$nin": auth_ids}})
+ except Exception as e:
+ logger.error(f"Error in get_regular_users_count: {e}", exc_info=True)
+ return 0
+
+ async def get_all_users(self):
+ try:
+ return self.col.find({})
+ except Exception as e:
+ logger.error(f"Error in get_all_users: {e}", exc_info=True)
+ return self.col.find({"_id": {"$exists": False}})
+
+ async def get_authorized_users_cursor(self):
+ try:
+ return self.authorized_users_col.find({})
+ except Exception as e:
+ logger.error(f"Error in get_authorized_users_cursor: {e}", exc_info=True)
+ return self.authorized_users_col.find({"_id": {"$exists": False}})
+
+ async def get_regular_users_cursor(self):
+ try:
+ auth_ids = await self.authorized_users_col.distinct("user_id")
+ return self.col.find({"id": {"$nin": auth_ids}})
+ except Exception as e:
+ logger.error(f"Error in get_regular_users_cursor: {e}", exc_info=True)
+ return self.col.find({"_id": {"$exists": False}})
+
+ async def delete_user(self, user_id: int):
+ try:
+ await self.col.delete_one({'id': user_id})
+ logger.debug(f"Deleted user {user_id}.")
+ except Exception as e:
+ logger.error(f"Error in delete_user for user {user_id}: {e}", exc_info=True)
+ raise
+
+
+ async def add_banned_user(
+ self, user_id: int, banned_by: Optional[int] = None,
+ reason: Optional[str] = None
+ ):
+ try:
+ ban_data = {
+ "user_id": user_id,
+ "banned_at": datetime.datetime.now(datetime.timezone.utc),
+ "banned_by": banned_by,
+ "reason": reason
+ }
+ await self.banned_users_col.update_one(
+ {"user_id": user_id},
+ {"$set": ban_data},
+ upsert=True
+ )
+ logger.debug(f"Added/Updated banned user {user_id}. Reason: {reason}")
+ except Exception as e:
+ logger.error(f"Error in add_banned_user for user {user_id}: {e}", exc_info=True)
+ raise
+
+ async def remove_banned_user(self, user_id: int) -> bool:
+ try:
+ result = await self.banned_users_col.delete_one({"user_id": user_id})
+ if result.deleted_count > 0:
+ logger.debug(f"Removed banned user {user_id}.")
+ return True
+ return False
+ except Exception as e:
+ logger.error(f"Error in remove_banned_user for user {user_id}: {e}", exc_info=True)
+ return False
+
+ async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
+ try:
+ return await self.banned_users_col.find_one({"user_id": user_id})
+ except Exception as e:
+ logger.error(f"Error in is_user_banned for user {user_id}: {e}", exc_info=True)
+ return None
+
+ async def add_banned_channel(
+ self, channel_id: int, banned_by: Optional[int] = None,
+ reason: Optional[str] = None
+ ):
+ try:
+ ban_data = {
+ "channel_id": channel_id,
+ "banned_at": datetime.datetime.now(datetime.timezone.utc),
+ "banned_by": banned_by,
+ "reason": reason
+ }
+ await self.banned_channels_col.update_one(
+ {"channel_id": channel_id},
+ {"$set": ban_data},
+ upsert=True
+ )
+ logger.debug(f"Added/Updated banned channel {channel_id}. Reason: {reason}")
+ except Exception as e:
+ logger.error(f"Error in add_banned_channel for channel {channel_id}: {e}", exc_info=True)
+ raise
+
+ async def remove_banned_channel(self, channel_id: int) -> bool:
+ try:
+ result = await self.banned_channels_col.delete_one({"channel_id": channel_id})
+ if result.deleted_count > 0:
+ logger.debug(f"Removed banned channel {channel_id}.")
+ return True
+ return False
+ except Exception as e:
+ logger.error(f"Error in remove_banned_channel for channel {channel_id}: {e}", exc_info=True)
+ return False
+
+ async def is_channel_banned(self, channel_id: int) -> Optional[Dict[str, Any]]:
+ try:
+ return await self.banned_channels_col.find_one({"channel_id": channel_id})
+ except Exception as e:
+ logger.error(f"Error in is_channel_banned for channel {channel_id}: {e}", exc_info=True)
+ return None
+
+ async def save_main_token(self, user_id: int, token_value: str, expires_at: datetime.datetime, created_at: datetime.datetime, activated: bool) -> None:
+ try:
+ await self.token_col.update_one(
+ {"user_id": user_id, "token": token_value},
+ {"$set": {
+ "expires_at": expires_at,
+ "created_at": created_at,
+ "activated": activated
+ }
+ },
+ upsert=True
+ )
+ logger.debug(f"Saved main token {token_value} for user {user_id} with activated status {activated}.")
+ except Exception as e:
+ logger.error(f"Error saving main token for user {user_id}: {e}", exc_info=True)
+ raise
+
+
+ async def add_restart_message(self, message_id: int, chat_id: int) -> None:
+ try:
+ await self.restart_message_col.insert_one({
+ "message_id": message_id,
+ "chat_id": chat_id,
+ "timestamp": datetime.datetime.now(datetime.timezone.utc)
+ })
+ logger.debug(f"Added restart message {message_id} for chat {chat_id}.")
+ except Exception as e:
+ logger.error(f"Error adding restart message {message_id}: {e}", exc_info=True)
+
+ async def get_restart_message(self) -> Optional[Dict[str, Any]]:
+ try:
+ return await self.restart_message_col.find_one(sort=[("timestamp", -1)])
+ except Exception as e:
+ logger.error(f"Error getting restart message: {e}", exc_info=True)
+ return None
+
+ async def delete_restart_message(self, message_id: int) -> None:
+ try:
+ await self.restart_message_col.delete_one({"message_id": message_id})
+ logger.debug(f"Deleted restart message {message_id}.")
+ except Exception as e:
+ logger.error(f"Error deleting restart message {message_id}: {e}", exc_info=True)
+
+ async def is_user_authorized(self, user_id: int) -> bool:
+ try:
+ user = await self.authorized_users_col.find_one({'user_id': user_id}, {'_id': 1})
+ return bool(user)
+ except Exception as e:
+ logger.error(f"Error in is_user_authorized for user {user_id}: {e}", exc_info=True)
+ return False
+
+ async def get_file_by_unique_id(self, file_unique_id: str) -> Optional[Dict[str, Any]]:
+ try:
+ return await self.files_col.find_one({"file_unique_id": file_unique_id})
+ except Exception as e:
+ logger.error(f"Error getting file by unique_id {file_unique_id}: {e}", exc_info=True)
+ return None
+
+ async def get_file_by_hash(
+ self,
+ public_hash: str,
+ *,
+ raise_on_error: bool = True
+ ) -> Optional[Dict[str, Any]]:
+ try:
+ return await self.files_col.find_one({"public_hash": public_hash})
+ except Exception as e:
+ logger.error(f"Error getting file by hash {public_hash}: {e}", exc_info=True)
+ if raise_on_error:
+ raise
+ return None
+
+ async def get_file_by_message_id(self, canonical_message_id: int) -> Optional[Dict[str, Any]]:
+ try:
+ return await self.files_col.find_one({"canonical_message_id": canonical_message_id})
+ except Exception as e:
+ logger.error(
+ f"Error getting file by message_id {canonical_message_id}: {e}",
+ exc_info=True
+ )
+ return None
+
+ async def create_file_record(self, file_record: Dict[str, Any]) -> None:
+ try:
+ await self.files_col.insert_one(file_record)
+ except Exception as e:
+ logger.error(
+ f"Error creating canonical file record for {file_record.get('file_unique_id')}: {e}",
+ exc_info=True
+ )
+ raise
+
+ async def replace_file_record(self, file_record: Dict[str, Any]) -> None:
+ try:
+ await self.files_col.replace_one(
+ {"file_unique_id": file_record["file_unique_id"]},
+ file_record,
+ upsert=True
+ )
+ except Exception as e:
+ logger.error(
+ f"Error replacing canonical file record for {file_record.get('file_unique_id')}: {e}",
+ exc_info=True
+ )
+ raise
+
+ async def touch_file_record(
+ self,
+ public_hash: str,
+ *,
+ reused: bool = False,
+ raise_on_error: bool = False
+ ) -> bool:
+ try:
+ update_doc: Dict[str, Any] = {
+ "$set": {"last_seen_at": datetime.datetime.now(datetime.timezone.utc)},
+ "$inc": {"seen_count": 1}
+ }
+ if reused:
+ update_doc["$inc"]["reuse_count"] = 1
+ await self.files_col.update_one({"public_hash": public_hash}, update_doc)
+ return True
+ except Exception as e:
+ logger.error(f"Error touching canonical file {public_hash}: {e}", exc_info=True)
+ if raise_on_error:
+ raise
+ return False
+
+ async def update_file_id(
+ self,
+ public_hash: str,
+ file_id: str,
+ *,
+ raise_on_error: bool = False
+ ) -> bool:
+ try:
+ await self.files_col.update_one(
+ {"public_hash": public_hash},
+ {
+ "$set": {
+ "file_id": file_id,
+ "last_seen_at": datetime.datetime.now(datetime.timezone.utc)
+ }
+ }
+ )
+ return True
+ except Exception as e:
+ logger.error(f"Error updating file_id for {public_hash}: {e}", exc_info=True)
+ if raise_on_error:
+ raise
+ return False
+
+ async def acquire_file_ingest_claim(
+ self,
+ file_unique_id: str,
+ *,
+ ttl_seconds: int = 60
+ ) -> bool:
+ now = datetime.datetime.now(datetime.timezone.utc)
+ claim_fields = {
+ "created_at": now,
+ "expires_at": now + datetime.timedelta(seconds=ttl_seconds)
+ }
+ try:
+ await self.file_ingest_locks_col.insert_one({
+ "_id": file_unique_id,
+ **claim_fields
+ })
+ return True
+ except DuplicateKeyError:
+ try:
+ result = await self.file_ingest_locks_col.find_one_and_update(
+ {
+ "_id": file_unique_id,
+ "$or": [
+ {"expires_at": {"$lte": now}},
+ {"expires_at": {"$exists": False}}
+ ]
+ },
+ {
+ "$set": claim_fields
+ },
+ return_document=False
+ )
+ return bool(result)
+ except Exception as e:
+ logger.error(f"Error updating ingest claim for {file_unique_id}: {e}", exc_info=True)
+ raise
+ except Exception as e:
+ logger.error(f"Error acquiring ingest claim for {file_unique_id}: {e}", exc_info=True)
+ raise
+
+ async def release_file_ingest_claim(self, file_unique_id: str) -> bool:
+ try:
+ await self.file_ingest_locks_col.delete_one({"_id": file_unique_id})
+ return True
+ except Exception as e:
+ logger.error(f"Error releasing ingest claim for {file_unique_id}: {e}", exc_info=True)
+ return False
+
+ async def is_file_ingest_claim_active(self, file_unique_id: str) -> bool:
+ try:
+ claim = await self.file_ingest_locks_col.find_one(
+ {
+ "_id": file_unique_id,
+ "expires_at": {"$gt": datetime.datetime.now(datetime.timezone.utc)}
+ },
+ {"_id": 1}
+ )
+ return bool(claim)
+ except Exception as e:
+ logger.error(f"Error checking ingest claim for {file_unique_id}: {e}", exc_info=True)
+ raise
+
+ async def close(self):
+ if self._client:
+ await self._client.close()
+
+db = Database(Var.DATABASE_URL, Var.NAME)
diff --git a/Thunder/utils/decorators.py b/Thunder/utils/decorators.py
old mode 100644
new mode 100755
index b1209f1..2775225
--- a/Thunder/utils/decorators.py
+++ b/Thunder/utils/decorators.py
@@ -1,181 +1,181 @@
-# Thunder/utils/decorators.py
-
-import asyncio
-from pyrogram.errors import FloodWait
-from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
-
-from Thunder.utils.database import db
-from Thunder.utils.logger import logger
-from Thunder.utils.messages import (MSG_DECORATOR_BANNED,
- MSG_ERROR_UNAUTHORIZED, MSG_TOKEN_INVALID)
-from Thunder.utils.shortener import shorten
-from Thunder.utils.tokens import allowed, check, generate
-from Thunder.vars import Var
-
-
-async def check_banned(client, message: Message):
- try:
- if not message.from_user:
- return True
- user_id = message.from_user.id
- if user_id == Var.OWNER_ID:
- return True
-
- ban_details = await db.is_user_banned(user_id)
- if ban_details:
- banned_at = ban_details.get('banned_at')
- ban_time = (
- banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
- if banned_at and hasattr(banned_at, 'strftime')
- else str(banned_at) if banned_at else 'N/A'
- )
- try:
- await message.reply_text(
- MSG_DECORATOR_BANNED.format(
- reason=ban_details.get('reason', 'Not specified'),
- ban_time=ban_time
- ),
- quote=True
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(
- MSG_DECORATOR_BANNED.format(
- reason=ban_details.get('reason', 'Not specified'),
- ban_time=ban_time
- ),
- quote=True
- )
- logger.debug(f"Blocked banned user {user_id}.")
- return False
- return True
- except Exception as e:
- logger.error(f"Error in check_banned: {e}", exc_info=True)
- return True
-
-async def require_token(client, message: Message):
- try:
- if not message.from_user:
- return True
-
- if not getattr(Var, "TOKEN_ENABLED", False):
- return True
-
- user_id = message.from_user.id
- if user_id == Var.OWNER_ID or await allowed(user_id) or await check(user_id):
- return True
-
- temp_token_string = None
- try:
- temp_token_string = await generate(user_id)
- except Exception as e:
- logger.error(f"Failed to generate temporary token for user {user_id} in require_token: {e}", exc_info=True)
- try:
- await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
- return False
-
- if not temp_token_string:
- logger.error(f"Temporary token generation returned empty for user {user_id} in require_token.", exc_info=True)
- try:
- await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
- return False
-
- try:
- me = await client.get_me()
- except FloodWait as e:
- await asyncio.sleep(e.value)
- me = await client.get_me()
- if not me:
- logger.error(f"Failed to get bot info for user {user_id} in require_token.", exc_info=True)
- try:
- await message.reply_text("Sorry, an unexpected error occurred. Please try again later.", quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("Sorry, an unexpected error occurred. Please try again later.", quote=True)
- return False
- deep_link = f"https://t.me/{me.username}?start={temp_token_string}"
- short_url = deep_link
-
- try:
- short_url_result = await shorten(deep_link)
- if short_url_result:
- short_url = short_url_result
- except Exception as e:
- logger.warning(f"Failed to shorten token link for user {user_id}: {e}. Using full link.", exc_info=True)
-
- try:
- await message.reply_text(
- MSG_TOKEN_INVALID,
- reply_markup=InlineKeyboardMarkup([
- [InlineKeyboardButton("Activate Access", url=short_url)]
- ]),
- quote=True
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(
- MSG_TOKEN_INVALID,
- reply_markup=InlineKeyboardMarkup([
- [InlineKeyboardButton("Activate Access", url=short_url)]
- ]),
- quote=True
- )
- logger.debug(f"Sent temporary token activation link to user {user_id}.")
- return False
- except Exception as e:
- logger.error(f"Error in require_token: {e}", exc_info=True)
- try:
- try:
- await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
- except Exception as inner_e:
- logger.error(f"Failed to send error message to user in require_token: {inner_e}", exc_info=True)
- return False
-
-async def get_shortener_status(client, message: Message):
- try:
- user_id = message.from_user.id if message.from_user else None
- use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
- if user_id:
- try:
- if user_id == Var.OWNER_ID or await allowed(user_id):
- use_shortener = False
- except Exception as e:
- logger.warning(f"Error checking allowed status for user {user_id} in get_shortener_status: {e}. Defaulting shortener behavior.", exc_info=True)
- return use_shortener
- except Exception as e:
- logger.error(f"Error in get_shortener_status: {e}", exc_info=True)
- return getattr(Var, "SHORTEN_MEDIA_LINKS", False)
-
-async def owner_only(client, update):
- try:
- user = None
- if hasattr(update, 'from_user'):
- user = update.from_user
- else:
- logger.error(f"Unsupported update type or missing from_user in owner_only: {type(update)}", exc_info=True)
- return False
-
- if not user or user.id != Var.OWNER_ID:
- if hasattr(update, 'answer'):
- await update.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True)
- logger.warning(f"Unauthorized access attempt by {user.id if user else 'unknown'} to owner_only function.")
- return False
-
- return True
- except Exception as e:
- logger.error(f"Error in owner_only: {e}", exc_info=True)
- try:
- if hasattr(update, 'answer'):
- await update.answer("An error occurred. Please try again.", show_alert=True)
- except Exception as inner_e:
- logger.error(f"Failed to send error answer in owner_only: {inner_e}", exc_info=True)
- return False
+# Thunder/utils/decorators.py
+
+import asyncio
+from pyrogram.errors import FloodWait
+from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
+
+from Thunder.utils.database import db
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import (MSG_DECORATOR_BANNED,
+ MSG_ERROR_UNAUTHORIZED, MSG_TOKEN_INVALID)
+from Thunder.utils.shortener import shorten
+from Thunder.utils.tokens import allowed, check, generate
+from Thunder.vars import Var
+
+
+async def check_banned(client, message: Message):
+ try:
+ if not message.from_user:
+ return True
+ user_id = message.from_user.id
+ if user_id == Var.OWNER_ID:
+ return True
+
+ ban_details = await db.is_user_banned(user_id)
+ if ban_details:
+ banned_at = ban_details.get('banned_at')
+ ban_time = (
+ banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
+ if banned_at and hasattr(banned_at, 'strftime')
+ else str(banned_at) if banned_at else 'N/A'
+ )
+ try:
+ await message.reply_text(
+ MSG_DECORATOR_BANNED.format(
+ reason=ban_details.get('reason', 'Not specified'),
+ ban_time=ban_time
+ ),
+ quote=True
+ )
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text(
+ MSG_DECORATOR_BANNED.format(
+ reason=ban_details.get('reason', 'Not specified'),
+ ban_time=ban_time
+ ),
+ quote=True
+ )
+ logger.debug(f"Blocked banned user {user_id}.")
+ return False
+ return True
+ except Exception as e:
+ logger.error(f"Error in check_banned: {e}", exc_info=True)
+ return True
+
+async def require_token(client, message: Message):
+ try:
+ if not message.from_user:
+ return True
+
+ if not getattr(Var, "TOKEN_ENABLED", False):
+ return True
+
+ user_id = message.from_user.id
+ if user_id == Var.OWNER_ID or await allowed(user_id) or await check(user_id):
+ return True
+
+ temp_token_string = None
+ try:
+ temp_token_string = await generate(user_id)
+ except Exception as e:
+ logger.error(f"Failed to generate temporary token for user {user_id} in require_token: {e}", exc_info=True)
+ try:
+ await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
+ return False
+
+ if not temp_token_string:
+ logger.error(f"Temporary token generation returned empty for user {user_id} in require_token.", exc_info=True)
+ try:
+ await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
+ return False
+
+ try:
+ me = await client.get_me()
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ me = await client.get_me()
+ if not me:
+ logger.error(f"Failed to get bot info for user {user_id} in require_token.", exc_info=True)
+ try:
+ await message.reply_text("Sorry, an unexpected error occurred. Please try again later.", quote=True)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text("Sorry, an unexpected error occurred. Please try again later.", quote=True)
+ return False
+ deep_link = f"https://t.me/{me.username}?start={temp_token_string}"
+ short_url = deep_link
+
+ try:
+ short_url_result = await shorten(deep_link)
+ if short_url_result:
+ short_url = short_url_result
+ except Exception as e:
+ logger.warning(f"Failed to shorten token link for user {user_id}: {e}. Using full link.", exc_info=True)
+
+ try:
+ await message.reply_text(
+ MSG_TOKEN_INVALID,
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("Activate Access", url=short_url)]
+ ]),
+ quote=True
+ )
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text(
+ MSG_TOKEN_INVALID,
+ reply_markup=InlineKeyboardMarkup([
+ [InlineKeyboardButton("Activate Access", url=short_url)]
+ ]),
+ quote=True
+ )
+ logger.debug(f"Sent temporary token activation link to user {user_id}.")
+ return False
+ except Exception as e:
+ logger.error(f"Error in require_token: {e}", exc_info=True)
+ try:
+ try:
+ await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
+ except Exception as inner_e:
+ logger.error(f"Failed to send error message to user in require_token: {inner_e}", exc_info=True)
+ return False
+
+async def get_shortener_status(client, message: Message):
+ try:
+ user_id = message.from_user.id if message.from_user else None
+ use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
+ if user_id:
+ try:
+ if user_id == Var.OWNER_ID or await allowed(user_id):
+ use_shortener = False
+ except Exception as e:
+ logger.warning(f"Error checking allowed status for user {user_id} in get_shortener_status: {e}. Defaulting shortener behavior.", exc_info=True)
+ return use_shortener
+ except Exception as e:
+ logger.error(f"Error in get_shortener_status: {e}", exc_info=True)
+ return getattr(Var, "SHORTEN_MEDIA_LINKS", False)
+
+async def owner_only(client, update):
+ try:
+ user = None
+ if hasattr(update, 'from_user'):
+ user = update.from_user
+ else:
+ logger.error(f"Unsupported update type or missing from_user in owner_only: {type(update)}", exc_info=True)
+ return False
+
+ if not user or user.id != Var.OWNER_ID:
+ if hasattr(update, 'answer'):
+ await update.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True)
+ logger.warning(f"Unauthorized access attempt by {user.id if user else 'unknown'} to owner_only function.")
+ return False
+
+ return True
+ except Exception as e:
+ logger.error(f"Error in owner_only: {e}", exc_info=True)
+ try:
+ if hasattr(update, 'answer'):
+ await update.answer("An error occurred. Please try again.", show_alert=True)
+ except Exception as inner_e:
+ logger.error(f"Failed to send error answer in owner_only: {inner_e}", exc_info=True)
+ return False
diff --git a/Thunder/utils/file_properties.py b/Thunder/utils/file_properties.py
old mode 100644
new mode 100755
index c83f7ae..fdc6297
--- a/Thunder/utils/file_properties.py
+++ b/Thunder/utils/file_properties.py
@@ -1,99 +1,99 @@
-# Thunder/utils/file_properties.py
-
-import asyncio
-from datetime import datetime as dt
-from typing import Any, Optional
-
-from pyrogram.client import Client
-from pyrogram.errors import FloodWait
-from pyrogram.file_id import FileId
-from pyrogram.types import Message
-
-from Thunder.server.exceptions import FileNotFound
-from Thunder.utils.logger import logger
-
-
-def get_media(message: Message) -> Optional[Any]:
- for attr in ("audio", "document", "photo", "sticker", "animation", "video", "voice", "video_note"):
- media = getattr(message, attr, None)
- if media:
- return media
- return None
-
-
-def get_uniqid(message: Message) -> Optional[str]:
- media = get_media(message)
- return getattr(media, 'file_unique_id', None)
-
-
-def get_hash(media_msg: Message) -> str:
- uniq_id = get_uniqid(media_msg)
- return uniq_id[:6] if uniq_id else ''
-
-
-def get_fsize(message: Message) -> int:
- media = get_media(message)
- return getattr(media, 'file_size', 0) if media else 0
-
-
-def parse_fid(message: Message) -> Optional[FileId]:
- media = get_media(message)
- if media and hasattr(media, 'file_id'):
- try:
- return FileId.decode(media.file_id)
- except Exception:
- return None
- return None
-
-
-def get_fname(msg: Message) -> str:
- media = get_media(msg)
- fname = getattr(media, 'file_name', None) if media else None
-
- if not fname:
- ext = "bin"
- if media:
- media_types = {
- "photo": "jpg",
- "audio": "mp3",
- "voice": "ogg",
- "video": "mp4",
- "animation": "mp4",
- "video_note": "mp4",
- "sticker": "webp"
- }
-
- # Check which attribute type the message has
- for attr, extension in media_types.items():
- if getattr(msg, attr, None) is not None:
- ext = extension
- break
-
- timestamp = dt.now().strftime("%Y%m%d%H%M%S")
- fname = f"Thunder File To Link_{timestamp}.{ext}"
-
- return fname
-
-
-async def get_fids(client: Client, chat_id: int, message_id: int) -> FileId:
- try:
- try:
- msg = await client.get_messages(chat_id, message_id)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- msg = await client.get_messages(chat_id, message_id)
-
- if not msg or getattr(msg, 'empty', False):
- raise FileNotFound("Message not found")
-
- media = get_media(msg)
- if media:
- if not hasattr(media, 'file_id') or not hasattr(media, 'file_unique_id'):
- raise FileNotFound("Media metadata incomplete")
- return FileId.decode(media.file_id)
-
- raise FileNotFound("No media in message")
-
- except Exception as e:
- logger.error(f"Error in get_fids: {e}", exc_info=True)
- raise FileNotFound(str(e))
+# Thunder/utils/file_properties.py
+
+import asyncio
+from datetime import datetime as dt
+from typing import Any, Optional
+
+from pyrogram.client import Client
+from pyrogram.errors import FloodWait
+from pyrogram.file_id import FileId
+from pyrogram.types import Message
+
+from Thunder.server.exceptions import FileNotFound
+from Thunder.utils.logger import logger
+
+
+def get_media(message: Message) -> Optional[Any]:
+ for attr in ("audio", "document", "photo", "sticker", "animation", "video", "voice", "video_note"):
+ media = getattr(message, attr, None)
+ if media:
+ return media
+ return None
+
+
+def get_uniqid(message: Message) -> Optional[str]:
+ media = get_media(message)
+ return getattr(media, 'file_unique_id', None)
+
+
+def get_hash(media_msg: Message) -> str:
+ uniq_id = get_uniqid(media_msg)
+ return uniq_id[:6] if uniq_id else ''
+
+
+def get_fsize(message: Message) -> int:
+ media = get_media(message)
+ return getattr(media, 'file_size', 0) if media else 0
+
+
+def parse_fid(message: Message) -> Optional[FileId]:
+ media = get_media(message)
+ if media and hasattr(media, 'file_id'):
+ try:
+ return FileId.decode(media.file_id)
+ except Exception:
+ return None
+ return None
+
+
+def get_fname(msg: Message) -> str:
+ media = get_media(msg)
+ fname = getattr(media, 'file_name', None) if media else None
+
+ if not fname:
+ ext = "bin"
+ if media:
+ media_types = {
+ "photo": "jpg",
+ "audio": "mp3",
+ "voice": "ogg",
+ "video": "mp4",
+ "animation": "mp4",
+ "video_note": "mp4",
+ "sticker": "webp"
+ }
+
+ # Check which attribute type the message has
+ for attr, extension in media_types.items():
+ if getattr(msg, attr, None) is not None:
+ ext = extension
+ break
+
+ timestamp = dt.now().strftime("%Y%m%d%H%M%S")
+ fname = f"Thunder File To Link_{timestamp}.{ext}"
+
+ return fname
+
+
+async def get_fids(client: Client, chat_id: int, message_id: int) -> FileId:
+ try:
+ try:
+ msg = await client.get_messages(chat_id, message_id)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ msg = await client.get_messages(chat_id, message_id)
+
+ if not msg or getattr(msg, 'empty', False):
+ raise FileNotFound("Message not found")
+
+ media = get_media(msg)
+ if media:
+ if not hasattr(media, 'file_id') or not hasattr(media, 'file_unique_id'):
+ raise FileNotFound("Media metadata incomplete")
+ return FileId.decode(media.file_id)
+
+ raise FileNotFound("No media in message")
+
+ except Exception as e:
+ logger.error(f"Error in get_fids: {e}", exc_info=True)
+ raise FileNotFound(str(e))
diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py
old mode 100644
new mode 100755
index 698ef18..9a32535
--- a/Thunder/utils/force_channel.py
+++ b/Thunder/utils/force_channel.py
@@ -1,89 +1,89 @@
-# Thunder/utils/force_channel.py
-
-import asyncio
-
-from pyrogram import Client
-from pyrogram.errors import FloodWait, UserNotParticipant
-from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
-
-from Thunder.utils.logger import logger
-from Thunder.utils.messages import MSG_COMMUNITY_CHANNEL
-from Thunder.vars import Var
-
-_force_link = None
-_force_title = None
-
-async def get_force_info(bot: Client):
- global _force_link, _force_title
-
- if not Var.FORCE_CHANNEL_ID:
- return None, None
-
- if _force_link is not None and _force_title is not None:
- return _force_link, _force_title
-
- try:
- try:
- chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
- except FloodWait as e:
- await asyncio.sleep(e.value)
- chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
- if chat:
- _force_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
- _force_title = chat.title or "Channel"
- return _force_link, _force_title
- except Exception as e:
- logger.error(f"Force channel error: {e}", exc_info=True)
- return None, None
-
-async def force_channel_check(client: Client, message: Message):
- if not Var.FORCE_CHANNEL_ID:
- return True
-
- if message.from_user is None:
- return True
-
- try:
- while True:
- try:
- member = await client.get_chat_member(Var.FORCE_CHANNEL_ID, message.from_user.id)
- if member is None:
- logger.error(f"Failed to get chat member for {message.from_user.id} in force channel {Var.FORCE_CHANNEL_ID} after retries.")
- return False
- return True
- except FloodWait as e:
- logger.debug(f"FloodWait in force_channel_check, sleeping for {e.value}s")
- await asyncio.sleep(e.value)
- except UserNotParticipant:
- link, title = await get_force_info(client)
- if link and title:
- try:
- await message.reply_text(
- MSG_COMMUNITY_CHANNEL.format(channel_title=title),
- reply_markup=InlineKeyboardMarkup([[
- InlineKeyboardButton("Join", url=link)
- ]])
- )
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text(
- MSG_COMMUNITY_CHANNEL.format(channel_title=title),
- reply_markup=InlineKeyboardMarkup([[
- InlineKeyboardButton("Join", url=link)
- ]])
- )
- else:
- try:
- await message.reply_text("You must join the channel to use this bot.")
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("You must join the channel to use this bot.")
- return False
- except Exception as e:
- logger.error(f"Error checking force channel: {e}", exc_info=True)
- try:
- await message.reply_text("An unexpected error occurred while checking channel membership. Please try again.")
- except FloodWait as e:
- await asyncio.sleep(e.value)
- await message.reply_text("An unexpected error occurred while checking channel membership. Please try again.")
- return False
+# Thunder/utils/force_channel.py
+
+import asyncio
+
+from pyrogram import Client
+from pyrogram.errors import FloodWait, UserNotParticipant
+from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message
+
+from Thunder.utils.logger import logger
+from Thunder.utils.messages import MSG_COMMUNITY_CHANNEL
+from Thunder.vars import Var
+
+_force_link = None
+_force_title = None
+
+async def get_force_info(bot: Client):
+ global _force_link, _force_title
+
+ if not Var.FORCE_CHANNEL_ID:
+ return None, None
+
+ if _force_link is not None and _force_title is not None:
+ return _force_link, _force_title
+
+ try:
+ try:
+ chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
+ if chat:
+ _force_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
+ _force_title = chat.title or "Channel"
+ return _force_link, _force_title
+ except Exception as e:
+ logger.error(f"Force channel error: {e}", exc_info=True)
+ return None, None
+
+async def force_channel_check(client: Client, message: Message):
+ if not Var.FORCE_CHANNEL_ID:
+ return True
+
+ if message.from_user is None:
+ return True
+
+ try:
+ while True:
+ try:
+ member = await client.get_chat_member(Var.FORCE_CHANNEL_ID, message.from_user.id)
+ if member is None:
+ logger.error(f"Failed to get chat member for {message.from_user.id} in force channel {Var.FORCE_CHANNEL_ID} after retries.")
+ return False
+ return True
+ except FloodWait as e:
+ logger.debug(f"FloodWait in force_channel_check, sleeping for {e.value}s")
+ await asyncio.sleep(e.value)
+ except UserNotParticipant:
+ link, title = await get_force_info(client)
+ if link and title:
+ try:
+ await message.reply_text(
+ MSG_COMMUNITY_CHANNEL.format(channel_title=title),
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("Join", url=link)
+ ]])
+ )
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text(
+ MSG_COMMUNITY_CHANNEL.format(channel_title=title),
+ reply_markup=InlineKeyboardMarkup([[
+ InlineKeyboardButton("Join", url=link)
+ ]])
+ )
+ else:
+ try:
+ await message.reply_text("You must join the channel to use this bot.")
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text("You must join the channel to use this bot.")
+ return False
+ except Exception as e:
+ logger.error(f"Error checking force channel: {e}", exc_info=True)
+ try:
+ await message.reply_text("An unexpected error occurred while checking channel membership. Please try again.")
+ except FloodWait as e:
+ await asyncio.sleep(e.value)
+ await message.reply_text("An unexpected error occurred while checking channel membership. Please try again.")
+ return False
diff --git a/Thunder/utils/human_readable.py b/Thunder/utils/human_readable.py
old mode 100644
new mode 100755
index 0a7ee46..4a76fbe
--- a/Thunder/utils/human_readable.py
+++ b/Thunder/utils/human_readable.py
@@ -1,18 +1,18 @@
-# Thunder/utils/human_readable.py
-
-from Thunder.utils.logger import logger
-
-_UNITS = ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
-
-def humanbytes(size: int, decimal_places: int = 2) -> str:
- try:
- if not size:
- return "0 B"
- n = 0
- while size >= 1024 and n < len(_UNITS) - 1:
- size /= 1024
- n += 1
- return f"{round(size, decimal_places)} {_UNITS[n]}B"
- except Exception as e:
- logger.error(f"Error in humanbytes for size {size}: {e}", exc_info=True)
- return "N/A"
+# Thunder/utils/human_readable.py
+
+from Thunder.utils.logger import logger
+
+_UNITS = ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
+
+def humanbytes(size: int, decimal_places: int = 2) -> str:
+ try:
+ if not size:
+ return "0 B"
+ n = 0
+ while size >= 1024 and n < len(_UNITS) - 1:
+ size /= 1024
+ n += 1
+ return f"{round(size, decimal_places)} {_UNITS[n]}B"
+ except Exception as e:
+ logger.error(f"Error in humanbytes for size {size}: {e}", exc_info=True)
+ return "N/A"
diff --git a/Thunder/utils/keepalive.py b/Thunder/utils/keepalive.py
old mode 100644
new mode 100755
index d7089c0..46bf180
--- a/Thunder/utils/keepalive.py
+++ b/Thunder/utils/keepalive.py
@@ -1,22 +1,22 @@
-# Thunder/utils/keepalive.py
-
-import asyncio
-import aiohttp
-from Thunder.vars import Var
-from Thunder.utils.logger import logger
-
-async def ping_server():
- try:
- async with aiohttp.ClientSession(
- timeout=aiohttp.ClientTimeout(total=10)
- ) as session:
- while True:
- try:
- await asyncio.sleep(Var.PING_INTERVAL)
- async with session.get(Var.URL) as resp:
- if resp.status != 200:
- logger.warning(f"Ping to {Var.URL} returned status {resp.status}.")
- except asyncio.CancelledError:
- break
- except Exception as e:
- logger.error(f"Error in ping_server: {e}", exc_info=True)
+# Thunder/utils/keepalive.py
+
+import asyncio
+import aiohttp
+from Thunder.vars import Var
+from Thunder.utils.logger import logger
+
+async def ping_server():
+ try:
+ async with aiohttp.ClientSession(
+ timeout=aiohttp.ClientTimeout(total=10)
+ ) as session:
+ while True:
+ try:
+ await asyncio.sleep(Var.PING_INTERVAL)
+ async with session.get(Var.URL) as resp:
+ if resp.status != 200:
+ logger.warning(f"Ping to {Var.URL} returned status {resp.status}.")
+ except asyncio.CancelledError:
+ break
+ except Exception as e:
+ logger.error(f"Error in ping_server: {e}", exc_info=True)
diff --git a/Thunder/utils/logger.py b/Thunder/utils/logger.py
old mode 100644
new mode 100755
index 6609a39..b063baa
--- a/Thunder/utils/logger.py
+++ b/Thunder/utils/logger.py
@@ -1,39 +1,39 @@
-# Thunder/utils/logger.py
-
-import logging
-from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener
-import os
-import queue
-import atexit
-import sys
-
-LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
-os.makedirs(LOG_DIR, exist_ok=True)
-LOG_FILE = os.path.join(LOG_DIR, 'bot.txt')
-
-logging._srcfile = None
-logging.logThreads = 0
-logging.logProcesses = 0
-
-log_queue = queue.Queue(maxsize=10000)
-
-formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
-
-file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8')
-file_handler.setFormatter(formatter)
-
-console_handler = logging.StreamHandler(stream=sys.__stdout__)
-console_handler.setFormatter(formatter)
-console_handler.stream.reconfigure(encoding='utf-8', errors='replace')
-
-listener = QueueListener(log_queue, file_handler, console_handler, respect_handler_level=True)
-listener.start()
-
-logger = logging.getLogger('ThunderBot')
-logger.setLevel(logging.INFO)
-logger.propagate = False
-logger.addHandler(QueueHandler(log_queue))
-
-atexit.register(listener.stop)
-
-__all__ = ['logger', 'LOG_FILE']
+# Thunder/utils/logger.py
+
+import logging
+from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener
+import os
+import queue
+import atexit
+import sys
+
+LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
+os.makedirs(LOG_DIR, exist_ok=True)
+LOG_FILE = os.path.join(LOG_DIR, 'bot.txt')
+
+logging._srcfile = None
+logging.logThreads = 0
+logging.logProcesses = 0
+
+log_queue = queue.Queue(maxsize=10000)
+
+formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+
+file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8')
+file_handler.setFormatter(formatter)
+
+console_handler = logging.StreamHandler(stream=sys.__stdout__)
+console_handler.setFormatter(formatter)
+console_handler.stream.reconfigure(encoding='utf-8', errors='replace')
+
+listener = QueueListener(log_queue, file_handler, console_handler, respect_handler_level=True)
+listener.start()
+
+logger = logging.getLogger('ThunderBot')
+logger.setLevel(logging.INFO)
+logger.propagate = False
+logger.addHandler(QueueHandler(log_queue))
+
+atexit.register(listener.stop)
+
+__all__ = ['logger', 'LOG_FILE']
diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py
old mode 100644
new mode 100755
index feb8f04..c98ad3b
--- a/Thunder/utils/messages.py
+++ b/Thunder/utils/messages.py
@@ -1,399 +1,399 @@
-# Thunder/utils/messages.py
-
-# =====================================================================================
-# ====== ERROR MESSAGES ======
-# =====================================================================================
-
-# ------ General Errors ------
-MSG_ERROR_GENERIC = "β οΈ **Oops!** Something went wrong. Please try again. If the issue persists, contact support."
-MSG_ERROR_USER_INFO = "β **User Not Found:** Couldn't find user. Please check the ID or Username."
-
-# ------ User Input & Validation Errors ------
-MSG_INVALID_USER_ID = "β **Invalid User ID:** Please provide a numeric user ID."
-MSG_ERROR_START_BOT = "β οΈ You need to start the bot in private first to use this command.\nπ [Click here]({invite_link}) to start a private chat."
-MSG_ERROR_REPLY_FILE = "β οΈ Please use the /link command in reply to a file."
-MSG_ERROR_NO_FILE = "β οΈ The message you're replying to does not contain any file."
-MSG_ERROR_INVALID_NUMBER = "β οΈ **Invalid number specified.**"
-MSG_ERROR_NUMBER_RANGE = "β οΈ **Please specify a number between 1 and {max_files}.**"
-MSG_ERROR_DM_FAILED = "β οΈ I couldn't send you a Direct Message. Please start the bot first."
-
-# ------ File & Media Errors ------
-MSG_ERROR_PROCESSING_MEDIA = "β οΈ **Oops!** Something went wrong while processing your media. Please try again. If the issue persists, contact support."
-
-# ------ Admin Action Errors (Ban, Auth, etc.) ------
-MSG_AUTHORIZE_FAILED = (
- "β **Authorization Failed:** "
- "Could not authorize user `{user_id}`."
-)
-MSG_DEAUTHORIZE_FAILED = (
- "β **Deauthorization Failed:** "
- "User `{user_id}` was not authorized or an error occurred."
-)
-MSG_TOKEN_FAILED = (
- "β οΈ **Token Activation Failed!**\n\n"
- "> β Reason: {reason}\n\n"
- "π Please check your token or contact support."
-)
-MSG_SHELL_ERROR = """**β Shell Command Error β**
-
{error}
"""
-
-# ------ System & Bot Errors ------
-MSG_ERROR_NOT_ADMIN = "β οΈ **Admin Required:** I need admin privileges to work here."
-MSG_DC_INVALID_USAGE = "π€ **Invalid Usage:** Please reply to a user's message or a media file to get DC info."
-MSG_DC_ANON_ERROR = "π₯ **Cannot Get Your DC Info:** Unable to identify you. This command might not work for anonymous users."
-MSG_DC_FILE_ERROR = "βοΈ **Error Getting File DC Info:** Could not fetch details. File might be inaccessible."
-MSG_STATS_ERROR = "β **Stats Error:** Could not retrieve system statistics."
-MSG_STATUS_ERROR = "β **Status Error:** Could not retrieve system status."
-MSG_DB_ERROR = "β **Database Error:** Could not retrieve user count."
-MSG_CRITICAL_ERROR = (
- "π¨ **Critical Media Processing Error** π¨\n\n"
- "> β οΈ Details:\n```\n{error}\n```\n\n"
- "Please investigate immediately! (ID: {error_id})"
-)
-
-# =====================================================================================
-# ====== ADMIN MESSAGES ======
-# =====================================================================================
-
-# ------ Ban/Unban ------
-MSG_DECORATOR_BANNED = "You are currently banned and cannot use this bot.\nReason: {reason}\nBanned on: {ban_time}"
-MSG_BAN_USAGE = "β οΈ **Usage:** /ban [user_id] [reason]"
-MSG_CANNOT_BAN_OWNER = "β **Cannot ban an owner.**"
-MSG_ADMIN_USER_BANNED = "β **User {user_id} has been banned."
-MSG_BAN_REASON_SUFFIX = "\nπ **Reason:** {reason}"
-MSG_ADMIN_NO_BAN_REASON = "No reason provided"
-MSG_USER_BANNED_NOTIFICATION = "π« **You have been banned from using this bot.**"
-MSG_UNBAN_USAGE = "β οΈ **Usage:** /unban "
-MSG_ADMIN_USER_UNBANNED = "β **User {user_id} has been unbanned."
-MSG_USER_UNBANNED_NOTIFICATION = "π **You have been unbanned from using this bot.**"
-MSG_USER_NOT_IN_BAN_LIST = "βΉοΈ **User {user_id} was not found in the ban list."
-MSG_CHANNEL_BANNED = "β **Channel {channel_id} has been banned.**"
-MSG_CHANNEL_BANNED_REASON_SUFFIX = "\nπ **Reason:** {reason}"
-MSG_CHANNEL_UNBANNED = "β **Channel {channel_id} has been unbanned.**"
-MSG_CHANNEL_NOT_BANNED = "βΉοΈ **Channel {channel_id} was not found in the ban list.**"
-
-# ------ Token & Authorization ------
-MSG_AUTHORIZE_USAGE = "π **Usage:** `/authorize `"
-MSG_DEAUTHORIZE_USAGE = "π **Usage:** `/deauthorize `"
-MSG_AUTHORIZE_SUCCESS = (
- "β **User Authorized!**\n\n"
- "> π€ User ID: `{user_id}`\n"
- "> π Access: Permanent"
-)
-MSG_DEAUTHORIZE_SUCCESS = (
- "β **User Deauthorized!**\n\n"
- "> π€ User ID: `{user_id}`\n"
- "> π Access: Revoked"
-)
-MSG_TOKEN_ACTIVATED = "β Token successfully activated!\n\nβ³ This token is valid for {duration_hours} hours."
-MSG_TOKEN_INVALID = "π« **Expired or Invalid Token.** Please click the button below to activate your access token."
-MSG_NO_AUTH_USERS = "βΉοΈ **No Authorized Users Found:** The list is currently empty."
+# Thunder/utils/messages.py
+
+# =====================================================================================
+# ====== ERROR MESSAGES ======
+# =====================================================================================
+
+# ------ General Errors ------
+MSG_ERROR_GENERIC = "β οΈ **Oops!** Something went wrong. Please try again. If the issue persists, contact support."
+MSG_ERROR_USER_INFO = "β **User Not Found:** Couldn't find user. Please check the ID or Username."
+
+# ------ User Input & Validation Errors ------
+MSG_INVALID_USER_ID = "β **Invalid User ID:** Please provide a numeric user ID."
+MSG_ERROR_START_BOT = "β οΈ You need to start the bot in private first to use this command.\nπ [Click here]({invite_link}) to start a private chat."
+MSG_ERROR_REPLY_FILE = "β οΈ Please use the /link command in reply to a file."
+MSG_ERROR_NO_FILE = "β οΈ The message you're replying to does not contain any file."
+MSG_ERROR_INVALID_NUMBER = "β οΈ **Invalid number specified.**"
+MSG_ERROR_NUMBER_RANGE = "β οΈ **Please specify a number between 1 and {max_files}.**"
+MSG_ERROR_DM_FAILED = "β οΈ I couldn't send you a Direct Message. Please start the bot first."
+
+# ------ File & Media Errors ------
+MSG_ERROR_PROCESSING_MEDIA = "β οΈ **Oops!** Something went wrong while processing your media. Please try again. If the issue persists, contact support."
+
+# ------ Admin Action Errors (Ban, Auth, etc.) ------
+MSG_AUTHORIZE_FAILED = (
+ "β **Authorization Failed:** "
+ "Could not authorize user `{user_id}`."
+)
+MSG_DEAUTHORIZE_FAILED = (
+ "β **Deauthorization Failed:** "
+ "User `{user_id}` was not authorized or an error occurred."
+)
+MSG_TOKEN_FAILED = (
+ "β οΈ **Token Activation Failed!**\n\n"
+ "> β Reason: {reason}\n\n"
+ "π Please check your token or contact support."
+)
+MSG_SHELL_ERROR = """**β Shell Command Error β**
+
{error}
"""
+
+# ------ System & Bot Errors ------
+MSG_ERROR_NOT_ADMIN = "β οΈ **Admin Required:** I need admin privileges to work here."
+MSG_DC_INVALID_USAGE = "π€ **Invalid Usage:** Please reply to a user's message or a media file to get DC info."
+MSG_DC_ANON_ERROR = "π₯ **Cannot Get Your DC Info:** Unable to identify you. This command might not work for anonymous users."
+MSG_DC_FILE_ERROR = "βοΈ **Error Getting File DC Info:** Could not fetch details. File might be inaccessible."
+MSG_STATS_ERROR = "β **Stats Error:** Could not retrieve system statistics."
+MSG_STATUS_ERROR = "β **Status Error:** Could not retrieve system status."
+MSG_DB_ERROR = "β **Database Error:** Could not retrieve user count."
+MSG_CRITICAL_ERROR = (
+ "π¨ **Critical Media Processing Error** π¨\n\n"
+ "> β οΈ Details:\n```\n{error}\n```\n\n"
+ "Please investigate immediately! (ID: {error_id})"
+)
+
+# =====================================================================================
+# ====== ADMIN MESSAGES ======
+# =====================================================================================
+
+# ------ Ban/Unban ------
+MSG_DECORATOR_BANNED = "You are currently banned and cannot use this bot.\nReason: {reason}\nBanned on: {ban_time}"
+MSG_BAN_USAGE = "β οΈ **Usage:** /ban [user_id] [reason]"
+MSG_CANNOT_BAN_OWNER = "β **Cannot ban an owner.**"
+MSG_ADMIN_USER_BANNED = "β **User {user_id} has been banned."
+MSG_BAN_REASON_SUFFIX = "\nπ **Reason:** {reason}"
+MSG_ADMIN_NO_BAN_REASON = "No reason provided"
+MSG_USER_BANNED_NOTIFICATION = "π« **You have been banned from using this bot.**"
+MSG_UNBAN_USAGE = "β οΈ **Usage:** /unban "
+MSG_ADMIN_USER_UNBANNED = "β **User {user_id} has been unbanned."
+MSG_USER_UNBANNED_NOTIFICATION = "π **You have been unbanned from using this bot.**"
+MSG_USER_NOT_IN_BAN_LIST = "βΉοΈ **User {user_id} was not found in the ban list."
+MSG_CHANNEL_BANNED = "β **Channel {channel_id} has been banned.**"
+MSG_CHANNEL_BANNED_REASON_SUFFIX = "\nπ **Reason:** {reason}"
+MSG_CHANNEL_UNBANNED = "β **Channel {channel_id} has been unbanned.**"
+MSG_CHANNEL_NOT_BANNED = "βΉοΈ **Channel {channel_id} was not found in the ban list.**"
+
+# ------ Token & Authorization ------
+MSG_AUTHORIZE_USAGE = "π **Usage:** `/authorize `"
+MSG_DEAUTHORIZE_USAGE = "π **Usage:** `/deauthorize `"
+MSG_AUTHORIZE_SUCCESS = (
+ "β **User Authorized!**\n\n"
+ "> π€ User ID: `{user_id}`\n"
+ "> π Access: Permanent"
+)
+MSG_DEAUTHORIZE_SUCCESS = (
+ "β **User Deauthorized!**\n\n"
+ "> π€ User ID: `{user_id}`\n"
+ "> π Access: Revoked"
+)
+MSG_TOKEN_ACTIVATED = "β Token successfully activated!\n\nβ³ This token is valid for {duration_hours} hours."
+MSG_TOKEN_INVALID = "π« **Expired or Invalid Token.** Please click the button below to activate your access token."
+MSG_NO_AUTH_USERS = "βΉοΈ **No Authorized Users Found:** The list is currently empty."
MSG_AUTH_USER_INFO = """{i}. π€: {display_name}
β’ User ID: `{user_id}`
β’ Authorized by: `{authorized_by}`
β’ Date: `{auth_time}`\n\n"""
-MSG_ADMIN_AUTH_LIST_HEADER = "π **Authorized Users List**\n\n"
-
-# ------ Shell Commands ------
-MSG_SHELL_USAGE = (
- "Usage:\n"
- "/shell \n\n"
- "Example:\n"
- "/shell ls -l"
-)
-MSG_SHELL_EXECUTING = "Executing Command... βοΈ\n