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

⚑ Thunder

-

- -

- High-Performance Telegram File-to-Link Bot for Direct Links & Streaming -

- -

- Python Version - Pyrofork - License - Telegram Channel -

- -
- -## πŸ“‘ 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 - -[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](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. - ---- - -

- ⭐ Star this project if you find it useful!
- Report Bug β€’ - Request Feature -

+

+ Thunder Logo +

⚑ Thunder

+

+ +

+ High-Performance Telegram File-to-Link Bot for Direct Links & Streaming +

+ +

+ Python Version + Pyrofork + License + Telegram Channel +

+ +
+ +## πŸ“‘ 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 + +[![Deploy to Koyeb](https://www.koyeb.com/static/images/deploy/button.svg)](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. + +--- + +

+ ⭐ Star this project if you find it useful!
+ Report Bug β€’ + Request Feature +

diff --git a/Thunder/__init__.py b/Thunder/__init__.py old mode 100644 new mode 100755 index 4e32f5d..1613fd7 --- a/Thunder/__init__.py +++ b/Thunder/__init__.py @@ -1,6 +1,6 @@ -# Thunder/__init__.py - -import time - -StartTime = time.time() -__version__ = "2.1.0" +# Thunder/__init__.py + +import time + +StartTime = time.time() +__version__ = "2.1.0" diff --git a/Thunder/__main__.py b/Thunder/__main__.py old mode 100644 new mode 100755 index a6c5473..a65e6aa --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -1,328 +1,328 @@ -# Thunder/__main__.py - -import asyncio -import glob -import importlib.util -import sys -from datetime import datetime - -from pathlib import Path - -if sys.platform == 'win32': - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - -try: - from uvloop import install - install() -except ImportError: - pass -from aiohttp import web -from pyrogram import idle -from pyrogram.errors import FloodWait, MessageNotModified - -from Thunder import __version__ -from Thunder.bot import StreamBot -from Thunder.bot.clients import cleanup_clients, initialize_clients -from Thunder.server import web_server -from Thunder.utils.commands import set_commands -from Thunder.utils.database import db -from Thunder.utils.keepalive import ping_server -from Thunder.utils.canonical_files import drain_background_touch_tasks -from Thunder.utils.logger import logger -from Thunder.utils.messages import MSG_ADMIN_RESTART_DONE -from Thunder.utils.rate_limiter import rate_limiter, request_executor -from Thunder.utils.tokens import cleanup_expired_tokens -from Thunder.vars import Var - - -PLUGIN_PATH = "Thunder/bot/plugins/*.py" -VERSION = __version__ - - -def print_banner(): - banner = f""" -╔═══════════════════════════════════════════════════════════════════╗ -β•‘ β•‘ -β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•‘ -β•‘ β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— β•‘ -β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β•‘ -β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— β•‘ -β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•‘ -β•‘ β•šβ•β• β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•β•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• β•‘ -β•‘ β•‘ -β•‘ File Streaming Bot v{VERSION} β•‘ -β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• -""" - print(banner) - - -def schedule_index_ensure() -> None: - task = asyncio.create_task( - db.ensure_indexes(raise_on_error=False), - name="ensure_database_indexes" - ) - - def _log_index_failure(done_task: asyncio.Task) -> None: - try: - created_indexes = done_task.result() - if created_indexes: - print(" βœ“ Database indexes ensured.") - else: - print(" β–Ά Database indexes could not be ensured during startup.") - except Exception as e: - logger.error(f"Background database index ensure failed: {e}", exc_info=True) - - task.add_done_callback(_log_index_failure) - - -async def import_plugins(): - print("╠════════════════════ IMPORTING PLUGINS ════════════════════╣") - plugins = glob.glob(PLUGIN_PATH) - if not plugins: - print(" β–Ά No plugins found to import!") - return 0 - - success_count = 0 - failed_plugins = [] - - for file_path in plugins: - try: - plugin_path = Path(file_path) - plugin_name = plugin_path.stem - import_path = f"Thunder.bot.plugins.{plugin_name}" - - spec = importlib.util.spec_from_file_location( - import_path, plugin_path - ) - if spec is None or spec.loader is None: - logger.error(f"Invalid plugin specification for {plugin_name}") - failed_plugins.append(plugin_name) - continue - - module = importlib.util.module_from_spec(spec) - sys.modules[import_path] = module - spec.loader.exec_module(module) - success_count += 1 - - except Exception as e: - plugin_name = Path(file_path).stem - logger.error(f" βœ– Failed to import plugin {plugin_name}: {e}") - failed_plugins.append(plugin_name) - - print( - f" β–Ά Total: {len(plugins)} | Success: {success_count} | " - f"Failed: {len(failed_plugins)}" - ) - if failed_plugins: - print(f" β–Ά Failed plugins: {', '.join(failed_plugins)}") - - return success_count - - -async def start_services(): - start_time = datetime.now() - print_banner() - print("╔════════════════ INITIALIZING BOT SERVICES ════════════════╗") - - print(" β–Ά Starting Telegram Bot initialization...") - try: - try: - await StreamBot.start() - except FloodWait as e: - logger.debug(f"FloodWait in bot start, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await StreamBot.start() - - try: - bot_info = await StreamBot.get_me() - except FloodWait as e: - logger.debug(f"FloodWait in get_me, sleeping for {e.value}s") - await asyncio.sleep(e.value) - bot_info = await StreamBot.get_me() - - StreamBot.username = bot_info.username - print(f" βœ“ Bot initialized successfully as @{StreamBot.username}") - - await set_commands() - print(" βœ“ Bot commands set successfully.") - schedule_index_ensure() - - restart_message_data = await db.get_restart_message() - if restart_message_data: - try: - try: - await StreamBot.edit_message_text( - chat_id=restart_message_data["chat_id"], - message_id=restart_message_data["message_id"], - text=MSG_ADMIN_RESTART_DONE, - ) - except FloodWait as e: - logger.debug(f"FloodWait in restart message edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await StreamBot.edit_message_text( - chat_id=restart_message_data["chat_id"], - message_id=restart_message_data["message_id"], - text=MSG_ADMIN_RESTART_DONE, - ) - except MessageNotModified: - pass - await db.delete_restart_message( - restart_message_data["message_id"] - ) - except Exception as e: - logger.error( - f"Error processing restart message: {e}", exc_info=True - ) - else: - pass - - except Exception as e: - logger.error( - f" βœ– Failed to initialize Telegram Bot: {e}", exc_info=True - ) - return - - print(" β–Ά Starting Client initialization...") - try: - await initialize_clients() - except Exception as e: - logger.error(f" βœ– Failed to initialize clients: {e}", exc_info=True) - return - - await import_plugins() - - print(" β–Ά Starting Request Executor initialization...") - try: - request_executor_task = asyncio.create_task( - request_executor(), name="request_executor_task" - ) - print(" βœ“ Request executor service started") - except Exception as e: - logger.error( - f" βœ– Failed to start request executor: {e}", exc_info=True - ) - return - - print(" β–Ά Starting Web Server initialization...") - try: - app_runner = web.AppRunner(await web_server()) - await app_runner.setup() - bind_address = Var.BIND_ADDRESS - site = web.TCPSite(app_runner, bind_address, Var.PORT) - await site.start() - - keepalive_task = asyncio.create_task( - ping_server(), name="keepalive_task" - ) - print(" βœ“ Keep-alive service started") - token_cleanup_task = asyncio.create_task( - schedule_token_cleanup(), name="token_cleanup_task" - ) - - except Exception as e: - logger.error(f" βœ– Failed to start Web Server: {e}", exc_info=True) - if 'request_executor_task' in locals() and not request_executor_task.done(): - request_executor_task.cancel() - try: - await request_executor_task - except asyncio.CancelledError: - pass - try: - await StreamBot.stop() - except Exception: - pass - try: - await cleanup_clients() - except Exception: - pass - try: - await rate_limiter.shutdown() - except Exception: - pass - try: - await db.close() - except Exception as e: - logger.error(f"Error during database cleanup: {e}", exc_info=True) - try: - await drain_background_touch_tasks() - except Exception as e: - logger.error(f"Error during canonical touch task cleanup: {e}", exc_info=True) - return - - elapsed_time = (datetime.now() - start_time).total_seconds() - print("╠═══════════════════════════════════════════════════════════╣") - print(f" β–Ά Bot Name: {bot_info.first_name}") - print(f" β–Ά Username: @{bot_info.username}") - print(f" β–Ά Server: {bind_address}:{Var.PORT}") - print(f" β–Ά Startup Time: {elapsed_time:.2f} seconds") - print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") - print(" β–Ά Bot is now running! Press CTRL+C to stop.") - - background_tasks = [ - request_executor_task, - keepalive_task, - token_cleanup_task - ] - - try: - await idle() - finally: - print(" β–Ά Shutting down services...") - - for task in background_tasks: - if not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - - try: - await rate_limiter.shutdown() - except Exception as e: - logger.error(f"Error during rate limiter cleanup: {e}") - - try: - await cleanup_clients() - except Exception as e: - logger.error(f"Error during client cleanup: {e}") - - try: - await drain_background_touch_tasks() - except Exception as e: - logger.error(f"Error during canonical touch task cleanup: {e}", exc_info=True) - - if 'app_runner' in locals() and app_runner is not None: - try: - await app_runner.cleanup() - except Exception as e: - logger.error(f"Error during web server cleanup: {e}") - - try: - await db.close() - print(" βœ“ Database connection closed") - except Exception as e: - logger.error("Error during database cleanup", exc_info=True) - - -async def schedule_token_cleanup(): - while True: - try: - await asyncio.sleep(3 * 3600) - await cleanup_expired_tokens() - except asyncio.CancelledError: - logger.debug("schedule_token_cleanup cancelled cleanly.") - break - except Exception as e: - logger.error(f"Token cleanup error: {e}", exc_info=True) - -if __name__ == '__main__': - try: - loop = asyncio.get_event_loop() - loop.run_until_complete(start_services()) - except KeyboardInterrupt: - print("╔═══════════════════════════════════════════════════════════╗") - print("β•‘ Bot stopped by user (CTRL+C) β•‘") - print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") - except Exception as e: - logger.error(f"An unexpected error occurred: {e}") +# Thunder/__main__.py + +import asyncio +import glob +import importlib.util +import sys +from datetime import datetime + +from pathlib import Path + +if sys.platform == 'win32': + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + +try: + from uvloop import install + install() +except ImportError: + pass +from aiohttp import web +from pyrogram import idle +from pyrogram.errors import FloodWait, MessageNotModified + +from Thunder import __version__ +from Thunder.bot import StreamBot +from Thunder.bot.clients import cleanup_clients, initialize_clients +from Thunder.server import web_server +from Thunder.utils.commands import set_commands +from Thunder.utils.database import db +from Thunder.utils.keepalive import ping_server +from Thunder.utils.canonical_files import drain_background_touch_tasks +from Thunder.utils.logger import logger +from Thunder.utils.messages import MSG_ADMIN_RESTART_DONE +from Thunder.utils.rate_limiter import rate_limiter, request_executor +from Thunder.utils.tokens import cleanup_expired_tokens +from Thunder.vars import Var + + +PLUGIN_PATH = "Thunder/bot/plugins/*.py" +VERSION = __version__ + + +def print_banner(): + banner = f""" +╔═══════════════════════════════════════════════════════════════════╗ +β•‘ β•‘ +β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•‘ +β•‘ β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— β•‘ +β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β•‘ +β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— β•‘ +β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•‘ +β•‘ β•šβ•β• β•šβ•β• β•šβ•β• β•šβ•β•β•β•β•β• β•šβ•β• β•šβ•β•β•β•β•šβ•β•β•β•β•β• β•šβ•β•β•β•β•β•β•β•šβ•β• β•šβ•β• β•‘ +β•‘ β•‘ +β•‘ File Streaming Bot v{VERSION} β•‘ +β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β• +""" + print(banner) + + +def schedule_index_ensure() -> None: + task = asyncio.create_task( + db.ensure_indexes(raise_on_error=False), + name="ensure_database_indexes" + ) + + def _log_index_failure(done_task: asyncio.Task) -> None: + try: + created_indexes = done_task.result() + if created_indexes: + print(" βœ“ Database indexes ensured.") + else: + print(" β–Ά Database indexes could not be ensured during startup.") + except Exception as e: + logger.error(f"Background database index ensure failed: {e}", exc_info=True) + + task.add_done_callback(_log_index_failure) + + +async def import_plugins(): + print("╠════════════════════ IMPORTING PLUGINS ════════════════════╣") + plugins = glob.glob(PLUGIN_PATH) + if not plugins: + print(" β–Ά No plugins found to import!") + return 0 + + success_count = 0 + failed_plugins = [] + + for file_path in plugins: + try: + plugin_path = Path(file_path) + plugin_name = plugin_path.stem + import_path = f"Thunder.bot.plugins.{plugin_name}" + + spec = importlib.util.spec_from_file_location( + import_path, plugin_path + ) + if spec is None or spec.loader is None: + logger.error(f"Invalid plugin specification for {plugin_name}") + failed_plugins.append(plugin_name) + continue + + module = importlib.util.module_from_spec(spec) + sys.modules[import_path] = module + spec.loader.exec_module(module) + success_count += 1 + + except Exception as e: + plugin_name = Path(file_path).stem + logger.error(f" βœ– Failed to import plugin {plugin_name}: {e}") + failed_plugins.append(plugin_name) + + print( + f" β–Ά Total: {len(plugins)} | Success: {success_count} | " + f"Failed: {len(failed_plugins)}" + ) + if failed_plugins: + print(f" β–Ά Failed plugins: {', '.join(failed_plugins)}") + + return success_count + + +async def start_services(): + start_time = datetime.now() + print_banner() + print("╔════════════════ INITIALIZING BOT SERVICES ════════════════╗") + + print(" β–Ά Starting Telegram Bot initialization...") + try: + try: + await StreamBot.start() + except FloodWait as e: + logger.debug(f"FloodWait in bot start, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await StreamBot.start() + + try: + bot_info = await StreamBot.get_me() + except FloodWait as e: + logger.debug(f"FloodWait in get_me, sleeping for {e.value}s") + await asyncio.sleep(e.value) + bot_info = await StreamBot.get_me() + + StreamBot.username = bot_info.username + print(f" βœ“ Bot initialized successfully as @{StreamBot.username}") + + await set_commands() + print(" βœ“ Bot commands set successfully.") + schedule_index_ensure() + + restart_message_data = await db.get_restart_message() + if restart_message_data: + try: + try: + await StreamBot.edit_message_text( + chat_id=restart_message_data["chat_id"], + message_id=restart_message_data["message_id"], + text=MSG_ADMIN_RESTART_DONE, + ) + except FloodWait as e: + logger.debug(f"FloodWait in restart message edit, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await StreamBot.edit_message_text( + chat_id=restart_message_data["chat_id"], + message_id=restart_message_data["message_id"], + text=MSG_ADMIN_RESTART_DONE, + ) + except MessageNotModified: + pass + await db.delete_restart_message( + restart_message_data["message_id"] + ) + except Exception as e: + logger.error( + f"Error processing restart message: {e}", exc_info=True + ) + else: + pass + + except Exception as e: + logger.error( + f" βœ– Failed to initialize Telegram Bot: {e}", exc_info=True + ) + return + + print(" β–Ά Starting Client initialization...") + try: + await initialize_clients() + except Exception as e: + logger.error(f" βœ– Failed to initialize clients: {e}", exc_info=True) + return + + await import_plugins() + + print(" β–Ά Starting Request Executor initialization...") + try: + request_executor_task = asyncio.create_task( + request_executor(), name="request_executor_task" + ) + print(" βœ“ Request executor service started") + except Exception as e: + logger.error( + f" βœ– Failed to start request executor: {e}", exc_info=True + ) + return + + print(" β–Ά Starting Web Server initialization...") + try: + app_runner = web.AppRunner(await web_server()) + await app_runner.setup() + bind_address = Var.BIND_ADDRESS + site = web.TCPSite(app_runner, bind_address, Var.PORT) + await site.start() + + keepalive_task = asyncio.create_task( + ping_server(), name="keepalive_task" + ) + print(" βœ“ Keep-alive service started") + token_cleanup_task = asyncio.create_task( + schedule_token_cleanup(), name="token_cleanup_task" + ) + + except Exception as e: + logger.error(f" βœ– Failed to start Web Server: {e}", exc_info=True) + if 'request_executor_task' in locals() and not request_executor_task.done(): + request_executor_task.cancel() + try: + await request_executor_task + except asyncio.CancelledError: + pass + try: + await StreamBot.stop() + except Exception: + pass + try: + await cleanup_clients() + except Exception: + pass + try: + await rate_limiter.shutdown() + except Exception: + pass + try: + await db.close() + except Exception as e: + logger.error(f"Error during database cleanup: {e}", exc_info=True) + try: + await drain_background_touch_tasks() + except Exception as e: + logger.error(f"Error during canonical touch task cleanup: {e}", exc_info=True) + return + + elapsed_time = (datetime.now() - start_time).total_seconds() + print("╠═══════════════════════════════════════════════════════════╣") + print(f" β–Ά Bot Name: {bot_info.first_name}") + print(f" β–Ά Username: @{bot_info.username}") + print(f" β–Ά Server: {bind_address}:{Var.PORT}") + print(f" β–Ά Startup Time: {elapsed_time:.2f} seconds") + print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") + print(" β–Ά Bot is now running! Press CTRL+C to stop.") + + background_tasks = [ + request_executor_task, + keepalive_task, + token_cleanup_task + ] + + try: + await idle() + finally: + print(" β–Ά Shutting down services...") + + for task in background_tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + try: + await rate_limiter.shutdown() + except Exception as e: + logger.error(f"Error during rate limiter cleanup: {e}") + + try: + await cleanup_clients() + except Exception as e: + logger.error(f"Error during client cleanup: {e}") + + try: + await drain_background_touch_tasks() + except Exception as e: + logger.error(f"Error during canonical touch task cleanup: {e}", exc_info=True) + + if 'app_runner' in locals() and app_runner is not None: + try: + await app_runner.cleanup() + except Exception as e: + logger.error(f"Error during web server cleanup: {e}") + + try: + await db.close() + print(" βœ“ Database connection closed") + except Exception as e: + logger.error("Error during database cleanup", exc_info=True) + + +async def schedule_token_cleanup(): + while True: + try: + await asyncio.sleep(3 * 3600) + await cleanup_expired_tokens() + except asyncio.CancelledError: + logger.debug("schedule_token_cleanup cancelled cleanly.") + break + except Exception as e: + logger.error(f"Token cleanup error: {e}", exc_info=True) + +if __name__ == '__main__': + try: + loop = asyncio.get_event_loop() + loop.run_until_complete(start_services()) + except KeyboardInterrupt: + print("╔═══════════════════════════════════════════════════════════╗") + print("β•‘ Bot stopped by user (CTRL+C) β•‘") + print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") + except Exception as e: + logger.error(f"An unexpected error occurred: {e}") diff --git a/Thunder/bot/__init__.py b/Thunder/bot/__init__.py old mode 100644 new mode 100755 diff --git a/Thunder/bot/clients.py b/Thunder/bot/clients.py old mode 100644 new mode 100755 index 14cf307..ac811a7 --- a/Thunder/bot/clients.py +++ b/Thunder/bot/clients.py @@ -1,82 +1,82 @@ -# Thunder/bot/clients.py - -import asyncio - -from pyrogram import Client -from pyrogram.errors import FloodWait - -from Thunder.bot import StreamBot, multi_clients, work_loads -from Thunder.utils.config_parser import TokenParser -from Thunder.utils.logger import logger -from Thunder.vars import Var - -async def cleanup_clients(): - for client in multi_clients.values(): - try: - try: - await client.stop() - except FloodWait as e: - await asyncio.sleep(e.value) - await client.stop() - except Exception as e: - logger.error(f"Error stopping client: {e}", exc_info=True) - -async def initialize_clients(): - print("╠══════════════════ INITIALIZING CLIENTS ═══════════════════╣") - multi_clients[0] = StreamBot - work_loads[0] = 0 - print(" βœ“ Primary client initialized") - try: - all_tokens = TokenParser().parse_from_env() - if not all_tokens: - print(" β—Ž No additional clients found.") - return - except Exception as e: - logger.error(f" βœ– Error parsing additional tokens: {e}", exc_info=True) - print(" β–Ά Primary client will be used.") - return - - async def start_client(client_id, token): - try: - if client_id == len(all_tokens): - await asyncio.sleep(2) - client = Client( - api_hash=Var.API_HASH, - api_id=Var.API_ID, - bot_token=token, - in_memory=True, - name=str(client_id), - no_updates=True, - max_concurrent_transmissions=1000, - sleep_threshold=Var.SLEEP_THRESHOLD - ) - try: - await client.start() - except FloodWait as e: - await asyncio.sleep(e.value) - await client.start() - work_loads[client_id] = 0 - print(f" β—Ž Client ID {client_id} started") - return client_id, client - except Exception as e: - logger.error(f" βœ– Failed to start Client ID {client_id}. Error: {e}", exc_info=True) - return None - - clients = await asyncio.gather(*[start_client(i, token) for i, token in all_tokens.items() if token]) - clients = [client for client in clients if client] - - multi_clients.update(dict(clients)) - - if len(multi_clients) > 1: - Var.MULTI_CLIENT = True - print("╠══════════════════════ MULTI-CLIENT ═══════════════════════╣") - print(f" β—Ž Total Clients: {len(multi_clients)} (Including primary client)") - - print(" β–Ά Initial workload distribution:") - for client_id, load in work_loads.items(): - print(f" β€’ Client {client_id}: {load} tasks") - - else: - print("╠═══════════════════════════════════════════════════════════╣") - print(" β–Ά No additional clients were initialized") - print(" β–Ά Primary client will handle all requests") +# Thunder/bot/clients.py + +import asyncio + +from pyrogram import Client +from pyrogram.errors import FloodWait + +from Thunder.bot import StreamBot, multi_clients, work_loads +from Thunder.utils.config_parser import TokenParser +from Thunder.utils.logger import logger +from Thunder.vars import Var + +async def cleanup_clients(): + for client in multi_clients.values(): + try: + try: + await client.stop() + except FloodWait as e: + await asyncio.sleep(e.value) + await client.stop() + except Exception as e: + logger.error(f"Error stopping client: {e}", exc_info=True) + +async def initialize_clients(): + print("╠══════════════════ INITIALIZING CLIENTS ═══════════════════╣") + multi_clients[0] = StreamBot + work_loads[0] = 0 + print(" βœ“ Primary client initialized") + try: + all_tokens = TokenParser().parse_from_env() + if not all_tokens: + print(" β—Ž No additional clients found.") + return + except Exception as e: + logger.error(f" βœ– Error parsing additional tokens: {e}", exc_info=True) + print(" β–Ά Primary client will be used.") + return + + async def start_client(client_id, token): + try: + if client_id == len(all_tokens): + await asyncio.sleep(2) + client = Client( + api_hash=Var.API_HASH, + api_id=Var.API_ID, + bot_token=token, + in_memory=True, + name=str(client_id), + no_updates=True, + max_concurrent_transmissions=1000, + sleep_threshold=Var.SLEEP_THRESHOLD + ) + try: + await client.start() + except FloodWait as e: + await asyncio.sleep(e.value) + await client.start() + work_loads[client_id] = 0 + print(f" β—Ž Client ID {client_id} started") + return client_id, client + except Exception as e: + logger.error(f" βœ– Failed to start Client ID {client_id}. Error: {e}", exc_info=True) + return None + + clients = await asyncio.gather(*[start_client(i, token) for i, token in all_tokens.items() if token]) + clients = [client for client in clients if client] + + multi_clients.update(dict(clients)) + + if len(multi_clients) > 1: + Var.MULTI_CLIENT = True + print("╠══════════════════════ MULTI-CLIENT ═══════════════════════╣") + print(f" β—Ž Total Clients: {len(multi_clients)} (Including primary client)") + + print(" β–Ά Initial workload distribution:") + for client_id, load in work_loads.items(): + print(f" β€’ Client {client_id}: {load} tasks") + + else: + print("╠═══════════════════════════════════════════════════════════╣") + print(" β–Ά No additional clients were initialized") + print(" β–Ά Primary client will handle all requests") diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py old mode 100644 new mode 100755 index 82d0e72..e552e6e --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -1,52 +1,52 @@ -# Thunder/bot/plugins/admin.py - -import asyncio -import html -import os -import shutil -import sys -import time -from io import BytesIO - -import psutil -from pyrogram import filters -from pyrogram.client import Client -from pyrogram.enums import ParseMode -from pyrogram.errors import FloodWait, MessageNotModified -from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message - -from Thunder import StartTime, __version__ -from Thunder.bot import StreamBot, multi_clients, work_loads +# Thunder/bot/plugins/admin.py + +import asyncio +import html +import os +import shutil +import sys +import time +from io import BytesIO + +import psutil +from pyrogram import filters +from pyrogram.client import Client +from pyrogram.enums import ParseMode +from pyrogram.errors import FloodWait, MessageNotModified +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message + +from Thunder import StartTime, __version__ +from Thunder.bot import StreamBot, multi_clients, work_loads from Thunder.utils.bot_utils import get_user, reply -from Thunder.utils.broadcast import broadcast_message -from Thunder.utils.database import db -from Thunder.utils.human_readable import humanbytes -from Thunder.utils.logger import LOG_FILE, logger -from Thunder.utils.messages import ( - MSG_ADMIN_AUTH_LIST_HEADER, MSG_ADMIN_NO_BAN_REASON, - MSG_ADMIN_USER_BANNED, MSG_ADMIN_USER_UNBANNED, MSG_AUTHORIZE_FAILED, - MSG_AUTHORIZE_SUCCESS, MSG_AUTHORIZE_USAGE, MSG_AUTH_USER_INFO, - MSG_BAN_REASON_SUFFIX, MSG_BAN_USAGE, MSG_BROADCAST_USAGE, - MSG_BUTTON_CLOSE, MSG_CANNOT_BAN_OWNER, MSG_CHANNEL_BANNED, - MSG_CHANNEL_BANNED_REASON_SUFFIX, MSG_CHANNEL_NOT_BANNED, - MSG_CHANNEL_UNBANNED, MSG_DB_ERROR, MSG_DB_STATS, - MSG_DEAUTHORIZE_FAILED, MSG_DEAUTHORIZE_SUCCESS, - MSG_DEAUTHORIZE_USAGE, MSG_ERROR_GENERIC, MSG_INVALID_BROADCAST_CMD, - MSG_INVALID_USER_ID, MSG_LOG_FILE_CAPTION, MSG_LOG_FILE_EMPTY, - MSG_LOG_FILE_MISSING, MSG_NO_AUTH_USERS, MSG_RESTARTING, MSG_SHELL_ERROR, - MSG_SHELL_EXECUTING, MSG_SHELL_NO_OUTPUT, MSG_SHELL_OUTPUT, - MSG_SHELL_OUTPUT_STDERR, MSG_SHELL_OUTPUT_STDOUT, MSG_SHELL_USAGE, - MSG_SPEEDTEST_ERROR, MSG_SPEEDTEST_INIT, MSG_SPEEDTEST_RESULT, - MSG_STATUS_ERROR, MSG_SYSTEM_STATS, MSG_SYSTEM_STATUS, - MSG_UNBAN_USAGE, MSG_USER_BANNED_NOTIFICATION, - MSG_USER_NOT_IN_BAN_LIST, MSG_USER_UNBANNED_NOTIFICATION, - MSG_WORKLOAD_ITEM -) -from Thunder.utils.time_format import get_readable_time -from Thunder.utils.tokens import authorize, deauthorize, list_allowed -from Thunder.utils.speedtest import run_speedtest -from Thunder.vars import Var - +from Thunder.utils.broadcast import broadcast_message +from Thunder.utils.database import db +from Thunder.utils.human_readable import humanbytes +from Thunder.utils.logger import LOG_FILE, logger +from Thunder.utils.messages import ( + MSG_ADMIN_AUTH_LIST_HEADER, MSG_ADMIN_NO_BAN_REASON, + MSG_ADMIN_USER_BANNED, MSG_ADMIN_USER_UNBANNED, MSG_AUTHORIZE_FAILED, + MSG_AUTHORIZE_SUCCESS, MSG_AUTHORIZE_USAGE, MSG_AUTH_USER_INFO, + MSG_BAN_REASON_SUFFIX, MSG_BAN_USAGE, MSG_BROADCAST_USAGE, + MSG_BUTTON_CLOSE, MSG_CANNOT_BAN_OWNER, MSG_CHANNEL_BANNED, + MSG_CHANNEL_BANNED_REASON_SUFFIX, MSG_CHANNEL_NOT_BANNED, + MSG_CHANNEL_UNBANNED, MSG_DB_ERROR, MSG_DB_STATS, + MSG_DEAUTHORIZE_FAILED, MSG_DEAUTHORIZE_SUCCESS, + MSG_DEAUTHORIZE_USAGE, MSG_ERROR_GENERIC, MSG_INVALID_BROADCAST_CMD, + MSG_INVALID_USER_ID, MSG_LOG_FILE_CAPTION, MSG_LOG_FILE_EMPTY, + MSG_LOG_FILE_MISSING, MSG_NO_AUTH_USERS, MSG_RESTARTING, MSG_SHELL_ERROR, + MSG_SHELL_EXECUTING, MSG_SHELL_NO_OUTPUT, MSG_SHELL_OUTPUT, + MSG_SHELL_OUTPUT_STDERR, MSG_SHELL_OUTPUT_STDOUT, MSG_SHELL_USAGE, + MSG_SPEEDTEST_ERROR, MSG_SPEEDTEST_INIT, MSG_SPEEDTEST_RESULT, + MSG_STATUS_ERROR, MSG_SYSTEM_STATS, MSG_SYSTEM_STATUS, + MSG_UNBAN_USAGE, MSG_USER_BANNED_NOTIFICATION, + MSG_USER_NOT_IN_BAN_LIST, MSG_USER_UNBANNED_NOTIFICATION, + MSG_WORKLOAD_ITEM +) +from Thunder.utils.time_format import get_readable_time +from Thunder.utils.tokens import authorize, deauthorize, list_allowed +from Thunder.utils.speedtest import run_speedtest +from Thunder.vars import Var + owner_filter = filters.private & filters.user(Var.OWNER_ID) _MARKDOWN_ESCAPE_TRANS = str.maketrans({ @@ -61,190 +61,190 @@ def _escape_markdown(text: str) -> str: return text.translate(_MARKDOWN_ESCAPE_TRANS) - - -@StreamBot.on_message(filters.command("users") & owner_filter) -async def get_total_users(client: Client, message: Message): - try: - total = await db.total_users_count() - await reply(message, - text=MSG_DB_STATS.format(total_users=total), - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - except Exception as e: - logger.error(f"Error in get_total_users: {e}", exc_info=True) - await reply(message, text=MSG_DB_ERROR) - - -@StreamBot.on_message(filters.command("broadcast") & owner_filter) -async def broadcast_handler(client: Client, message: Message): - mode = "all" - if len(message.command) > 1: - arg = message.command[1].lower().strip() - if arg in ("help", "--help", "-h"): - return await reply(message, text=MSG_BROADCAST_USAGE, parse_mode=ParseMode.MARKDOWN) - if arg == "authorized": - mode = "authorized" - elif arg == "regular": - mode = "regular" - else: - safe_arg = arg.replace("`", "'") - await reply( - message, - text=f"❌ **Invalid argument:** `{safe_arg}`\n\n{MSG_BROADCAST_USAGE}", - parse_mode=ParseMode.MARKDOWN - ) - return - - if not message.reply_to_message: - return await reply(message, text=MSG_INVALID_BROADCAST_CMD) - - await broadcast_message(client, message, mode=mode) - - -@StreamBot.on_message(filters.command("status") & owner_filter) -async def show_status(client: Client, message: Message): - try: - uptime_str = get_readable_time(int(time.time() - StartTime)) - workload_items = "" - sorted_workloads = sorted(work_loads.items(), key=lambda item: item[0]) - for client_id, load_val in sorted_workloads: - workload_items += MSG_WORKLOAD_ITEM.format( - bot_name=f"πŸ”Ή Client {client_id}", load=load_val) - - total_workload = sum(work_loads.values()) - status_text_str = MSG_SYSTEM_STATUS.format( - uptime=uptime_str, active_bots=len(multi_clients), - total_workload=total_workload, workload_items=workload_items, - version=__version__) - await reply(message, - text=status_text_str, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - except Exception as e: - logger.error(f"Error in show_status: {e}", exc_info=True) - await reply(message, text=MSG_STATUS_ERROR) - - -@StreamBot.on_message(filters.command("stats") & owner_filter) -async def show_stats(client: Client, message: Message): - try: - sys_uptime = await asyncio.to_thread(psutil.boot_time) - sys_uptime_str = get_readable_time(int(time.time() - sys_uptime)) - bot_uptime_str = get_readable_time(int(time.time() - StartTime)) - net_io_counters = await asyncio.to_thread(psutil.net_io_counters) - cpu_percent = await asyncio.to_thread(psutil.cpu_percent, interval=0.5) - cpu_cores = await asyncio.to_thread(psutil.cpu_count, logical=False) - cpu_freq = await asyncio.to_thread(psutil.cpu_freq) - cpu_freq_ghz = f"{cpu_freq.current / 1000:.2f}" if cpu_freq else "N/A" - ram_info = await asyncio.to_thread(psutil.virtual_memory) - ram_total = humanbytes(ram_info.total) - ram_used = humanbytes(ram_info.used) - ram_free = humanbytes(ram_info.free) - - total_disk, used_disk, free_disk = await asyncio.to_thread( - shutil.disk_usage, '.') - - stats_text_val = MSG_SYSTEM_STATS.format( - sys_uptime=sys_uptime_str, - bot_uptime=bot_uptime_str, - cpu_percent=cpu_percent, - cpu_cores=cpu_cores, - cpu_freq=cpu_freq_ghz, - ram_total=ram_total, - ram_used=ram_used, - ram_free=ram_free, - disk_percent=psutil.disk_usage('.').percent, - total=humanbytes(total_disk), - used=humanbytes(used_disk), - free=humanbytes(free_disk), - upload=humanbytes(net_io_counters.bytes_sent), - download=humanbytes(net_io_counters.bytes_recv) - ) - - await reply(message, - text=stats_text_val, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - except Exception as e: - logger.error(f"Error in show_stats: {e}", exc_info=True) - await reply(message, text=MSG_STATUS_ERROR) - - -@StreamBot.on_message(filters.command("restart") & owner_filter) -async def restart_bot(client: Client, message: Message): - msg = await reply(message, text=MSG_RESTARTING) - await db.add_restart_message(msg.id, message.chat.id) - os.execv("/bin/bash", ["bash", "thunder.sh"]) - - -@StreamBot.on_message(filters.command("log") & owner_filter) -async def send_logs(client: Client, message: Message): - if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) == 0: - await reply( - message, - text=(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY) - ) - return - - try: - try: - await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) - except FloodWait as e: - logger.debug(f"FloodWait in log file sending, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) - except Exception as e: - logger.error(f"Error sending log file: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("authorize") & owner_filter) -async def authorize_command(client: Client, message: Message): - if len(message.command) != 2: - return await reply( - message, text=MSG_AUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) - - try: - user_id = int(message.command[1]) - success = await authorize(user_id, message.from_user.id) - await reply(message, - text=((MSG_AUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_AUTHORIZE_FAILED.format(user_id=user_id)))) - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in authorize_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("deauthorize") & owner_filter) -async def deauthorize_command(client: Client, message: Message): - if len(message.command) != 2: - return await reply( - message, text=MSG_DEAUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) - - try: - user_id = int(message.command[1]) - success = await deauthorize(user_id) - await reply(message, - text=((MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id)))) - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in deauthorize_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("listauth") & owner_filter) -async def list_authorized_command(client: Client, message: Message): - users = await list_allowed() - if not users: - return await reply( - message, text=MSG_NO_AUTH_USERS) - + + +@StreamBot.on_message(filters.command("users") & owner_filter) +async def get_total_users(client: Client, message: Message): + try: + total = await db.total_users_count() + await reply(message, + text=MSG_DB_STATS.format(total_users=total), + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) + except Exception as e: + logger.error(f"Error in get_total_users: {e}", exc_info=True) + await reply(message, text=MSG_DB_ERROR) + + +@StreamBot.on_message(filters.command("broadcast") & owner_filter) +async def broadcast_handler(client: Client, message: Message): + mode = "all" + if len(message.command) > 1: + arg = message.command[1].lower().strip() + if arg in ("help", "--help", "-h"): + return await reply(message, text=MSG_BROADCAST_USAGE, parse_mode=ParseMode.MARKDOWN) + if arg == "authorized": + mode = "authorized" + elif arg == "regular": + mode = "regular" + else: + safe_arg = arg.replace("`", "'") + await reply( + message, + text=f"❌ **Invalid argument:** `{safe_arg}`\n\n{MSG_BROADCAST_USAGE}", + parse_mode=ParseMode.MARKDOWN + ) + return + + if not message.reply_to_message: + return await reply(message, text=MSG_INVALID_BROADCAST_CMD) + + await broadcast_message(client, message, mode=mode) + + +@StreamBot.on_message(filters.command("status") & owner_filter) +async def show_status(client: Client, message: Message): + try: + uptime_str = get_readable_time(int(time.time() - StartTime)) + workload_items = "" + sorted_workloads = sorted(work_loads.items(), key=lambda item: item[0]) + for client_id, load_val in sorted_workloads: + workload_items += MSG_WORKLOAD_ITEM.format( + bot_name=f"πŸ”Ή Client {client_id}", load=load_val) + + total_workload = sum(work_loads.values()) + status_text_str = MSG_SYSTEM_STATUS.format( + uptime=uptime_str, active_bots=len(multi_clients), + total_workload=total_workload, workload_items=workload_items, + version=__version__) + await reply(message, + text=status_text_str, + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) + except Exception as e: + logger.error(f"Error in show_status: {e}", exc_info=True) + await reply(message, text=MSG_STATUS_ERROR) + + +@StreamBot.on_message(filters.command("stats") & owner_filter) +async def show_stats(client: Client, message: Message): + try: + sys_uptime = await asyncio.to_thread(psutil.boot_time) + sys_uptime_str = get_readable_time(int(time.time() - sys_uptime)) + bot_uptime_str = get_readable_time(int(time.time() - StartTime)) + net_io_counters = await asyncio.to_thread(psutil.net_io_counters) + cpu_percent = await asyncio.to_thread(psutil.cpu_percent, interval=0.5) + cpu_cores = await asyncio.to_thread(psutil.cpu_count, logical=False) + cpu_freq = await asyncio.to_thread(psutil.cpu_freq) + cpu_freq_ghz = f"{cpu_freq.current / 1000:.2f}" if cpu_freq else "N/A" + ram_info = await asyncio.to_thread(psutil.virtual_memory) + ram_total = humanbytes(ram_info.total) + ram_used = humanbytes(ram_info.used) + ram_free = humanbytes(ram_info.free) + + total_disk, used_disk, free_disk = await asyncio.to_thread( + shutil.disk_usage, '.') + + stats_text_val = MSG_SYSTEM_STATS.format( + sys_uptime=sys_uptime_str, + bot_uptime=bot_uptime_str, + cpu_percent=cpu_percent, + cpu_cores=cpu_cores, + cpu_freq=cpu_freq_ghz, + ram_total=ram_total, + ram_used=ram_used, + ram_free=ram_free, + disk_percent=psutil.disk_usage('.').percent, + total=humanbytes(total_disk), + used=humanbytes(used_disk), + free=humanbytes(free_disk), + upload=humanbytes(net_io_counters.bytes_sent), + download=humanbytes(net_io_counters.bytes_recv) + ) + + await reply(message, + text=stats_text_val, + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) + except Exception as e: + logger.error(f"Error in show_stats: {e}", exc_info=True) + await reply(message, text=MSG_STATUS_ERROR) + + +@StreamBot.on_message(filters.command("restart") & owner_filter) +async def restart_bot(client: Client, message: Message): + msg = await reply(message, text=MSG_RESTARTING) + await db.add_restart_message(msg.id, message.chat.id) + os.execv("/bin/bash", ["bash", "thunder.sh"]) + + +@StreamBot.on_message(filters.command("log") & owner_filter) +async def send_logs(client: Client, message: Message): + if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) == 0: + await reply( + message, + text=(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY) + ) + return + + try: + try: + await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) + except FloodWait as e: + logger.debug(f"FloodWait in log file sending, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) + except Exception as e: + logger.error(f"Error sending log file: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("authorize") & owner_filter) +async def authorize_command(client: Client, message: Message): + if len(message.command) != 2: + return await reply( + message, text=MSG_AUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) + + try: + user_id = int(message.command[1]) + success = await authorize(user_id, message.from_user.id) + await reply(message, + text=((MSG_AUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_AUTHORIZE_FAILED.format(user_id=user_id)))) + except ValueError: + await reply(message, text=MSG_INVALID_USER_ID) + except Exception as e: + logger.error(f"Error in authorize_command: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("deauthorize") & owner_filter) +async def deauthorize_command(client: Client, message: Message): + if len(message.command) != 2: + return await reply( + message, text=MSG_DEAUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) + + try: + user_id = int(message.command[1]) + success = await deauthorize(user_id) + await reply(message, + text=((MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id)))) + except ValueError: + await reply(message, text=MSG_INVALID_USER_ID) + except Exception as e: + logger.error(f"Error in deauthorize_command: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("listauth") & owner_filter) +async def list_authorized_command(client: Client, message: Message): + users = await list_allowed() + if not users: + return await reply( + message, text=MSG_NO_AUTH_USERS) + text = MSG_ADMIN_AUTH_LIST_HEADER for i, user in enumerate(users, 1): display_name = "Unknown" @@ -263,267 +263,267 @@ async def list_authorized_command(client: Client, message: Message): authorized_by=user['authorized_by'], auth_time=user['authorized_at'] ) - - await reply(message, - text=text, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - - -@StreamBot.on_message(filters.command("ban") & owner_filter) -async def ban_command(client: Client, message: Message): - if len(message.command) < 2: - return await reply(message, text=MSG_BAN_USAGE) - - try: - target_id = int(message.command[1]) - reason = " ".join(message.command[2:]) or MSG_ADMIN_NO_BAN_REASON - banned_by_id = message.from_user.id if message.from_user else None - - if target_id == Var.OWNER_ID: - return await reply(message, text=MSG_CANNOT_BAN_OWNER) - - if target_id < 0: - await db.add_banned_channel( - channel_id=target_id, - reason=reason, - banned_by=banned_by_id - ) - text = MSG_CHANNEL_BANNED.format(channel_id=target_id) - if reason != MSG_ADMIN_NO_BAN_REASON: - text += MSG_CHANNEL_BANNED_REASON_SUFFIX.format(reason=reason) - await reply(message, text=text) - try: - try: - await client.leave_chat(target_id) - except FloodWait as e: - logger.debug(f"FloodWait in leave_chat, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.leave_chat(target_id) - except Exception as e: - logger.warning(f"Could not leave banned channel {target_id}: {e}", exc_info=True) - else: - await db.add_banned_user( - user_id=target_id, - reason=reason, - banned_by=banned_by_id - ) - text = MSG_ADMIN_USER_BANNED.format(user_id=target_id) - if reason != MSG_ADMIN_NO_BAN_REASON: - text += MSG_BAN_REASON_SUFFIX.format(reason=reason) - await reply(message, text=text) - try: - try: - await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) - except FloodWait as e: - logger.debug(f"FloodWait in ban notification, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) - except Exception as e: - logger.warning(f"Could not notify banned user {target_id}: {e}", exc_info=True) - - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in ban_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("unban") & owner_filter) -async def unban_command(client: Client, message: Message): - if len(message.command) != 2: - return await reply(message, text=MSG_UNBAN_USAGE) - - try: - target_id = int(message.command[1]) - - if target_id < 0: - if await db.remove_banned_channel(channel_id=target_id): - await reply(message, text=MSG_CHANNEL_UNBANNED.format(channel_id=target_id)) - else: - await reply(message, text=MSG_CHANNEL_NOT_BANNED.format(channel_id=target_id)) - else: - if await db.remove_banned_user(user_id=target_id): - await reply(message, text=MSG_ADMIN_USER_UNBANNED.format(user_id=target_id)) - try: - try: - await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) - except FloodWait as e: - logger.debug(f"FloodWait in unban notification, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) - except Exception as e: - logger.warning(f"Could not notify unbanned user {target_id}: {e}", exc_info=True) - else: - await reply(message, text=MSG_USER_NOT_IN_BAN_LIST.format(user_id=target_id)) - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in unban_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("shell") & owner_filter) -async def run_shell_command(client: Client, message: Message): - if len(message.command) < 2: - return await reply( - message, text=MSG_SHELL_USAGE, parse_mode=ParseMode.HTML) - - command = " ".join(message.command[1:]) - status_msg = await reply(message, - text=MSG_SHELL_EXECUTING.format( - command=html.escape(command)), - parse_mode=ParseMode.HTML) - - try: - process = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await process.communicate() - - output = "" - if stdout: - output += MSG_SHELL_OUTPUT_STDOUT.format( - output=html.escape(stdout.decode(errors='ignore'))) - if stderr: - output += MSG_SHELL_OUTPUT_STDERR.format( - error=html.escape(stderr.decode(errors='ignore'))) - - output = output.strip() or MSG_SHELL_NO_OUTPUT - - try: - await status_msg.delete() - except FloodWait as e: - logger.debug(f"FloodWait in shell status message delete, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.delete() - - if len(output) > 4096: - file = BytesIO(output.encode()) - file.name = "shell_output.txt" - try: - await message.reply_document( - file, - caption=MSG_SHELL_OUTPUT.format( - command=html.escape(command))) - except FloodWait as e: - logger.debug(f"FloodWait in shell output document, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_document( - file, - caption=MSG_SHELL_OUTPUT.format( - command=html.escape(command))) - else: - await reply(message, text=output, parse_mode=ParseMode.HTML) - - except Exception as e: - try: - try: - await status_msg.edit_text( - MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - except FloodWait as e: - logger.debug(f"FloodWait in shell error message edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - except MessageNotModified: - pass - except Exception: - await reply( - message, - text=MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - - -@StreamBot.on_message(filters.command("speedtest") & owner_filter) -async def speedtest_command(client: Client, message: Message): - status_msg = await reply(message, text=MSG_SPEEDTEST_INIT) - try: - result_dict, image_url = await run_speedtest() - if result_dict is None: - try: - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest error edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except MessageNotModified: - pass - return - - result_text = _format_speedtest_result(result_dict) - await _send_result(message, status_msg, result_text, image_url) - except Exception as e: - logger.error(f"Error in speedtest_command: {e}", exc_info=True) - try: - try: - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest exception error edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except MessageNotModified: - pass - except Exception: - await reply(message, text=MSG_SPEEDTEST_ERROR) - - -def _format_speedtest_result(result_dict: dict) -> str: - s, c = result_dict['server'], result_dict['client'] - return MSG_SPEEDTEST_RESULT.format( - download_mbps=_fmt(result_dict['download_mbps']), - upload_mbps=_fmt(result_dict['upload_mbps']), - download_bps=humanbytes(result_dict['download_bps']), - upload_bps=humanbytes(result_dict['upload_bps']), - ping=_fmt(result_dict['ping']), - timestamp=result_dict['timestamp'], - bytes_sent=humanbytes(result_dict['bytes_sent']), - bytes_received=humanbytes(result_dict['bytes_received']), - server_name=s['name'], - server_country=f"{s['country']} ({s['cc']})", - server_sponsor=s['sponsor'], - server_latency=_fmt(s['latency']), - server_lat=_fmt(s['lat'], 4), - server_lon=_fmt(s['lon'], 4), - client_ip=c['ip'], - client_lat=_fmt(c['lat'], 4), - client_lon=_fmt(c['lon'], 4), - client_isp=c['isp'], - client_isprating=c['isprating'], - client_country=c['country'] - ) - - -async def _send_result(message: Message, status_msg: Message, result_text: str, image_url: str): - if image_url: - try: - await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest photo reply, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) - try: - await status_msg.delete() - except FloodWait as e: - logger.debug(f"FloodWait in speedtest status delete, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.delete() - else: - try: - await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest result edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) - except MessageNotModified: - pass - - -def _fmt(value, decimals: int = 2) -> str: + + await reply(message, + text=text, + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) + + +@StreamBot.on_message(filters.command("ban") & owner_filter) +async def ban_command(client: Client, message: Message): + if len(message.command) < 2: + return await reply(message, text=MSG_BAN_USAGE) + + try: + target_id = int(message.command[1]) + reason = " ".join(message.command[2:]) or MSG_ADMIN_NO_BAN_REASON + banned_by_id = message.from_user.id if message.from_user else None + + if target_id == Var.OWNER_ID: + return await reply(message, text=MSG_CANNOT_BAN_OWNER) + + if target_id < 0: + await db.add_banned_channel( + channel_id=target_id, + reason=reason, + banned_by=banned_by_id + ) + text = MSG_CHANNEL_BANNED.format(channel_id=target_id) + if reason != MSG_ADMIN_NO_BAN_REASON: + text += MSG_CHANNEL_BANNED_REASON_SUFFIX.format(reason=reason) + await reply(message, text=text) + try: + try: + await client.leave_chat(target_id) + except FloodWait as e: + logger.debug(f"FloodWait in leave_chat, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await client.leave_chat(target_id) + except Exception as e: + logger.warning(f"Could not leave banned channel {target_id}: {e}", exc_info=True) + else: + await db.add_banned_user( + user_id=target_id, + reason=reason, + banned_by=banned_by_id + ) + text = MSG_ADMIN_USER_BANNED.format(user_id=target_id) + if reason != MSG_ADMIN_NO_BAN_REASON: + text += MSG_BAN_REASON_SUFFIX.format(reason=reason) + await reply(message, text=text) + try: + try: + await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) + except FloodWait as e: + logger.debug(f"FloodWait in ban notification, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) + except Exception as e: + logger.warning(f"Could not notify banned user {target_id}: {e}", exc_info=True) + + except ValueError: + await reply(message, text=MSG_INVALID_USER_ID) + except Exception as e: + logger.error(f"Error in ban_command: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("unban") & owner_filter) +async def unban_command(client: Client, message: Message): + if len(message.command) != 2: + return await reply(message, text=MSG_UNBAN_USAGE) + + try: + target_id = int(message.command[1]) + + if target_id < 0: + if await db.remove_banned_channel(channel_id=target_id): + await reply(message, text=MSG_CHANNEL_UNBANNED.format(channel_id=target_id)) + else: + await reply(message, text=MSG_CHANNEL_NOT_BANNED.format(channel_id=target_id)) + else: + if await db.remove_banned_user(user_id=target_id): + await reply(message, text=MSG_ADMIN_USER_UNBANNED.format(user_id=target_id)) + try: + try: + await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) + except FloodWait as e: + logger.debug(f"FloodWait in unban notification, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) + except Exception as e: + logger.warning(f"Could not notify unbanned user {target_id}: {e}", exc_info=True) + else: + await reply(message, text=MSG_USER_NOT_IN_BAN_LIST.format(user_id=target_id)) + except ValueError: + await reply(message, text=MSG_INVALID_USER_ID) + except Exception as e: + logger.error(f"Error in unban_command: {e}", exc_info=True) + await reply(message, text=MSG_ERROR_GENERIC) + + +@StreamBot.on_message(filters.command("shell") & owner_filter) +async def run_shell_command(client: Client, message: Message): + if len(message.command) < 2: + return await reply( + message, text=MSG_SHELL_USAGE, parse_mode=ParseMode.HTML) + + command = " ".join(message.command[1:]) + status_msg = await reply(message, + text=MSG_SHELL_EXECUTING.format( + command=html.escape(command)), + parse_mode=ParseMode.HTML) + + try: + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + stdout, stderr = await process.communicate() + + output = "" + if stdout: + output += MSG_SHELL_OUTPUT_STDOUT.format( + output=html.escape(stdout.decode(errors='ignore'))) + if stderr: + output += MSG_SHELL_OUTPUT_STDERR.format( + error=html.escape(stderr.decode(errors='ignore'))) + + output = output.strip() or MSG_SHELL_NO_OUTPUT + + try: + await status_msg.delete() + except FloodWait as e: + logger.debug(f"FloodWait in shell status message delete, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await status_msg.delete() + + if len(output) > 4096: + file = BytesIO(output.encode()) + file.name = "shell_output.txt" + try: + await message.reply_document( + file, + caption=MSG_SHELL_OUTPUT.format( + command=html.escape(command))) + except FloodWait as e: + logger.debug(f"FloodWait in shell output document, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await message.reply_document( + file, + caption=MSG_SHELL_OUTPUT.format( + command=html.escape(command))) + else: + await reply(message, text=output, parse_mode=ParseMode.HTML) + + except Exception as e: + try: + try: + await status_msg.edit_text( + MSG_SHELL_ERROR.format(error=html.escape(str(e))), + parse_mode=ParseMode.HTML) + except FloodWait as e: + logger.debug(f"FloodWait in shell error message edit, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await status_msg.edit_text( + MSG_SHELL_ERROR.format(error=html.escape(str(e))), + parse_mode=ParseMode.HTML) + except MessageNotModified: + pass + except Exception: + await reply( + message, + text=MSG_SHELL_ERROR.format(error=html.escape(str(e))), + parse_mode=ParseMode.HTML) + + +@StreamBot.on_message(filters.command("speedtest") & owner_filter) +async def speedtest_command(client: Client, message: Message): + status_msg = await reply(message, text=MSG_SPEEDTEST_INIT) + try: + result_dict, image_url = await run_speedtest() + if result_dict is None: + try: + await status_msg.edit_text(MSG_SPEEDTEST_ERROR) + except FloodWait as e: + logger.debug(f"FloodWait in speedtest error edit, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await status_msg.edit_text(MSG_SPEEDTEST_ERROR) + except MessageNotModified: + pass + return + + result_text = _format_speedtest_result(result_dict) + await _send_result(message, status_msg, result_text, image_url) + except Exception as e: + logger.error(f"Error in speedtest_command: {e}", exc_info=True) + try: + try: + await status_msg.edit_text(MSG_SPEEDTEST_ERROR) + except FloodWait as e: + logger.debug(f"FloodWait in speedtest exception error edit, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await status_msg.edit_text(MSG_SPEEDTEST_ERROR) + except MessageNotModified: + pass + except Exception: + await reply(message, text=MSG_SPEEDTEST_ERROR) + + +def _format_speedtest_result(result_dict: dict) -> str: + s, c = result_dict['server'], result_dict['client'] + return MSG_SPEEDTEST_RESULT.format( + download_mbps=_fmt(result_dict['download_mbps']), + upload_mbps=_fmt(result_dict['upload_mbps']), + download_bps=humanbytes(result_dict['download_bps']), + upload_bps=humanbytes(result_dict['upload_bps']), + ping=_fmt(result_dict['ping']), + timestamp=result_dict['timestamp'], + bytes_sent=humanbytes(result_dict['bytes_sent']), + bytes_received=humanbytes(result_dict['bytes_received']), + server_name=s['name'], + server_country=f"{s['country']} ({s['cc']})", + server_sponsor=s['sponsor'], + server_latency=_fmt(s['latency']), + server_lat=_fmt(s['lat'], 4), + server_lon=_fmt(s['lon'], 4), + client_ip=c['ip'], + client_lat=_fmt(c['lat'], 4), + client_lon=_fmt(c['lon'], 4), + client_isp=c['isp'], + client_isprating=c['isprating'], + client_country=c['country'] + ) + + +async def _send_result(message: Message, status_msg: Message, result_text: str, image_url: str): + if image_url: + try: + await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) + except FloodWait as e: + logger.debug(f"FloodWait in speedtest photo reply, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) + try: + await status_msg.delete() + except FloodWait as e: + logger.debug(f"FloodWait in speedtest status delete, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await status_msg.delete() + else: + try: + await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) + except FloodWait as e: + logger.debug(f"FloodWait in speedtest result edit, sleeping for {e.value}s") + await asyncio.sleep(e.value) + await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) + except MessageNotModified: + pass + + +def _fmt(value, decimals: int = 2) -> str: return f"{float(value):.{decimals}f}" diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py old mode 100644 new mode 100755 index ab6d71a..c1133b1 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -1,223 +1,223 @@ -# Thunder/bot/plugins/callbacks.py - -import asyncio - -from pyrogram import Client, filters -from pyrogram.errors import FloodWait, MessageNotModified, MessageDeleteForbidden -from pyrogram.types import (CallbackQuery, InlineKeyboardButton, - InlineKeyboardMarkup) - -from Thunder.bot import StreamBot -from Thunder.utils.broadcast import broadcast_ids -from Thunder.utils.decorators import owner_only -from Thunder.utils.logger import logger -from Thunder.utils.messages import ( - MSG_ABOUT, MSG_BROADCAST_CANCEL, MSG_BUTTON_ABOUT, MSG_BUTTON_CLOSE, - MSG_BUTTON_GET_HELP, MSG_BUTTON_GITHUB, MSG_BUTTON_JOIN_CHANNEL, - MSG_ERROR_BROADCAST_INSTRUCTION, MSG_ERROR_BROADCAST_RESTART, - MSG_ERROR_CALLBACK_UNSUPPORTED, MSG_HELP -) -from Thunder.vars import Var - -async def get_force_channel_button(client: Client): - if not Var.FORCE_CHANNEL_ID: - return None - try: - try: - chat = await client.get_chat(Var.FORCE_CHANNEL_ID) - except FloodWait as e: - await asyncio.sleep(e.value) - chat = await client.get_chat(Var.FORCE_CHANNEL_ID) - if chat: - invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None) - if invite_link: - return [InlineKeyboardButton( - MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title or "Channel"), - url=invite_link - )] - except Exception as e: - logger.error(f"Error getting force channel button: {e}", exc_info=True) - return None - -@StreamBot.on_callback_query(filters.regex(r"^help_command$")) -async def help_callback(client: Client, callback_query: CallbackQuery): - try: - await callback_query.answer() - buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] - force_button = await get_force_channel_button(client) - if force_button: - buttons.append(force_button) - buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) - try: - await callback_query.message.edit_text( - text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except MessageNotModified: - pass - except Exception as e: - logger.error(f"Error in help callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - -@StreamBot.on_callback_query(filters.regex(r"^about_command$")) -async def about_callback(client: Client, callback_query: CallbackQuery): - try: - await callback_query.answer() - buttons = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], - [ - InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel") - ] - ] - try: - await callback_query.message.edit_text( - text=MSG_ABOUT, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - text=MSG_ABOUT, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except MessageNotModified: - pass - except Exception as e: - logger.error(f"Error in about callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - -@StreamBot.on_callback_query(filters.regex(r"^restart_broadcast$")) -async def restart_broadcast_callback(client: Client, callback_query: CallbackQuery): - if not await owner_only(client, callback_query): - return - try: - try: - await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True) - buttons = [ - [ - InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel") - ] - ] - try: - await callback_query.message.edit_text( - MSG_ERROR_BROADCAST_INSTRUCTION, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - MSG_ERROR_BROADCAST_INSTRUCTION, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except Exception as e: - logger.error(f"Error in restart broadcast callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - -@StreamBot.on_callback_query(filters.regex(r"^close_panel$")) -async def close_panel_callback(client: Client, callback_query: CallbackQuery): - try: - try: - await callback_query.answer() - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer() - try: - try: - await callback_query.message.delete() - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.delete() - except MessageDeleteForbidden: - logger.debug(f"Failed to delete callback query message due to permissions. Message ID: {callback_query.message.id}") - except Exception as e: - logger.error(f"Error deleting callback query message: {e}", exc_info=True) - - if callback_query.message.reply_to_message: - try: - reply_msg = callback_query.message.reply_to_message - try: - await reply_msg.delete() - except FloodWait as e: - await asyncio.sleep(e.value) - await reply_msg.delete() - except MessageDeleteForbidden: - logger.debug(f"Failed to delete replied message due to permissions. Message ID: {reply_msg.id}") - except Exception as e: - logger.error(f"Error deleting replied message: {e}", exc_info=True) - except Exception as e: - logger.error(f"General error in close panel callback: {e}", exc_info=True) - -@StreamBot.on_callback_query(filters.regex(r"^cancel_")) -async def cancel_broadcast(client: Client, callback_query: CallbackQuery): - try: - broadcast_id = callback_query.data.split("_")[1] - if broadcast_id in broadcast_ids: - broadcast_ids[broadcast_id]["cancelled"] = True - try: - await callback_query.message.edit_text( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) - ) - else: - try: - await callback_query.answer( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), - show_alert=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), - show_alert=True - ) - except Exception as e: - logger.error(f"Error in cancel broadcast callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - -@StreamBot.on_callback_query() -async def fallback_callback(client: Client, callback_query: CallbackQuery): - try: - try: - await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) - except Exception as e: - logger.error(f"Error in fallback callback: {e}", exc_info=True) +# Thunder/bot/plugins/callbacks.py + +import asyncio + +from pyrogram import Client, filters +from pyrogram.errors import FloodWait, MessageNotModified, MessageDeleteForbidden +from pyrogram.types import (CallbackQuery, InlineKeyboardButton, + InlineKeyboardMarkup) + +from Thunder.bot import StreamBot +from Thunder.utils.broadcast import broadcast_ids +from Thunder.utils.decorators import owner_only +from Thunder.utils.logger import logger +from Thunder.utils.messages import ( + MSG_ABOUT, MSG_BROADCAST_CANCEL, MSG_BUTTON_ABOUT, MSG_BUTTON_CLOSE, + MSG_BUTTON_GET_HELP, MSG_BUTTON_GITHUB, MSG_BUTTON_JOIN_CHANNEL, + MSG_ERROR_BROADCAST_INSTRUCTION, MSG_ERROR_BROADCAST_RESTART, + MSG_ERROR_CALLBACK_UNSUPPORTED, MSG_HELP +) +from Thunder.vars import Var + +async def get_force_channel_button(client: Client): + if not Var.FORCE_CHANNEL_ID: + return None + try: + try: + chat = await client.get_chat(Var.FORCE_CHANNEL_ID) + except FloodWait as e: + await asyncio.sleep(e.value) + chat = await client.get_chat(Var.FORCE_CHANNEL_ID) + if chat: + invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None) + if invite_link: + return [InlineKeyboardButton( + MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title or "Channel"), + url=invite_link + )] + except Exception as e: + logger.error(f"Error getting force channel button: {e}", exc_info=True) + return None + +@StreamBot.on_callback_query(filters.regex(r"^help_command$")) +async def help_callback(client: Client, callback_query: CallbackQuery): + try: + await callback_query.answer() + buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] + force_button = await get_force_channel_button(client) + if force_button: + buttons.append(force_button) + buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) + try: + await callback_query.message.edit_text( + text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.message.edit_text( + text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True + ) + except MessageNotModified: + pass + except Exception as e: + logger.error(f"Error in help callback: {e}", exc_info=True) + try: + await callback_query.answer("An error occurred. Please try again.", show_alert=True) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.answer("An error occurred. Please try again.", show_alert=True) + +@StreamBot.on_callback_query(filters.regex(r"^about_command$")) +async def about_callback(client: Client, callback_query: CallbackQuery): + try: + await callback_query.answer() + buttons = [ + [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], + [ + InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel") + ] + ] + try: + await callback_query.message.edit_text( + text=MSG_ABOUT, + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.message.edit_text( + text=MSG_ABOUT, + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True + ) + except MessageNotModified: + pass + except Exception as e: + logger.error(f"Error in about callback: {e}", exc_info=True) + try: + await callback_query.answer("An error occurred. Please try again.", show_alert=True) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.answer("An error occurred. Please try again.", show_alert=True) + +@StreamBot.on_callback_query(filters.regex(r"^restart_broadcast$")) +async def restart_broadcast_callback(client: Client, callback_query: CallbackQuery): + if not await owner_only(client, callback_query): + return + try: + try: + await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True) + buttons = [ + [ + InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel") + ] + ] + try: + await callback_query.message.edit_text( + MSG_ERROR_BROADCAST_INSTRUCTION, + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.message.edit_text( + MSG_ERROR_BROADCAST_INSTRUCTION, + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True + ) + except Exception as e: + logger.error(f"Error in restart broadcast callback: {e}", exc_info=True) + try: + await callback_query.answer("An error occurred. Please try again.", show_alert=True) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.answer("An error occurred. Please try again.", show_alert=True) + +@StreamBot.on_callback_query(filters.regex(r"^close_panel$")) +async def close_panel_callback(client: Client, callback_query: CallbackQuery): + try: + try: + await callback_query.answer() + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.answer() + try: + try: + await callback_query.message.delete() + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.message.delete() + except MessageDeleteForbidden: + logger.debug(f"Failed to delete callback query message due to permissions. Message ID: {callback_query.message.id}") + except Exception as e: + logger.error(f"Error deleting callback query message: {e}", exc_info=True) + + if callback_query.message.reply_to_message: + try: + reply_msg = callback_query.message.reply_to_message + try: + await reply_msg.delete() + except FloodWait as e: + await asyncio.sleep(e.value) + await reply_msg.delete() + except MessageDeleteForbidden: + logger.debug(f"Failed to delete replied message due to permissions. Message ID: {reply_msg.id}") + except Exception as e: + logger.error(f"Error deleting replied message: {e}", exc_info=True) + except Exception as e: + logger.error(f"General error in close panel callback: {e}", exc_info=True) + +@StreamBot.on_callback_query(filters.regex(r"^cancel_")) +async def cancel_broadcast(client: Client, callback_query: CallbackQuery): + try: + broadcast_id = callback_query.data.split("_")[1] + if broadcast_id in broadcast_ids: + broadcast_ids[broadcast_id]["cancelled"] = True + try: + await callback_query.message.edit_text( + MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.message.edit_text( + MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) + ) + else: + try: + await callback_query.answer( + MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), + show_alert=True + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.answer( + MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), + show_alert=True + ) + except Exception as e: + logger.error(f"Error in cancel broadcast callback: {e}", exc_info=True) + try: + await callback_query.answer("An error occurred. Please try again.", show_alert=True) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.answer("An error occurred. Please try again.", show_alert=True) + +@StreamBot.on_callback_query() +async def fallback_callback(client: Client, callback_query: CallbackQuery): + try: + try: + await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) + except FloodWait as e: + await asyncio.sleep(e.value) + await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) + except Exception as e: + logger.error(f"Error in fallback callback: {e}", exc_info=True) diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py old mode 100644 new mode 100755 index 2b6344d..e5d1af2 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -1,280 +1,280 @@ -# Thunder/bot/plugins/common.py - -import asyncio -import time -from datetime import datetime, timedelta - -from pyrogram import Client, filters -from pyrogram.errors import FloodWait, MessageNotModified -from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup, - Message, User) - -from Thunder.bot import StreamBot -from Thunder.utils.bot_utils import (gen_dc_txt, get_user, log_newusr, - reply_user_err) -from Thunder.utils.database import db -from Thunder.utils.decorators import check_banned -from Thunder.utils.file_properties import get_fname, get_fsize, parse_fid -from Thunder.utils.force_channel import force_channel_check, get_force_info -from Thunder.utils.human_readable import humanbytes -from Thunder.utils.logger import logger -from Thunder.utils.messages import ( - MSG_ABOUT, MSG_BUTTON_ABOUT, MSG_BUTTON_CLOSE, MSG_BUTTON_GET_HELP, - MSG_BUTTON_GITHUB, MSG_BUTTON_JOIN_CHANNEL, MSG_BUTTON_VIEW_PROFILE, - MSG_COMMUNITY_CHANNEL, MSG_DC_ANON_ERROR, MSG_DC_FILE_ERROR, - MSG_DC_FILE_INFO, MSG_DC_INVALID_USAGE, MSG_DC_UNKNOWN, - MSG_ERROR_USER_INFO, MSG_FILE_TYPE_ANIMATION, MSG_FILE_TYPE_AUDIO, - MSG_FILE_TYPE_DOCUMENT, MSG_FILE_TYPE_PHOTO, MSG_FILE_TYPE_STICKER, - MSG_FILE_TYPE_UNKNOWN, MSG_FILE_TYPE_VIDEO, MSG_FILE_TYPE_VIDEO_NOTE, - MSG_FILE_TYPE_VOICE, MSG_HELP, MSG_PING_RESPONSE, MSG_PING_START, - MSG_TOKEN_ACTIVATED, MSG_TOKEN_FAILED, MSG_TOKEN_INVALID, MSG_WELCOME -) -from Thunder.vars import Var - -@StreamBot.on_message(filters.command("start") & filters.private) -async def start_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - user = msg.from_user - if user: - await log_newusr(bot, user.id, user.first_name) - - if len(msg.command) == 2: - payload = msg.command[1] - - if payload == "start": - pass - else: - token = await db.token_col.find_one({"token": payload}) - if token: - if token["user_id"] != user.id: - try: - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="This activation link is not for your account.", - error_id=str(int(time.time()))[-8:] - )) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="This activation link is not for your account.", - error_id=str(int(time.time()))[-8:] - )) - - if token.get("activated"): - try: - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="Token has already been activated.", - error_id=str(int(time.time()))[-8:] - )) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="Token has already been activated.", - error_id=str(int(time.time()))[-8:] - )) - - now = datetime.utcnow() - exp = now + timedelta(hours=Var.TOKEN_TTL_HOURS) - - await db.token_col.update_one( - {"token": payload, "user_id": user.id}, - {"$set": {"activated": True, "created_at": now, "expires_at": exp}} - ) - - hrs = round((exp - now).total_seconds() / 3600, 1) - try: - return await msg.reply_text(text=MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) - else: - try: - return await msg.reply_text(text=MSG_TOKEN_INVALID) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_INVALID) - - txt = MSG_WELCOME.format(user_name=user.first_name if user else "Unknown") - link, title = await get_force_info(bot) - if link: - txt += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=title)}" - - btns = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")], - [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] - ] - - if link: - btns.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)]) - - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - -@StreamBot.on_message(filters.command("help") & filters.private) -async def help_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - if msg.from_user: - await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) - - txt = MSG_HELP.format(max_files=Var.MAX_BATCH_FILES) - btns = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] - - link, title = await get_force_info(bot) - if link: - btns.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)]) - - btns.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - -@StreamBot.on_message(filters.command("about") & filters.private) -async def about_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - if msg.from_user: - await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) - - btns = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], - [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] - ] - - try: - await msg.reply_text(text=MSG_ABOUT, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=MSG_ABOUT, reply_markup=InlineKeyboardMarkup(btns)) - -async def send_user_dc(msg: Message, user: User): - txt = await gen_dc_txt(user) - url = f"https://t.me/{user.username}" if user.username else f"tg://user?id={user.id}" - btns = [ - [InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=url)], - [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] - ] - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - -async def send_file_dc(msg: Message, file_msg: Message): - try: - fname = get_fname(file_msg) or "Untitled File" - fsize = humanbytes(get_fsize(file_msg)) - - type_map = { - "document": MSG_FILE_TYPE_DOCUMENT, - "photo": MSG_FILE_TYPE_PHOTO, - "video": MSG_FILE_TYPE_VIDEO, - "audio": MSG_FILE_TYPE_AUDIO, - "voice": MSG_FILE_TYPE_VOICE, - "sticker": MSG_FILE_TYPE_STICKER, - "animation": MSG_FILE_TYPE_ANIMATION, - "video_note": MSG_FILE_TYPE_VIDEO_NOTE - } - - file_type = next((attr for attr in type_map if getattr(file_msg, attr, None)), "unknown") - type_display = type_map.get(file_type, MSG_FILE_TYPE_UNKNOWN) - - dc_id = MSG_DC_UNKNOWN - fid = parse_fid(file_msg) - if fid: - dc_id = fid.dc_id - - txt = MSG_DC_FILE_INFO.format( - file_name=fname, - file_size=fsize, - file_type=type_display, - dc_id=dc_id - ) - - btns = [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - - except Exception as e: - logger.error(f"File DC error: {e}", exc_info=True) - await reply_user_err(msg, MSG_DC_FILE_ERROR) - -@StreamBot.on_message(filters.command("dc")) -async def dc_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - if not await force_channel_check(bot, msg): - return - if not msg.from_user and not msg.reply_to_message: - return await reply_user_err(msg, MSG_DC_ANON_ERROR) - - args = msg.text.strip().split(maxsplit=1) - if len(args) > 1: - user = await get_user(bot, args[1].strip()) - if user: - await send_user_dc(msg, user) - else: - await reply_user_err(msg, MSG_ERROR_USER_INFO) - return - - if msg.reply_to_message: - ref = msg.reply_to_message - if ref.media: - await send_file_dc(msg, ref) - elif ref.from_user: - await send_user_dc(msg, ref.from_user) - else: - await reply_user_err(msg, MSG_DC_INVALID_USAGE) - return - - if msg.from_user: - await send_user_dc(msg, msg.from_user) - else: - await reply_user_err(msg, MSG_DC_ANON_ERROR) - -@StreamBot.on_message(filters.command("ping") & filters.private) -async def ping_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): - return - if not await force_channel_check(bot, msg): - return - start = time.time() - try: - sent = await msg.reply_text(text=MSG_PING_START) - except FloodWait as e: - await asyncio.sleep(e.value) - sent = await msg.reply_text(text=MSG_PING_START) - end = time.time() - ms = (end - start) * 1000 - - btns = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] - ] - - try: - await sent.edit_text( - MSG_PING_RESPONSE.format(time_taken_ms=ms), - reply_markup=InlineKeyboardMarkup(btns), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await sent.edit_text( - MSG_PING_RESPONSE.format(time_taken_ms=ms), - reply_markup=InlineKeyboardMarkup(btns), - disable_web_page_preview=True - ) - except MessageNotModified: - pass +# Thunder/bot/plugins/common.py + +import asyncio +import time +from datetime import datetime, timedelta + +from pyrogram import Client, filters +from pyrogram.errors import FloodWait, MessageNotModified +from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup, + Message, User) + +from Thunder.bot import StreamBot +from Thunder.utils.bot_utils import (gen_dc_txt, get_user, log_newusr, + reply_user_err) +from Thunder.utils.database import db +from Thunder.utils.decorators import check_banned +from Thunder.utils.file_properties import get_fname, get_fsize, parse_fid +from Thunder.utils.force_channel import force_channel_check, get_force_info +from Thunder.utils.human_readable import humanbytes +from Thunder.utils.logger import logger +from Thunder.utils.messages import ( + MSG_ABOUT, MSG_BUTTON_ABOUT, MSG_BUTTON_CLOSE, MSG_BUTTON_GET_HELP, + MSG_BUTTON_GITHUB, MSG_BUTTON_JOIN_CHANNEL, MSG_BUTTON_VIEW_PROFILE, + MSG_COMMUNITY_CHANNEL, MSG_DC_ANON_ERROR, MSG_DC_FILE_ERROR, + MSG_DC_FILE_INFO, MSG_DC_INVALID_USAGE, MSG_DC_UNKNOWN, + MSG_ERROR_USER_INFO, MSG_FILE_TYPE_ANIMATION, MSG_FILE_TYPE_AUDIO, + MSG_FILE_TYPE_DOCUMENT, MSG_FILE_TYPE_PHOTO, MSG_FILE_TYPE_STICKER, + MSG_FILE_TYPE_UNKNOWN, MSG_FILE_TYPE_VIDEO, MSG_FILE_TYPE_VIDEO_NOTE, + MSG_FILE_TYPE_VOICE, MSG_HELP, MSG_PING_RESPONSE, MSG_PING_START, + MSG_TOKEN_ACTIVATED, MSG_TOKEN_FAILED, MSG_TOKEN_INVALID, MSG_WELCOME +) +from Thunder.vars import Var + +@StreamBot.on_message(filters.command("start") & filters.private) +async def start_command(bot: Client, msg: Message): + if not await check_banned(bot, msg): + return + user = msg.from_user + if user: + await log_newusr(bot, user.id, user.first_name) + + if len(msg.command) == 2: + payload = msg.command[1] + + if payload == "start": + pass + else: + token = await db.token_col.find_one({"token": payload}) + if token: + if token["user_id"] != user.id: + try: + return await msg.reply_text(text=MSG_TOKEN_FAILED.format( + reason="This activation link is not for your account.", + error_id=str(int(time.time()))[-8:] + )) + except FloodWait as e: + await asyncio.sleep(e.value) + return await msg.reply_text(text=MSG_TOKEN_FAILED.format( + reason="This activation link is not for your account.", + error_id=str(int(time.time()))[-8:] + )) + + if token.get("activated"): + try: + return await msg.reply_text(text=MSG_TOKEN_FAILED.format( + reason="Token has already been activated.", + error_id=str(int(time.time()))[-8:] + )) + except FloodWait as e: + await asyncio.sleep(e.value) + return await msg.reply_text(text=MSG_TOKEN_FAILED.format( + reason="Token has already been activated.", + error_id=str(int(time.time()))[-8:] + )) + + now = datetime.utcnow() + exp = now + timedelta(hours=Var.TOKEN_TTL_HOURS) + + await db.token_col.update_one( + {"token": payload, "user_id": user.id}, + {"$set": {"activated": True, "created_at": now, "expires_at": exp}} + ) + + hrs = round((exp - now).total_seconds() / 3600, 1) + try: + return await msg.reply_text(text=MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) + except FloodWait as e: + await asyncio.sleep(e.value) + return await msg.reply_text(text=MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) + else: + try: + return await msg.reply_text(text=MSG_TOKEN_INVALID) + except FloodWait as e: + await asyncio.sleep(e.value) + return await msg.reply_text(text=MSG_TOKEN_INVALID) + + txt = MSG_WELCOME.format(user_name=user.first_name if user else "Unknown") + link, title = await get_force_info(bot) + if link: + txt += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=title)}" + + btns = [ + [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")], + [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + ] + + if link: + btns.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)]) + + try: + await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + except FloodWait as e: + await asyncio.sleep(e.value) + await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + +@StreamBot.on_message(filters.command("help") & filters.private) +async def help_command(bot: Client, msg: Message): + if not await check_banned(bot, msg): + return + if msg.from_user: + await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) + + txt = MSG_HELP.format(max_files=Var.MAX_BATCH_FILES) + btns = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] + + link, title = await get_force_info(bot) + if link: + btns.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)]) + + btns.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) + try: + await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + except FloodWait as e: + await asyncio.sleep(e.value) + await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + +@StreamBot.on_message(filters.command("about") & filters.private) +async def about_command(bot: Client, msg: Message): + if not await check_banned(bot, msg): + return + if msg.from_user: + await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) + + btns = [ + [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], + [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + ] + + try: + await msg.reply_text(text=MSG_ABOUT, reply_markup=InlineKeyboardMarkup(btns)) + except FloodWait as e: + await asyncio.sleep(e.value) + await msg.reply_text(text=MSG_ABOUT, reply_markup=InlineKeyboardMarkup(btns)) + +async def send_user_dc(msg: Message, user: User): + txt = await gen_dc_txt(user) + url = f"https://t.me/{user.username}" if user.username else f"tg://user?id={user.id}" + btns = [ + [InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=url)], + [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + ] + try: + await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + except FloodWait as e: + await asyncio.sleep(e.value) + await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + +async def send_file_dc(msg: Message, file_msg: Message): + try: + fname = get_fname(file_msg) or "Untitled File" + fsize = humanbytes(get_fsize(file_msg)) + + type_map = { + "document": MSG_FILE_TYPE_DOCUMENT, + "photo": MSG_FILE_TYPE_PHOTO, + "video": MSG_FILE_TYPE_VIDEO, + "audio": MSG_FILE_TYPE_AUDIO, + "voice": MSG_FILE_TYPE_VOICE, + "sticker": MSG_FILE_TYPE_STICKER, + "animation": MSG_FILE_TYPE_ANIMATION, + "video_note": MSG_FILE_TYPE_VIDEO_NOTE + } + + file_type = next((attr for attr in type_map if getattr(file_msg, attr, None)), "unknown") + type_display = type_map.get(file_type, MSG_FILE_TYPE_UNKNOWN) + + dc_id = MSG_DC_UNKNOWN + fid = parse_fid(file_msg) + if fid: + dc_id = fid.dc_id + + txt = MSG_DC_FILE_INFO.format( + file_name=fname, + file_size=fsize, + file_type=type_display, + dc_id=dc_id + ) + + btns = [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + try: + await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + except FloodWait as e: + await asyncio.sleep(e.value) + await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + + except Exception as e: + logger.error(f"File DC error: {e}", exc_info=True) + await reply_user_err(msg, MSG_DC_FILE_ERROR) + +@StreamBot.on_message(filters.command("dc")) +async def dc_command(bot: Client, msg: Message): + if not await check_banned(bot, msg): + return + if not await force_channel_check(bot, msg): + return + if not msg.from_user and not msg.reply_to_message: + return await reply_user_err(msg, MSG_DC_ANON_ERROR) + + args = msg.text.strip().split(maxsplit=1) + if len(args) > 1: + user = await get_user(bot, args[1].strip()) + if user: + await send_user_dc(msg, user) + else: + await reply_user_err(msg, MSG_ERROR_USER_INFO) + return + + if msg.reply_to_message: + ref = msg.reply_to_message + if ref.media: + await send_file_dc(msg, ref) + elif ref.from_user: + await send_user_dc(msg, ref.from_user) + else: + await reply_user_err(msg, MSG_DC_INVALID_USAGE) + return + + if msg.from_user: + await send_user_dc(msg, msg.from_user) + else: + await reply_user_err(msg, MSG_DC_ANON_ERROR) + +@StreamBot.on_message(filters.command("ping") & filters.private) +async def ping_command(bot: Client, msg: Message): + if not await check_banned(bot, msg): + return + if not await force_channel_check(bot, msg): + return + start = time.time() + try: + sent = await msg.reply_text(text=MSG_PING_START) + except FloodWait as e: + await asyncio.sleep(e.value) + sent = await msg.reply_text(text=MSG_PING_START) + end = time.time() + ms = (end - start) * 1000 + + btns = [ + [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + ] + + try: + await sent.edit_text( + MSG_PING_RESPONSE.format(time_taken_ms=ms), + reply_markup=InlineKeyboardMarkup(btns), + disable_web_page_preview=True + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await sent.edit_text( + MSG_PING_RESPONSE.format(time_taken_ms=ms), + reply_markup=InlineKeyboardMarkup(btns), + disable_web_page_preview=True + ) + except MessageNotModified: + pass diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py old mode 100644 new mode 100755 index 02c3368..638c594 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -1,11 +1,11 @@ -# Thunder/bot/plugins/stream.py - -import asyncio -import secrets -from typing import Any, Dict, Optional - -from pyrogram import Client, enums, filters -from pyrogram.errors import FloodWait, MessageNotModified, MessageDeleteForbidden, MessageIdInvalid +# Thunder/bot/plugins/stream.py + +import asyncio +import secrets +from typing import Any, Dict, Optional + +from pyrogram import Client, enums, filters +from pyrogram.errors import FloodWait, MessageNotModified, MessageDeleteForbidden, MessageIdInvalid from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup, Message) @@ -16,62 +16,62 @@ from Thunder.utils.database import db from Thunder.utils.decorators import (check_banned, get_shortener_status, require_token) -from Thunder.utils.force_channel import force_channel_check -from Thunder.utils.logger import logger -from Thunder.utils.messages import ( - MSG_BATCH_LINKS_READY, MSG_BUTTON_DOWNLOAD, MSG_BUTTON_START_CHAT, - MSG_BUTTON_STREAM_NOW, MSG_CRITICAL_ERROR, MSG_DM_BATCH_PREFIX, - MSG_DM_SINGLE_PREFIX, MSG_ERROR_DM_FAILED, MSG_ERROR_INVALID_NUMBER, - MSG_ERROR_NO_FILE, MSG_ERROR_NOT_ADMIN, MSG_ERROR_NUMBER_RANGE, - MSG_ERROR_PROCESSING_MEDIA, MSG_ERROR_REPLY_FILE, MSG_ERROR_START_BOT, - MSG_LINKS, MSG_NEW_FILE_REQUEST, MSG_PROCESSING_BATCH, - MSG_PROCESSING_FILE, MSG_PROCESSING_REQUEST, MSG_PROCESSING_RESULT, - MSG_PROCESSING_STATUS -) -from Thunder.utils.rate_limiter import handle_rate_limited_request -from Thunder.vars import Var - -BATCH_SIZE = 10 -LINK_CHUNK_SIZE = 20 -BATCH_UPDATE_INTERVAL = 5 -MESSAGE_DELAY = 0.5 - - -async def fwd_media(m_msg: Message) -> Optional[Message]: - try: - try: - return await m_msg.copy(chat_id=Var.BIN_CHANNEL) - except FloodWait as e: - await asyncio.sleep(e.value) - return await m_msg.copy(chat_id=Var.BIN_CHANNEL) - except Exception as e: - if "MEDIA_CAPTION_TOO_LONG" in str(e): - logger.debug(f"MEDIA_CAPTION_TOO_LONG error, retrying without caption: {e}") - try: - return await m_msg.copy(chat_id=Var.BIN_CHANNEL, caption=None) - except FloodWait as e: - await asyncio.sleep(e.value) - return await m_msg.copy(chat_id=Var.BIN_CHANNEL, caption=None) - logger.error(f"Error fwd_media copy: {e}", exc_info=True) - return None - - -def get_link_buttons(links): - return InlineKeyboardMarkup([[ - InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=links['stream_link']), - InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=links['online_link']) - ]]) - -async def validate_request_common(client: Client, message: Message) -> Optional[bool]: - if not await check_banned(client, message): - return None - if not await require_token(client, message): - return None - if not await force_channel_check(client, message): - return None - return await get_shortener_status(client, message) - - +from Thunder.utils.force_channel import force_channel_check +from Thunder.utils.logger import logger +from Thunder.utils.messages import ( + MSG_BATCH_LINKS_READY, MSG_BUTTON_DOWNLOAD, MSG_BUTTON_START_CHAT, + MSG_BUTTON_STREAM_NOW, MSG_CRITICAL_ERROR, MSG_DM_BATCH_PREFIX, + MSG_DM_SINGLE_PREFIX, MSG_ERROR_DM_FAILED, MSG_ERROR_INVALID_NUMBER, + MSG_ERROR_NO_FILE, MSG_ERROR_NOT_ADMIN, MSG_ERROR_NUMBER_RANGE, + MSG_ERROR_PROCESSING_MEDIA, MSG_ERROR_REPLY_FILE, MSG_ERROR_START_BOT, + MSG_LINKS, MSG_NEW_FILE_REQUEST, MSG_PROCESSING_BATCH, + MSG_PROCESSING_FILE, MSG_PROCESSING_REQUEST, MSG_PROCESSING_RESULT, + MSG_PROCESSING_STATUS +) +from Thunder.utils.rate_limiter import handle_rate_limited_request +from Thunder.vars import Var + +BATCH_SIZE = 10 +LINK_CHUNK_SIZE = 20 +BATCH_UPDATE_INTERVAL = 5 +MESSAGE_DELAY = 0.5 + + +async def fwd_media(m_msg: Message) -> Optional[Message]: + try: + try: + return await m_msg.copy(chat_id=Var.BIN_CHANNEL) + except FloodWait as e: + await asyncio.sleep(e.value) + return await m_msg.copy(chat_id=Var.BIN_CHANNEL) + except Exception as e: + if "MEDIA_CAPTION_TOO_LONG" in str(e): + logger.debug(f"MEDIA_CAPTION_TOO_LONG error, retrying without caption: {e}") + try: + return await m_msg.copy(chat_id=Var.BIN_CHANNEL, caption=None) + except FloodWait as e: + await asyncio.sleep(e.value) + return await m_msg.copy(chat_id=Var.BIN_CHANNEL, caption=None) + logger.error(f"Error fwd_media copy: {e}", exc_info=True) + return None + + +def get_link_buttons(links): + return InlineKeyboardMarkup([[ + InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=links['stream_link']), + InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=links['online_link']) + ]]) + +async def validate_request_common(client: Client, message: Message) -> Optional[bool]: + if not await check_banned(client, message): + return None + if not await require_token(client, message): + return None + if not await force_channel_check(client, message): + return None + return await get_shortener_status(client, message) + + async def send_channel_links( links: Dict[str, Any], source_info: str, @@ -121,224 +121,224 @@ async def send_channel_links( disable_web_page_preview=True, reply_to_message_id=reply_to_message_id ) - - -async def safe_edit_message(message: Message, text: str, **kwargs): - try: - try: - return await message.edit_text(text, **kwargs) - except FloodWait as e: - await asyncio.sleep(e.value) - return await message.edit_text(text, **kwargs) - except MessageNotModified: - pass - except MessageDeleteForbidden: - logger.debug(f"Failed to edit message {message.id} due to permissions.") - except Exception as e: - logger.error(f"Error editing message {message.id}: {e}", exc_info=True) - - -async def safe_delete_message(message: Message): - try: - try: - await message.delete() - except FloodWait as e: - await asyncio.sleep(e.value) - await message.delete() - except MessageDeleteForbidden: - logger.debug(f"Failed to delete message {message.id} due to permissions.") - except Exception as e: - logger.error(f"Error deleting message {message.id}: {e}", exc_info=True) - - -async def send_dm_links(bot: Client, user_id: int, links: Dict[str, Any], chat_title: str): - try: - dm_text = MSG_DM_SINGLE_PREFIX.format(chat_title=chat_title) + "\n" + \ - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ) - try: - await bot.send_message( - chat_id=user_id, - text=dm_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.MARKDOWN, - reply_markup=get_link_buttons(links) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await bot.send_message( - chat_id=user_id, - text=dm_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.MARKDOWN, - reply_markup=get_link_buttons(links) - ) - except Exception as e: - logger.error(f"Error sending DM to user {user_id}: {e}", exc_info=True) - - -async def send_link(msg: Message, links: Dict[str, Any]): - try: - await msg.reply_text( - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ), - quote=True, - parse_mode=enums.ParseMode.MARKDOWN, - disable_web_page_preview=True, - reply_markup=get_link_buttons(links) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text( - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ), - quote=True, - parse_mode=enums.ParseMode.MARKDOWN, - disable_web_page_preview=True, - reply_markup=get_link_buttons(links) - ) - - -@StreamBot.on_message(filters.command("link") & ~filters.private) -async def link_handler(bot: Client, msg: Message, **kwargs): - async def _actual_link_handler(client: Client, message: Message, **handler_kwargs): - shortener_val = await validate_request_common(client, message) - if shortener_val is None: - return - if message.from_user and not await db.is_user_exist(message.from_user.id): - invite_link = f"https://t.me/{client.me.username}?start=start" - try: - await message.reply_text( - MSG_ERROR_START_BOT.format(invite_link=invite_link), - disable_web_page_preview=True, - parse_mode=enums.ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]), - quote=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await message.reply_text( - MSG_ERROR_START_BOT.format(invite_link=invite_link), - disable_web_page_preview=True, - parse_mode=enums.ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]), - quote=True - ) - return - - if (message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP] - and not await is_admin(client, message.chat.id)): - await reply_user_err(message, MSG_ERROR_NOT_ADMIN) - return - - if not message.reply_to_message or not message.reply_to_message.media: - await reply_user_err( - message, - MSG_ERROR_REPLY_FILE if not message.reply_to_message else MSG_ERROR_NO_FILE) - return - - notification_msg = handler_kwargs.get('notification_msg') - - parts = message.text.split() - num_files = 1 - if len(parts) > 1: - try: - num_files = int(parts[1]) - if not 1 <= num_files <= Var.MAX_BATCH_FILES: - await reply_user_err( - message, - MSG_ERROR_NUMBER_RANGE.format(max_files=Var.MAX_BATCH_FILES)) - return - except ValueError: - await reply_user_err(message, MSG_ERROR_INVALID_NUMBER) - return - - try: - status_msg = await message.reply_text(MSG_PROCESSING_REQUEST, quote=True) - except FloodWait as e: - await asyncio.sleep(e.value) - status_msg = await message.reply_text(MSG_PROCESSING_REQUEST, quote=True) - shortener_val = handler_kwargs.get('shortener', shortener_val) - if num_files == 1: - await process_single(client, message, message.reply_to_message, status_msg, shortener_val, notification_msg=notification_msg) - else: - await process_batch(client, message, message.reply_to_message.id, num_files, status_msg, shortener_val, notification_msg=notification_msg) - - await handle_rate_limited_request(bot, msg, _actual_link_handler, **kwargs) - - -@StreamBot.on_message( - filters.private & - filters.incoming & - (filters.document | filters.video | filters.photo | filters.audio | - filters.voice | filters.animation | filters.video_note), - group=4 -) -async def private_receive_handler(bot: Client, msg: Message, **kwargs): - async def _actual_private_receive_handler(client: Client, message: Message, **handler_kwargs): - shortener_val = await validate_request_common(client, message) - if shortener_val is None: - return - if not message.from_user: - return - - notification_msg = handler_kwargs.get('notification_msg') - - await log_newusr(client, message.from_user.id, message.from_user.first_name or "") - try: - status_msg = await message.reply_text(MSG_PROCESSING_FILE, quote=True) - except FloodWait as e: - await asyncio.sleep(e.value) - status_msg = await message.reply_text(MSG_PROCESSING_FILE, quote=True) - await process_single(client, message, message, status_msg, shortener_val, notification_msg=notification_msg) - - await handle_rate_limited_request(bot, msg, _actual_private_receive_handler, **kwargs) - - -@StreamBot.on_message( - filters.channel & - filters.incoming & - (filters.document | filters.video | filters.audio) & - ~filters.chat(Var.BIN_CHANNEL), - group=-1 -) -async def channel_receive_handler(bot: Client, msg: Message): - async def _actual_channel_receive_handler(client: Client, message: Message, **handler_kwargs): - if not Var.CHANNEL: - return - notification_msg = handler_kwargs.get('notification_msg') - - is_banned_statically = hasattr(Var, 'BANNED_CHANNELS') and message.chat.id in Var.BANNED_CHANNELS - is_banned_dynamically = await db.is_channel_banned(message.chat.id) is not None - - if is_banned_statically or is_banned_dynamically: - try: - try: - await client.leave_chat(message.chat.id) - except FloodWait as e: - await asyncio.sleep(e.value) - await client.leave_chat(message.chat.id) - except Exception as e: - logger.error(f"Error leaving banned channel {message.chat.id}: {e}") - return - if not await is_admin(client, message.chat.id): - logger.debug( - f"Bot is not admin in channel {message.chat.id} " - f"({message.chat.title or 'Unknown'}). Ignoring message.") - return - + + +async def safe_edit_message(message: Message, text: str, **kwargs): + try: + try: + return await message.edit_text(text, **kwargs) + except FloodWait as e: + await asyncio.sleep(e.value) + return await message.edit_text(text, **kwargs) + except MessageNotModified: + pass + except MessageDeleteForbidden: + logger.debug(f"Failed to edit message {message.id} due to permissions.") + except Exception as e: + logger.error(f"Error editing message {message.id}: {e}", exc_info=True) + + +async def safe_delete_message(message: Message): + try: + try: + await message.delete() + except FloodWait as e: + await asyncio.sleep(e.value) + await message.delete() + except MessageDeleteForbidden: + logger.debug(f"Failed to delete message {message.id} due to permissions.") + except Exception as e: + logger.error(f"Error deleting message {message.id}: {e}", exc_info=True) + + +async def send_dm_links(bot: Client, user_id: int, links: Dict[str, Any], chat_title: str): + try: + dm_text = MSG_DM_SINGLE_PREFIX.format(chat_title=chat_title) + "\n" + \ + MSG_LINKS.format( + file_name=links['media_name'], + file_size=links['media_size'], + download_link=links['online_link'], + stream_link=links['stream_link'] + ) + try: + await bot.send_message( + chat_id=user_id, + text=dm_text, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.MARKDOWN, + reply_markup=get_link_buttons(links) + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await bot.send_message( + chat_id=user_id, + text=dm_text, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.MARKDOWN, + reply_markup=get_link_buttons(links) + ) + except Exception as e: + logger.error(f"Error sending DM to user {user_id}: {e}", exc_info=True) + + +async def send_link(msg: Message, links: Dict[str, Any]): + try: + await msg.reply_text( + MSG_LINKS.format( + file_name=links['media_name'], + file_size=links['media_size'], + download_link=links['online_link'], + stream_link=links['stream_link'] + ), + quote=True, + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + reply_markup=get_link_buttons(links) + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await msg.reply_text( + MSG_LINKS.format( + file_name=links['media_name'], + file_size=links['media_size'], + download_link=links['online_link'], + stream_link=links['stream_link'] + ), + quote=True, + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + reply_markup=get_link_buttons(links) + ) + + +@StreamBot.on_message(filters.command("link") & ~filters.private) +async def link_handler(bot: Client, msg: Message, **kwargs): + async def _actual_link_handler(client: Client, message: Message, **handler_kwargs): + shortener_val = await validate_request_common(client, message) + if shortener_val is None: + return + if message.from_user and not await db.is_user_exist(message.from_user.id): + invite_link = f"https://t.me/{client.me.username}?start=start" + try: + await message.reply_text( + MSG_ERROR_START_BOT.format(invite_link=invite_link), + disable_web_page_preview=True, + parse_mode=enums.ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]), + quote=True + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await message.reply_text( + MSG_ERROR_START_BOT.format(invite_link=invite_link), + disable_web_page_preview=True, + parse_mode=enums.ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]), + quote=True + ) + return + + if (message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP] + and not await is_admin(client, message.chat.id)): + await reply_user_err(message, MSG_ERROR_NOT_ADMIN) + return + + if not message.reply_to_message or not message.reply_to_message.media: + await reply_user_err( + message, + MSG_ERROR_REPLY_FILE if not message.reply_to_message else MSG_ERROR_NO_FILE) + return + + notification_msg = handler_kwargs.get('notification_msg') + + parts = message.text.split() + num_files = 1 + if len(parts) > 1: + try: + num_files = int(parts[1]) + if not 1 <= num_files <= Var.MAX_BATCH_FILES: + await reply_user_err( + message, + MSG_ERROR_NUMBER_RANGE.format(max_files=Var.MAX_BATCH_FILES)) + return + except ValueError: + await reply_user_err(message, MSG_ERROR_INVALID_NUMBER) + return + + try: + status_msg = await message.reply_text(MSG_PROCESSING_REQUEST, quote=True) + except FloodWait as e: + await asyncio.sleep(e.value) + status_msg = await message.reply_text(MSG_PROCESSING_REQUEST, quote=True) + shortener_val = handler_kwargs.get('shortener', shortener_val) + if num_files == 1: + await process_single(client, message, message.reply_to_message, status_msg, shortener_val, notification_msg=notification_msg) + else: + await process_batch(client, message, message.reply_to_message.id, num_files, status_msg, shortener_val, notification_msg=notification_msg) + + await handle_rate_limited_request(bot, msg, _actual_link_handler, **kwargs) + + +@StreamBot.on_message( + filters.private & + filters.incoming & + (filters.document | filters.video | filters.photo | filters.audio | + filters.voice | filters.animation | filters.video_note), + group=4 +) +async def private_receive_handler(bot: Client, msg: Message, **kwargs): + async def _actual_private_receive_handler(client: Client, message: Message, **handler_kwargs): + shortener_val = await validate_request_common(client, message) + if shortener_val is None: + return + if not message.from_user: + return + + notification_msg = handler_kwargs.get('notification_msg') + + await log_newusr(client, message.from_user.id, message.from_user.first_name or "") + try: + status_msg = await message.reply_text(MSG_PROCESSING_FILE, quote=True) + except FloodWait as e: + await asyncio.sleep(e.value) + status_msg = await message.reply_text(MSG_PROCESSING_FILE, quote=True) + await process_single(client, message, message, status_msg, shortener_val, notification_msg=notification_msg) + + await handle_rate_limited_request(bot, msg, _actual_private_receive_handler, **kwargs) + + +@StreamBot.on_message( + filters.channel & + filters.incoming & + (filters.document | filters.video | filters.audio) & + ~filters.chat(Var.BIN_CHANNEL), + group=-1 +) +async def channel_receive_handler(bot: Client, msg: Message): + async def _actual_channel_receive_handler(client: Client, message: Message, **handler_kwargs): + if not Var.CHANNEL: + return + notification_msg = handler_kwargs.get('notification_msg') + + is_banned_statically = hasattr(Var, 'BANNED_CHANNELS') and message.chat.id in Var.BANNED_CHANNELS + is_banned_dynamically = await db.is_channel_banned(message.chat.id) is not None + + if is_banned_statically or is_banned_dynamically: + try: + try: + await client.leave_chat(message.chat.id) + except FloodWait as e: + await asyncio.sleep(e.value) + await client.leave_chat(message.chat.id) + except Exception as e: + logger.error(f"Error leaving banned channel {message.chat.id}: {e}") + return + if not await is_admin(client, message.chat.id): + logger.debug( + f"Bot is not admin in channel {message.chat.id} " + f"({message.chat.title or 'Unknown'}). Ignoring message.") + return + try: shortener_val = await get_shortener_status(client, message) canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(message, fwd_media) @@ -369,27 +369,27 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha if notification_msg: try: - try: - await notification_msg.edit_text( - MSG_NEW_FILE_REQUEST.format( - source_info=source_info, - id_=message.chat.id, - online_link=links['online_link'], - stream_link=links['stream_link'] - ), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await notification_msg.edit_text( - MSG_NEW_FILE_REQUEST.format( - source_info=source_info, - id_=message.chat.id, - online_link=links['online_link'], - stream_link=links['stream_link'] - ), - disable_web_page_preview=True - ) + try: + await notification_msg.edit_text( + MSG_NEW_FILE_REQUEST.format( + source_info=source_info, + id_=message.chat.id, + online_link=links['online_link'], + stream_link=links['stream_link'] + ), + disable_web_page_preview=True + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await notification_msg.edit_text( + MSG_NEW_FILE_REQUEST.format( + source_info=source_info, + id_=message.chat.id, + online_link=links['online_link'], + stream_link=links['stream_link'] + ), + disable_web_page_preview=True + ) except Exception as e: logger.error(f"Error editing notification message with links: {e}", exc_info=True) await send_channel_links( @@ -407,43 +407,43 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha target_msg=stored_msg, reply_to_message_id=reply_to_message_id ) - - try: - try: - await message.edit_reply_markup(reply_markup=get_link_buttons(links)) - except FloodWait as e: - await asyncio.sleep(e.value) - await message.edit_reply_markup(reply_markup=get_link_buttons(links)) - except (MessageNotModified, MessageDeleteForbidden, MessageIdInvalid): - logger.debug(f"Failed to edit reply markup for message {message.id} due to not modified, permissions or invalid ID. Sending new link instead.") - await send_link(message, links) - except Exception as e: - logger.error(f"Error editing reply markup for message {message.id}: {e}", exc_info=True) - await send_link(message, links) - except Exception as e: - logger.error(f"Error in _actual_channel_receive_handler for message {message.id}: {e}", exc_info=True) - - rl_user_id = None - if msg.sender_chat and msg.sender_chat.id: - rl_user_id = msg.sender_chat.id - elif msg.from_user: - rl_user_id = msg.from_user.id - - if rl_user_id is None: - logger.debug(f"No identifiable user/channel for rate limiting for message {msg.id}. Skipping rate limit check and processing directly.") - await _actual_channel_receive_handler(bot, msg) - return - - await handle_rate_limited_request(bot, msg, _actual_channel_receive_handler, rl_user_id=rl_user_id) - - -async def process_single( - bot: Client, - msg: Message, - file_msg: Message, - status_msg: Message, - shortener_val: bool, - original_request_msg: Optional[Message] = None, + + try: + try: + await message.edit_reply_markup(reply_markup=get_link_buttons(links)) + except FloodWait as e: + await asyncio.sleep(e.value) + await message.edit_reply_markup(reply_markup=get_link_buttons(links)) + except (MessageNotModified, MessageDeleteForbidden, MessageIdInvalid): + logger.debug(f"Failed to edit reply markup for message {message.id} due to not modified, permissions or invalid ID. Sending new link instead.") + await send_link(message, links) + except Exception as e: + logger.error(f"Error editing reply markup for message {message.id}: {e}", exc_info=True) + await send_link(message, links) + except Exception as e: + logger.error(f"Error in _actual_channel_receive_handler for message {message.id}: {e}", exc_info=True) + + rl_user_id = None + if msg.sender_chat and msg.sender_chat.id: + rl_user_id = msg.sender_chat.id + elif msg.from_user: + rl_user_id = msg.from_user.id + + if rl_user_id is None: + logger.debug(f"No identifiable user/channel for rate limiting for message {msg.id}. Skipping rate limit check and processing directly.") + await _actual_channel_receive_handler(bot, msg) + return + + await handle_rate_limited_request(bot, msg, _actual_channel_receive_handler, rl_user_id=rl_user_id) + + +async def process_single( + bot: Client, + msg: Message, + file_msg: Message, + status_msg: Message, + shortener_val: bool, + original_request_msg: Optional[Message] = None, notification_msg: Optional[Message] = None ): try: @@ -467,33 +467,33 @@ async def process_single( return None links = await gen_links(stored_msg, shortener=shortener_val) canonical_reply_id = stored_msg.id - if notification_msg: - result = await safe_edit_message( - notification_msg, - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ), - parse_mode=enums.ParseMode.MARKDOWN, - disable_web_page_preview=True, - reply_markup=get_link_buttons(links) - ) - if not result: - await send_link(msg, links) + if notification_msg: + result = await safe_edit_message( + notification_msg, + MSG_LINKS.format( + file_name=links['media_name'], + file_size=links['media_size'], + download_link=links['online_link'], + stream_link=links['stream_link'] + ), + parse_mode=enums.ParseMode.MARKDOWN, + disable_web_page_preview=True, + reply_markup=get_link_buttons(links) + ) + if not result: + await send_link(msg, links) elif not original_request_msg: - await send_link(msg, links) - if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user and not original_request_msg: - await send_dm_links(bot, msg.from_user.id, links, msg.chat.title or "the chat") - source_msg = original_request_msg if original_request_msg else msg - source_info = "" - source_id = 0 - if source_msg.from_user: - source_info = source_msg.from_user.full_name - if not source_info: - source_info = f"@{source_msg.from_user.username}" if source_msg.from_user.username else "Unknown User" - source_id = source_msg.from_user.id + await send_link(msg, links) + if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user and not original_request_msg: + await send_dm_links(bot, msg.from_user.id, links, msg.chat.title or "the chat") + source_msg = original_request_msg if original_request_msg else msg + source_info = "" + source_id = 0 + if source_msg.from_user: + source_info = source_msg.from_user.full_name + if not source_info: + source_info = f"@{source_msg.from_user.username}" if source_msg.from_user.username else "Unknown User" + source_id = source_msg.from_user.id elif source_msg.chat.type == enums.ChatType.CHANNEL: source_info = source_msg.chat.title or "Unknown Channel" source_id = source_msg.chat.id @@ -517,152 +517,152 @@ async def process_single( ) if status_msg: await safe_delete_message(status_msg) - return links - except Exception as e: - logger.error(f"Error processing single file for message {file_msg.id}: {e}", exc_info=True) - if status_msg: - await safe_edit_message(status_msg, MSG_ERROR_PROCESSING_MEDIA) - - await notify_own(bot, MSG_CRITICAL_ERROR.format( - error=str(e), - error_id=secrets.token_hex(6) - )) - return None - - -async def process_batch( - bot: Client, - msg: Message, - start_id: int, - count: int, - status_msg: Message, - shortener_val: bool, - notification_msg: Optional[Message] = None -): - processed = 0 - failed = 0 - links_list = [] - for batch_start in range(0, count, BATCH_SIZE): - batch_size = min(BATCH_SIZE, count - batch_start) - batch_ids = list(range(start_id + batch_start, start_id + batch_start + batch_size)) - try: - try: - await status_msg.edit_text( - MSG_PROCESSING_BATCH.format( - batch_number=(batch_start // BATCH_SIZE) + 1, - total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, - file_count=batch_size - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_BATCH.format( - batch_number=(batch_start // BATCH_SIZE) + 1, - total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, - file_count=batch_size - ) - ) - except MessageNotModified: - pass - try: - try: - messages = await bot.get_messages(msg.chat.id, batch_ids) - except FloodWait as e: - await asyncio.sleep(e.value) - messages = await bot.get_messages(msg.chat.id, batch_ids) - if messages is None: - messages = [] - except Exception as e: - logger.error(f"Error getting messages in batch: {e}", exc_info=True) - messages = [] - for m in messages: - if m and m.media: - links = await process_single(bot, msg, m, None, shortener_val, original_request_msg=msg) - if links: - links_list.append(links['online_link']) - processed += 1 - else: - failed += 1 - else: - failed += 1 - if (processed + failed) % BATCH_UPDATE_INTERVAL == 0 or (processed + failed) == count: - try: - try: - await status_msg.edit_text( - MSG_PROCESSING_STATUS.format( - processed=processed, - total=count, - failed=failed - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_STATUS.format( - processed=processed, - total=count, - failed=failed - ) - ) - except MessageNotModified: - pass - for i in range(0, len(links_list), LINK_CHUNK_SIZE): - chunk = links_list[i:i+LINK_CHUNK_SIZE] - chunk_text = MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n{chr(10).join(chunk)}" - try: - await msg.reply_text( - chunk_text, - quote=True, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text( - chunk_text, - quote=True, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user: - try: - try: - await bot.send_message( - chat_id=msg.from_user.id, - text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await bot.send_message( - chat_id=msg.from_user.id, - text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - except Exception as e: - logger.error(f"Error sending DM in batch: {e}", exc_info=True) - await reply_user_err(msg, MSG_ERROR_DM_FAILED) - if i + LINK_CHUNK_SIZE < len(links_list): - await asyncio.sleep(MESSAGE_DELAY) - try: - await status_msg.edit_text( - MSG_PROCESSING_RESULT.format( - processed=processed, - total=count, - failed=failed - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_RESULT.format( - processed=processed, - total=count, - failed=failed - ) - ) - if notification_msg: - await safe_delete_message(notification_msg) + return links + except Exception as e: + logger.error(f"Error processing single file for message {file_msg.id}: {e}", exc_info=True) + if status_msg: + await safe_edit_message(status_msg, MSG_ERROR_PROCESSING_MEDIA) + + await notify_own(bot, MSG_CRITICAL_ERROR.format( + error=str(e), + error_id=secrets.token_hex(6) + )) + return None + + +async def process_batch( + bot: Client, + msg: Message, + start_id: int, + count: int, + status_msg: Message, + shortener_val: bool, + notification_msg: Optional[Message] = None +): + processed = 0 + failed = 0 + links_list = [] + for batch_start in range(0, count, BATCH_SIZE): + batch_size = min(BATCH_SIZE, count - batch_start) + batch_ids = list(range(start_id + batch_start, start_id + batch_start + batch_size)) + try: + try: + await status_msg.edit_text( + MSG_PROCESSING_BATCH.format( + batch_number=(batch_start // BATCH_SIZE) + 1, + total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, + file_count=batch_size + ) + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await status_msg.edit_text( + MSG_PROCESSING_BATCH.format( + batch_number=(batch_start // BATCH_SIZE) + 1, + total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, + file_count=batch_size + ) + ) + except MessageNotModified: + pass + try: + try: + messages = await bot.get_messages(msg.chat.id, batch_ids) + except FloodWait as e: + await asyncio.sleep(e.value) + messages = await bot.get_messages(msg.chat.id, batch_ids) + if messages is None: + messages = [] + except Exception as e: + logger.error(f"Error getting messages in batch: {e}", exc_info=True) + messages = [] + for m in messages: + if m and m.media: + links = await process_single(bot, msg, m, None, shortener_val, original_request_msg=msg) + if links: + links_list.append(links['online_link']) + processed += 1 + else: + failed += 1 + else: + failed += 1 + if (processed + failed) % BATCH_UPDATE_INTERVAL == 0 or (processed + failed) == count: + try: + try: + await status_msg.edit_text( + MSG_PROCESSING_STATUS.format( + processed=processed, + total=count, + failed=failed + ) + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await status_msg.edit_text( + MSG_PROCESSING_STATUS.format( + processed=processed, + total=count, + failed=failed + ) + ) + except MessageNotModified: + pass + for i in range(0, len(links_list), LINK_CHUNK_SIZE): + chunk = links_list[i:i+LINK_CHUNK_SIZE] + chunk_text = MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n{chr(10).join(chunk)}" + try: + await msg.reply_text( + chunk_text, + quote=True, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await msg.reply_text( + chunk_text, + quote=True, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML + ) + if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user: + try: + try: + await bot.send_message( + chat_id=msg.from_user.id, + text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await bot.send_message( + chat_id=msg.from_user.id, + text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML + ) + except Exception as e: + logger.error(f"Error sending DM in batch: {e}", exc_info=True) + await reply_user_err(msg, MSG_ERROR_DM_FAILED) + if i + LINK_CHUNK_SIZE < len(links_list): + await asyncio.sleep(MESSAGE_DELAY) + try: + await status_msg.edit_text( + MSG_PROCESSING_RESULT.format( + processed=processed, + total=count, + failed=failed + ) + ) + except FloodWait as e: + await asyncio.sleep(e.value) + await status_msg.edit_text( + MSG_PROCESSING_RESULT.format( + processed=processed, + total=count, + failed=failed + ) + ) + if notification_msg: + await safe_delete_message(notification_msg) diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py old mode 100644 new mode 100755 index ab450ee..7a31faa --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -1,10 +1,10 @@ -# Thunder/server/__init__.py - -from aiohttp import web -from .stream_routes import routes - - -async def web_server(): - web_app = web.Application(client_max_size=50 * 1024 * 1024) - web_app.add_routes(routes) - return web_app +# Thunder/server/__init__.py + +from aiohttp import web +from .stream_routes import routes + + +async def web_server(): + web_app = web.Application(client_max_size=50 * 1024 * 1024) + web_app.add_routes(routes) + return web_app diff --git a/Thunder/server/exceptions.py b/Thunder/server/exceptions.py old mode 100644 new mode 100755 index b25ef7e..da8095d --- a/Thunder/server/exceptions.py +++ b/Thunder/server/exceptions.py @@ -1,7 +1,7 @@ -# Thunder/server/exceptions.py - -class InvalidHash(Exception): - pass - -class FileNotFound(Exception): - pass +# Thunder/server/exceptions.py + +class InvalidHash(Exception): + pass + +class FileNotFound(Exception): + pass diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py old mode 100644 new mode 100755 diff --git a/Thunder/template/dl.html b/Thunder/template/dl.html old mode 100644 new mode 100755 index a0e3bb4..138cd56 --- a/Thunder/template/dl.html +++ b/Thunder/template/dl.html @@ -1,27 +1,27 @@ - - - - - - - - Downloading: {{ file_name }} - - - - -
-
-

Your download for {{ file_name }} should start automatically.

-

If it doesn't, please click here to download.

-
-
- + + + + + + + + Downloading: {{ file_name }} + + + + +
+
+

Your download for {{ file_name }} should start automatically.

+

If it doesn't, please click here to download.

+
+
+ \ No newline at end of file diff --git a/Thunder/template/req.html b/Thunder/template/req.html old mode 100644 new mode 100755 index c538902..d18cd64 --- a/Thunder/template/req.html +++ b/Thunder/template/req.html @@ -1,359 +1,359 @@ - - - - - - - {{ heading }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
-

{{ file_name }}

-
- - - - - Loading... - - - - - - Loading... - -
-
- -
- - -
-
-
- - - - If video not playing - Use External Player -
- - - - -
-
- - - - - - - - -
- -
-
-
- Space - Play / Pause -
-
- ←→ - Seek -
-
- ↑↓ - Volume -
-
- F - Fullscreen -
-
- M - Mute -
-
- <> - Speed -
-
-
- - - -
-
- - -
- - - - - - - - + + + + + + + {{ heading }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+

{{ file_name }}

+
+ + + + + Loading... + + + + + + Loading... + +
+
+ +
+ + +
+
+
+ + + + If video not playing + Use External Player +
+ + + + +
+
+ + + + + + + + +
+ +
+
+
+ Space + Play / Pause +
+
+ ←→ + Seek +
+
+ ↑↓ + Volume +
+
+ F + Fullscreen +
+
+ M + Mute +
+
+ <> + Speed +
+
+
+ + + +
+
+ + +
+ + + + + + + + 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
{command}
" -MSG_SHELL_OUTPUT = """**Shell Command Output:** -
{output}
""" -MSG_SHELL_OUTPUT_STDOUT = "[stdout]:\n
{output}
" -MSG_SHELL_OUTPUT_STDERR = "[stderr]:\n
{error}
" -MSG_SHELL_NO_OUTPUT = "βœ… Command Executed: No output." - -# ------ Admin View & Control ------ - -MSG_WORKLOAD_ITEM = " {bot_name}: {load}\n" -MSG_ADMIN_RESTART_DONE = "βœ… **Restart Successful!**" -MSG_RESTARTING = "♻️ **Updating and Restarting Bot...**\n\n> ⏳ Please wait a moment." -MSG_LOG_FILE_CAPTION = "πŸ“„ **System Logs**" - -MSG_LOG_FILE_EMPTY = "ℹ️ **Log File Empty:** No data found in the log file." -MSG_LOG_FILE_MISSING = "⚠️ **Log File Missing:** Could not find the log file." - -# ===================================================================================== -# ====== BUTTON TEXTS (User-facing) ====== -# ===================================================================================== - -MSG_BUTTON_STREAM_NOW = "πŸ–₯️ Stream" -MSG_BUTTON_DOWNLOAD = "πŸš€ Download" -MSG_BUTTON_GET_HELP = "πŸ“– Get Help" -MSG_BUTTON_CANCEL_BROADCAST = "πŸ›‘ Cancel Broadcast" -MSG_BUTTON_VIEW_PROFILE = "πŸ‘€ View User Profile" -MSG_BUTTON_ABOUT = "ℹ️ About Bot" -MSG_BUTTON_JOIN_CHANNEL = "πŸ“’ Join {channel_title}" -MSG_BUTTON_GITHUB = "πŸ› οΈ GitHub" -MSG_BUTTON_START_CHAT = "πŸ“© Start Chat" -MSG_BUTTON_CLOSE = "βœ– Close" - - -# ===================================================================================== -# ====== COMMAND RESPONSES (User-facing) ====== -# ===================================================================================== - -MSG_WELCOME = ( - "🌟 **Welcome, {user_name}!** 🌟\n\n" - "I'm **Thunder File to Link Bot** ⚑\n" - "I generate direct download and streaming links for your files.\n\n" - "**How to use:**\n" - "1. Send any file to me for private links.\n" - "2. In groups, reply to a file with `/link`.\n\n" - "Β» Use `/help` for all commands and detailed information.\n\n" - "πŸš€ Send a file to begin!" -) - -MSG_HELP = ( - "πŸ“˜ **Thunder Bot - Help Guide** πŸ“–\n\n" - "How to get direct download & streaming links:\n\n" - "**πŸš€ Private Chat (with me):**\n" - "> 1. Send me **any file** (document, video, audio, photo, etc.).\n" - "> 2. I'll instantly reply with your links! ⚑\n\n" - "**πŸ‘₯ Using in Groups:**\n" - "> β€’ Reply to any file with `/link`.\n" - "> β€’ **Batch Mode:** Reply to the **first** file with `/link ` (e.g., `/link 5` for 5 files, up to {max_files}).\n" - "> β€’ Bot needs administrator rights in the group to function.\n" - "> β€’ Links are posted in the group & sent to you privately.\n\n" - "**πŸ“’ Using in Channels:**\n" - "> β€’ Add me as an administrator with necessary permissions.\n" - "> β€’ I can be configured to auto-detect new media files.\n" - "> β€’ Inline stream/download buttons can be added to files automatically.\n" - "> β€’ Files from banned channels (owner configuration) are rejected.\n" - "> β€’ Auto-posting links if the bot has admin privileges with delete rights.\n\n" - "**βš™οΈ Available Commands:**\n" - "> `/start` πŸ‘‹ - Welcome message & quick start information.\n" - "> `/help` πŸ“– - Shows this help message.\n" - "> `/link ` πŸ”— - (Groups) Generate links. \n" - "> `/about` ℹ️ - Learn more about me and my features.\n" - "> `/ping` πŸ“‘ - Check my responsiveness and online status.\n" - "> `/dc` 🌍 - View DC information (for yourself, another user, or a file).\n\n" - "**πŸ’‘ Pro Tips:**\n" - "> β€’ You can forward files from other chats directly to me.\n" - "> β€’ If you encounter a rate limit message, please wait the specified time. ⏳\n" - "> β€’ For `/link` in groups to work reliably (and for private link delivery), ensure you've started a private chat with me first.\n" - "> β€’ Processing batch files might take a bit longer. Please be patient. 🐌\n\n" - "❓ Questions? Please ask in our support group!" -) - -MSG_ABOUT = ( - "🌟 **About Thunder File to Link Bot** ℹ️\n\n" - "I'm your go-to bot for **instant download & streaming!** ⚑\n\n" - "**πŸš€ Key Features:**\n" - "> **Instant Links:** Get your links within seconds.\n" - "> **Online Streaming:** Watch videos or listen to audio directly (for supported formats).\n" - "> **Universal File Support:** Handles documents, videos, audio, photos, and more.\n" - "> **High-Speed Access:** Optimized for fast link generation and file access.\n" - "> **Secure & Reliable:** Your files are handled with care during processing.\n" - "> **User-Friendly Interface:** Designed for ease of use on any device.\n" - "> **Efficient Processing:** Built for speed and reliability.\n" - "> **Batch Mode:** Process multiple files at once in groups using `/link `.\n" - "> **Versatile Usage:** Works in private chats, groups, and channels (with admin setup).\n\n" - "πŸ’– If you find me useful, please consider sharing me with your friends!" -) - -# ------ Ping ------ -MSG_PING_START = "πŸ›°οΈ **Pinging...** Please wait." -MSG_PING_RESPONSE = ( - "☁️ **PONG! Bot is Online!** ⚑\n\n" - "> ⏱️ **Ping:** {time_taken_ms:.2f} ms\n" - "> πŸ€– **Bot Status:** `Active`" -) - -# ------ DC Info ------ -MSG_DC_USER_INFO = ( - "πŸ“ **Information**\n" - "> πŸ‘€ **User:** [{user_name}](tg://user?id={user_id})\n" - "> πŸ†” **User ID:** `{user_id}`\n" - "> 🌍 **DC ID:** `{dc_id}`" -) - -MSG_DC_FILE_INFO = ( - "πŸ—‚οΈ **File Information**\n" - ">`{file_name}`\n" - "πŸ’Ύ **File Size:** `{file_size}`\n" - "πŸ“ **File Type:** `{file_type}`\n" - "🌍 **DC ID:** `{dc_id}`" -) - -MSG_DC_UNKNOWN = "Unknown" - -# ------ File Link Generation ------ -MSG_DM_SINGLE_PREFIX = "πŸ“¬ **From {chat_title}**\n" -MSG_LINKS = ( - "✨ **Your Links are Ready!** ✨\n\n" - "> `{file_name}`\n\n" - "πŸ“‚ **File Size:** `{file_size}`\n\n" - "πŸš€ **Download Link:**\n`{download_link}`\n\n" - "πŸ–₯️ **Stream Link:**\n`{stream_link}`\n\n" - "βŒ›οΈ **Note: Links remain active while the bot is running and the file is accessible.**" -) - -# ===================================================================================== -# ====== USER NOTIFICATIONS ====== -# ===================================================================================== - -MSG_NEW_USER = ( - "✨ **New User Alert!** ✨\n" - "> πŸ‘€ **Name:** [{first_name}](tg://user?id={user_id})\n" - "> πŸ†” **User ID:** `{user_id}`\n\n" -) -MSG_COMMUNITY_CHANNEL = "πŸ“’ **{channel_title}:** πŸ”’ Join this channel to use the bot." - -# ===================================================================================== -# ====== PROCESSING MESSAGES ====== -# ===================================================================================== - -# ------ General File Processing ------ -MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**" -MSG_PROCESSING_FILE = "⏳ **Processing your file...**" -MSG_NEW_FILE_REQUEST = ( - "> πŸ‘€ **Source:** [{source_info}](tg://user?id={id_})\n" - "> πŸ†” **ID:** `{id_}`\n\n" - "πŸš€ **Download:** `{online_link}`\n\n" - "πŸ–₯️ **Stream:** `{stream_link}`" -) - -# ------ Batch Processing ------ -MSG_PROCESSING_BATCH = "♻️ **Processing Batch {batch_number}/{total_batches}** ({file_count} files)" -MSG_PROCESSING_STATUS = "πŸ“Š **Processing Files:** {processed}/{total} complete, {failed} failed" -MSG_BATCH_LINKS_READY = "πŸ”— Here are your {count} download links:" -MSG_DM_BATCH_PREFIX = "πŸ“¬ **Batch Links from {chat_title}**\n" -MSG_PROCESSING_RESULT = "βœ… **Process Complete:** {processed}/{total} files processed successfully, {failed} failed" - -# ===================================================================================== -# ====== BROADCAST MESSAGES ====== -# ===================================================================================== - -MSG_BROADCAST_START = "πŸ“£ **Starting Broadcast...**\n\n> ⏳ Please wait for completion." -MSG_BROADCAST_COMPLETE = ( - "πŸ“’ **Broadcast Completed Successfully!** πŸ“’\n\n" - "⏱️ **Duration:** `{elapsed_time}`\n" - "πŸ‘₯ **Total Users:** `{total_users}`\n" - "βœ… **Successful Deliveries:** `{successes}`\n" - "❌ **Failed Deliveries:** `{failures}`\n" - "πŸ—‘οΈ **Accounts Removed (Blocked/Deactivated):** `{deleted_accounts}`\n" -) -MSG_BROADCAST_CANCEL = "πŸ›‘ **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations..." -MSG_INVALID_BROADCAST_CMD = "Please reply to the message you want to broadcast." -MSG_BROADCAST_USAGE = ( - "πŸ“£ **Broadcast Command Usage:**\n\n" - "`/broadcast` - Broadcast to all users\n" - "`/broadcast authorized` - Broadcast to authorized users only\n" - "`/broadcast regular` - Broadcast to regular (non-authorized) users only\n\n" - "**Note:** Reply to the message you want to broadcast." -) - -# ===================================================================================== -# ====== PERMISSION MESSAGES ====== -# ===================================================================================== - -MSG_ERROR_UNAUTHORIZED = "You are not authorized to view this information." -MSG_ERROR_BROADCAST_RESTART = "Please use the /broadcast command to start a new broadcast." -MSG_ERROR_BROADCAST_INSTRUCTION = "To start a new broadcast, use the /broadcast command and reply to the message you want to broadcast." -MSG_ERROR_CALLBACK_UNSUPPORTED = "This button is not active or no longer supported." - -# ===================================================================================== -# ====== RATE LIMITING MESSAGES ====== -# ===================================================================================== - -MSG_RATE_LIMIT_QUEUE_PRIORITY = ( - "⚑ You're in the **Priority Queue!**\n\n" - "> ⏳ **Estimated Wait:** `~{wait_estimate} minute{s}`\n" - "> πŸš€ **Status:** In Queue" -) - -MSG_RATE_LIMIT_QUEUE_REGULAR = ( - "⏳ **Rate Limit Reached!**\n\n" - "> βŒ› **Estimated Wait:** `~{wait_estimate} minute{s1}`\n" - "> πŸ“Š **Limit:** `{max_requests} files per {time_window} minute{s2}`\n" - "> πŸ”„ **Status:** In Queue" -) - -MSG_RATE_LIMIT_QUEUE_FULL = ( - "⚠️ **Service Busy!** The processing queue is currently full.\n\n" - "> πŸ•’ **Please try again in:** `~{wait_estimate} minute{s}`\n" - "> πŸ’‘ **Tip:** Try again later when system load decreases" -) - - -# ===================================================================================== -# ====== FILE TYPE DESCRIPTIONS ====== -# ===================================================================================== -MSG_FILE_TYPE_DOCUMENT = "πŸ“„ Document" -MSG_FILE_TYPE_PHOTO = "πŸ–ΌοΈ Photo" -MSG_FILE_TYPE_VIDEO = "🎬 Video" -MSG_FILE_TYPE_AUDIO = "🎡 Audio" -MSG_FILE_TYPE_VOICE = "🎀 Voice Message" -MSG_FILE_TYPE_STICKER = "🎨 Sticker" -MSG_FILE_TYPE_ANIMATION = "🎞️ Animation (GIF)" -MSG_FILE_TYPE_VIDEO_NOTE = "πŸ“Ή Video Note" -MSG_FILE_TYPE_UNKNOWN = "❓ Unknown File Type" - -# ===================================================================================== -# ====== SYSTEM & STATUS MESSAGES ====== -# ===================================================================================== - -MSG_SYSTEM_STATUS = ( - "βœ… **System Status:** Operational\n\n" - "> πŸ•’ **Uptime:** `{uptime}`\n" - "> πŸ€– **Bot Instances:** `{active_bots}`\n" - "> πŸ“Š **Total Workload:** `{total_workload}`\n\n" - "πŸ“œ **Workload Distribution:**\n\n" - "{workload_items}\n" - "> ♻️ **Version:** `{version}`" -) - -# ------ Speedtest Messages ------ -MSG_SPEEDTEST_INIT = "πŸš€ **Running Speed Test...**" -MSG_SPEEDTEST_ERROR = "❌ **Speed Test Failed!**\n\n> Unable to complete the speed test. Please try again later." -MSG_SPEEDTEST_RESULT = ( - "⚑ **Speed Test Results**\n\n" - "**SPEEDTEST INFO:**\n" - "> **Download:** `{download_mbps} Mbps` (`{download_bps}/s`)\n" - "> **Upload:** `{upload_mbps} Mbps` (`{upload_bps}/s`)\n" - "> **Ping:** `{ping} ms`\n" - "> **Timestamp:** `{timestamp}`\n" - "> **Data Sent:** `{bytes_sent}`\n" - "> **Data Received:** `{bytes_received}`\n\n" - "**SERVER INFO:**\n" - "> **Name:** `{server_name}`\n" - "> **Country:** `{server_country}`\n" - "> **Sponsor:** `{server_sponsor}`\n" - "> **Latency:** `{server_latency} ms`\n" - "> **Coordinates:** `{server_lat}, {server_lon}`\n\n" - "**CLIENT DETAILS:**\n" - "> **IP:** `{client_ip}`\n" - "> **Coordinates:** `{client_lat}, {client_lon}`\n" - "> **ISP:** `{client_isp}`\n" - "> **ISP Rating:** `{client_isprating}`\n" - "> **Country:** `{client_country}`" -) -MSG_SYSTEM_STATS = ( - "πŸ“Š **System Statistics**\n\n" - "> System Uptime: {sys_uptime}\n" - "> Bot Uptime: {bot_uptime}\n\n" - "βš™οΈ **Performance:**\n" - "> CPU: {cpu_percent}%\n" - "> CPU Core: {cpu_cores}\n" - "> Frequency: {cpu_freq} GHz\n\n" - "πŸ’Ύ **RAM**\n" - "> Total: {ram_total}\n" - "> Used: {ram_used}\n" - "> Free: {ram_free}\n\n" - "πŸ’½ **Storage:**\n" - "> Disk: `{disk_percent}%`\n" - "> Total: `{total}`\n" - "> Used: `{used}`\n" - "> Free: `{free}`\n\n" - "πŸ“Ά **Network:**\n" - "> πŸ”Ί Upload: `{upload}`\n" - "> πŸ”» Download: `{download}`\n" -) - -MSG_DB_STATS = "πŸ“Š **Database Statistics**\n\n> πŸ‘₯ **Total Users:** `{total_users}`" +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
{command}
" +MSG_SHELL_OUTPUT = """**Shell Command Output:** +
{output}
""" +MSG_SHELL_OUTPUT_STDOUT = "[stdout]:\n
{output}
" +MSG_SHELL_OUTPUT_STDERR = "[stderr]:\n
{error}
" +MSG_SHELL_NO_OUTPUT = "βœ… Command Executed: No output." + +# ------ Admin View & Control ------ + +MSG_WORKLOAD_ITEM = " {bot_name}: {load}\n" +MSG_ADMIN_RESTART_DONE = "βœ… **Restart Successful!**" +MSG_RESTARTING = "♻️ **Updating and Restarting Bot...**\n\n> ⏳ Please wait a moment." +MSG_LOG_FILE_CAPTION = "πŸ“„ **System Logs**" + +MSG_LOG_FILE_EMPTY = "ℹ️ **Log File Empty:** No data found in the log file." +MSG_LOG_FILE_MISSING = "⚠️ **Log File Missing:** Could not find the log file." + +# ===================================================================================== +# ====== BUTTON TEXTS (User-facing) ====== +# ===================================================================================== + +MSG_BUTTON_STREAM_NOW = "πŸ–₯️ Stream" +MSG_BUTTON_DOWNLOAD = "πŸš€ Download" +MSG_BUTTON_GET_HELP = "πŸ“– Get Help" +MSG_BUTTON_CANCEL_BROADCAST = "πŸ›‘ Cancel Broadcast" +MSG_BUTTON_VIEW_PROFILE = "πŸ‘€ View User Profile" +MSG_BUTTON_ABOUT = "ℹ️ About Bot" +MSG_BUTTON_JOIN_CHANNEL = "πŸ“’ Join {channel_title}" +MSG_BUTTON_GITHUB = "πŸ› οΈ GitHub" +MSG_BUTTON_START_CHAT = "πŸ“© Start Chat" +MSG_BUTTON_CLOSE = "βœ– Close" + + +# ===================================================================================== +# ====== COMMAND RESPONSES (User-facing) ====== +# ===================================================================================== + +MSG_WELCOME = ( + "🌟 **Welcome, {user_name}!** 🌟\n\n" + "I'm **Thunder File to Link Bot** ⚑\n" + "I generate direct download and streaming links for your files.\n\n" + "**How to use:**\n" + "1. Send any file to me for private links.\n" + "2. In groups, reply to a file with `/link`.\n\n" + "Β» Use `/help` for all commands and detailed information.\n\n" + "πŸš€ Send a file to begin!" +) + +MSG_HELP = ( + "πŸ“˜ **Thunder Bot - Help Guide** πŸ“–\n\n" + "How to get direct download & streaming links:\n\n" + "**πŸš€ Private Chat (with me):**\n" + "> 1. Send me **any file** (document, video, audio, photo, etc.).\n" + "> 2. I'll instantly reply with your links! ⚑\n\n" + "**πŸ‘₯ Using in Groups:**\n" + "> β€’ Reply to any file with `/link`.\n" + "> β€’ **Batch Mode:** Reply to the **first** file with `/link ` (e.g., `/link 5` for 5 files, up to {max_files}).\n" + "> β€’ Bot needs administrator rights in the group to function.\n" + "> β€’ Links are posted in the group & sent to you privately.\n\n" + "**πŸ“’ Using in Channels:**\n" + "> β€’ Add me as an administrator with necessary permissions.\n" + "> β€’ I can be configured to auto-detect new media files.\n" + "> β€’ Inline stream/download buttons can be added to files automatically.\n" + "> β€’ Files from banned channels (owner configuration) are rejected.\n" + "> β€’ Auto-posting links if the bot has admin privileges with delete rights.\n\n" + "**βš™οΈ Available Commands:**\n" + "> `/start` πŸ‘‹ - Welcome message & quick start information.\n" + "> `/help` πŸ“– - Shows this help message.\n" + "> `/link ` πŸ”— - (Groups) Generate links. \n" + "> `/about` ℹ️ - Learn more about me and my features.\n" + "> `/ping` πŸ“‘ - Check my responsiveness and online status.\n" + "> `/dc` 🌍 - View DC information (for yourself, another user, or a file).\n\n" + "**πŸ’‘ Pro Tips:**\n" + "> β€’ You can forward files from other chats directly to me.\n" + "> β€’ If you encounter a rate limit message, please wait the specified time. ⏳\n" + "> β€’ For `/link` in groups to work reliably (and for private link delivery), ensure you've started a private chat with me first.\n" + "> β€’ Processing batch files might take a bit longer. Please be patient. 🐌\n\n" + "❓ Questions? Please ask in our support group!" +) + +MSG_ABOUT = ( + "🌟 **About Thunder File to Link Bot** ℹ️\n\n" + "I'm your go-to bot for **instant download & streaming!** ⚑\n\n" + "**πŸš€ Key Features:**\n" + "> **Instant Links:** Get your links within seconds.\n" + "> **Online Streaming:** Watch videos or listen to audio directly (for supported formats).\n" + "> **Universal File Support:** Handles documents, videos, audio, photos, and more.\n" + "> **High-Speed Access:** Optimized for fast link generation and file access.\n" + "> **Secure & Reliable:** Your files are handled with care during processing.\n" + "> **User-Friendly Interface:** Designed for ease of use on any device.\n" + "> **Efficient Processing:** Built for speed and reliability.\n" + "> **Batch Mode:** Process multiple files at once in groups using `/link `.\n" + "> **Versatile Usage:** Works in private chats, groups, and channels (with admin setup).\n\n" + "πŸ’– If you find me useful, please consider sharing me with your friends!" +) + +# ------ Ping ------ +MSG_PING_START = "πŸ›°οΈ **Pinging...** Please wait." +MSG_PING_RESPONSE = ( + "☁️ **PONG! Bot is Online!** ⚑\n\n" + "> ⏱️ **Ping:** {time_taken_ms:.2f} ms\n" + "> πŸ€– **Bot Status:** `Active`" +) + +# ------ DC Info ------ +MSG_DC_USER_INFO = ( + "πŸ“ **Information**\n" + "> πŸ‘€ **User:** [{user_name}](tg://user?id={user_id})\n" + "> πŸ†” **User ID:** `{user_id}`\n" + "> 🌍 **DC ID:** `{dc_id}`" +) + +MSG_DC_FILE_INFO = ( + "πŸ—‚οΈ **File Information**\n" + ">`{file_name}`\n" + "πŸ’Ύ **File Size:** `{file_size}`\n" + "πŸ“ **File Type:** `{file_type}`\n" + "🌍 **DC ID:** `{dc_id}`" +) + +MSG_DC_UNKNOWN = "Unknown" + +# ------ File Link Generation ------ +MSG_DM_SINGLE_PREFIX = "πŸ“¬ **From {chat_title}**\n" +MSG_LINKS = ( + "✨ **Your Links are Ready!** ✨\n\n" + "> `{file_name}`\n\n" + "πŸ“‚ **File Size:** `{file_size}`\n\n" + "πŸš€ **Download Link:**\n`{download_link}`\n\n" + "πŸ–₯️ **Stream Link:**\n`{stream_link}`\n\n" + "βŒ›οΈ **Note: Links remain active while the bot is running and the file is accessible.**" +) + +# ===================================================================================== +# ====== USER NOTIFICATIONS ====== +# ===================================================================================== + +MSG_NEW_USER = ( + "✨ **New User Alert!** ✨\n" + "> πŸ‘€ **Name:** [{first_name}](tg://user?id={user_id})\n" + "> πŸ†” **User ID:** `{user_id}`\n\n" +) +MSG_COMMUNITY_CHANNEL = "πŸ“’ **{channel_title}:** πŸ”’ Join this channel to use the bot." + +# ===================================================================================== +# ====== PROCESSING MESSAGES ====== +# ===================================================================================== + +# ------ General File Processing ------ +MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**" +MSG_PROCESSING_FILE = "⏳ **Processing your file...**" +MSG_NEW_FILE_REQUEST = ( + "> πŸ‘€ **Source:** [{source_info}](tg://user?id={id_})\n" + "> πŸ†” **ID:** `{id_}`\n\n" + "πŸš€ **Download:** `{online_link}`\n\n" + "πŸ–₯️ **Stream:** `{stream_link}`" +) + +# ------ Batch Processing ------ +MSG_PROCESSING_BATCH = "♻️ **Processing Batch {batch_number}/{total_batches}** ({file_count} files)" +MSG_PROCESSING_STATUS = "πŸ“Š **Processing Files:** {processed}/{total} complete, {failed} failed" +MSG_BATCH_LINKS_READY = "πŸ”— Here are your {count} download links:" +MSG_DM_BATCH_PREFIX = "πŸ“¬ **Batch Links from {chat_title}**\n" +MSG_PROCESSING_RESULT = "βœ… **Process Complete:** {processed}/{total} files processed successfully, {failed} failed" + +# ===================================================================================== +# ====== BROADCAST MESSAGES ====== +# ===================================================================================== + +MSG_BROADCAST_START = "πŸ“£ **Starting Broadcast...**\n\n> ⏳ Please wait for completion." +MSG_BROADCAST_COMPLETE = ( + "πŸ“’ **Broadcast Completed Successfully!** πŸ“’\n\n" + "⏱️ **Duration:** `{elapsed_time}`\n" + "πŸ‘₯ **Total Users:** `{total_users}`\n" + "βœ… **Successful Deliveries:** `{successes}`\n" + "❌ **Failed Deliveries:** `{failures}`\n" + "πŸ—‘οΈ **Accounts Removed (Blocked/Deactivated):** `{deleted_accounts}`\n" +) +MSG_BROADCAST_CANCEL = "πŸ›‘ **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations..." +MSG_INVALID_BROADCAST_CMD = "Please reply to the message you want to broadcast." +MSG_BROADCAST_USAGE = ( + "πŸ“£ **Broadcast Command Usage:**\n\n" + "`/broadcast` - Broadcast to all users\n" + "`/broadcast authorized` - Broadcast to authorized users only\n" + "`/broadcast regular` - Broadcast to regular (non-authorized) users only\n\n" + "**Note:** Reply to the message you want to broadcast." +) + +# ===================================================================================== +# ====== PERMISSION MESSAGES ====== +# ===================================================================================== + +MSG_ERROR_UNAUTHORIZED = "You are not authorized to view this information." +MSG_ERROR_BROADCAST_RESTART = "Please use the /broadcast command to start a new broadcast." +MSG_ERROR_BROADCAST_INSTRUCTION = "To start a new broadcast, use the /broadcast command and reply to the message you want to broadcast." +MSG_ERROR_CALLBACK_UNSUPPORTED = "This button is not active or no longer supported." + +# ===================================================================================== +# ====== RATE LIMITING MESSAGES ====== +# ===================================================================================== + +MSG_RATE_LIMIT_QUEUE_PRIORITY = ( + "⚑ You're in the **Priority Queue!**\n\n" + "> ⏳ **Estimated Wait:** `~{wait_estimate} minute{s}`\n" + "> πŸš€ **Status:** In Queue" +) + +MSG_RATE_LIMIT_QUEUE_REGULAR = ( + "⏳ **Rate Limit Reached!**\n\n" + "> βŒ› **Estimated Wait:** `~{wait_estimate} minute{s1}`\n" + "> πŸ“Š **Limit:** `{max_requests} files per {time_window} minute{s2}`\n" + "> πŸ”„ **Status:** In Queue" +) + +MSG_RATE_LIMIT_QUEUE_FULL = ( + "⚠️ **Service Busy!** The processing queue is currently full.\n\n" + "> πŸ•’ **Please try again in:** `~{wait_estimate} minute{s}`\n" + "> πŸ’‘ **Tip:** Try again later when system load decreases" +) + + +# ===================================================================================== +# ====== FILE TYPE DESCRIPTIONS ====== +# ===================================================================================== +MSG_FILE_TYPE_DOCUMENT = "πŸ“„ Document" +MSG_FILE_TYPE_PHOTO = "πŸ–ΌοΈ Photo" +MSG_FILE_TYPE_VIDEO = "🎬 Video" +MSG_FILE_TYPE_AUDIO = "🎡 Audio" +MSG_FILE_TYPE_VOICE = "🎀 Voice Message" +MSG_FILE_TYPE_STICKER = "🎨 Sticker" +MSG_FILE_TYPE_ANIMATION = "🎞️ Animation (GIF)" +MSG_FILE_TYPE_VIDEO_NOTE = "πŸ“Ή Video Note" +MSG_FILE_TYPE_UNKNOWN = "❓ Unknown File Type" + +# ===================================================================================== +# ====== SYSTEM & STATUS MESSAGES ====== +# ===================================================================================== + +MSG_SYSTEM_STATUS = ( + "βœ… **System Status:** Operational\n\n" + "> πŸ•’ **Uptime:** `{uptime}`\n" + "> πŸ€– **Bot Instances:** `{active_bots}`\n" + "> πŸ“Š **Total Workload:** `{total_workload}`\n\n" + "πŸ“œ **Workload Distribution:**\n\n" + "{workload_items}\n" + "> ♻️ **Version:** `{version}`" +) + +# ------ Speedtest Messages ------ +MSG_SPEEDTEST_INIT = "πŸš€ **Running Speed Test...**" +MSG_SPEEDTEST_ERROR = "❌ **Speed Test Failed!**\n\n> Unable to complete the speed test. Please try again later." +MSG_SPEEDTEST_RESULT = ( + "⚑ **Speed Test Results**\n\n" + "**SPEEDTEST INFO:**\n" + "> **Download:** `{download_mbps} Mbps` (`{download_bps}/s`)\n" + "> **Upload:** `{upload_mbps} Mbps` (`{upload_bps}/s`)\n" + "> **Ping:** `{ping} ms`\n" + "> **Timestamp:** `{timestamp}`\n" + "> **Data Sent:** `{bytes_sent}`\n" + "> **Data Received:** `{bytes_received}`\n\n" + "**SERVER INFO:**\n" + "> **Name:** `{server_name}`\n" + "> **Country:** `{server_country}`\n" + "> **Sponsor:** `{server_sponsor}`\n" + "> **Latency:** `{server_latency} ms`\n" + "> **Coordinates:** `{server_lat}, {server_lon}`\n\n" + "**CLIENT DETAILS:**\n" + "> **IP:** `{client_ip}`\n" + "> **Coordinates:** `{client_lat}, {client_lon}`\n" + "> **ISP:** `{client_isp}`\n" + "> **ISP Rating:** `{client_isprating}`\n" + "> **Country:** `{client_country}`" +) +MSG_SYSTEM_STATS = ( + "πŸ“Š **System Statistics**\n\n" + "> System Uptime: {sys_uptime}\n" + "> Bot Uptime: {bot_uptime}\n\n" + "βš™οΈ **Performance:**\n" + "> CPU: {cpu_percent}%\n" + "> CPU Core: {cpu_cores}\n" + "> Frequency: {cpu_freq} GHz\n\n" + "πŸ’Ύ **RAM**\n" + "> Total: {ram_total}\n" + "> Used: {ram_used}\n" + "> Free: {ram_free}\n\n" + "πŸ’½ **Storage:**\n" + "> Disk: `{disk_percent}%`\n" + "> Total: `{total}`\n" + "> Used: `{used}`\n" + "> Free: `{free}`\n\n" + "πŸ“Ά **Network:**\n" + "> πŸ”Ί Upload: `{upload}`\n" + "> πŸ”» Download: `{download}`\n" +) + +MSG_DB_STATS = "πŸ“Š **Database Statistics**\n\n> πŸ‘₯ **Total Users:** `{total_users}`" diff --git a/Thunder/utils/rate_limiter.py b/Thunder/utils/rate_limiter.py old mode 100644 new mode 100755 index e7ca117..6ced23f --- a/Thunder/utils/rate_limiter.py +++ b/Thunder/utils/rate_limiter.py @@ -1,437 +1,437 @@ -# Thunder/utils/rate_limiter.py - -import time -import math -import asyncio -from collections import deque -from typing import Callable, Dict, Optional, Tuple -from pyrogram import Client -from pyrogram.types import Message -from pyrogram.errors import FloodWait, RPCError -from Thunder.utils.logger import logger -from Thunder.utils.database import db -from Thunder.utils.messages import ( - MSG_RATE_LIMIT_QUEUE_PRIORITY, - MSG_RATE_LIMIT_QUEUE_REGULAR, - MSG_RATE_LIMIT_QUEUE_FULL -) -from Thunder.vars import Var - - -class QueueFullError(Exception): - pass - - -class RateLimiter: - def __init__(self): - self.request_queue: deque = deque() - self.priority_queue: deque = deque() - self.user_queue_counts: Dict[int, int] = {} - - self.request_event: asyncio.Event = asyncio.Event() - self.request_lock: asyncio.Lock = asyncio.Lock() - - self.user_requests: Dict[int, deque] = {} - self.global_requests: deque = deque() - - self.processing_times: deque = deque(maxlen=100) - self.file_processing_times: Dict[str, deque] = {} - self.average_processing_time: float = 1.0 - - self.auth_cache: Dict[int, Tuple[bool, float]] = {} - self.auth_cache_ttl_seconds: int = 300 - - self._initialization_error = False - self._load_configuration() - - def _load_configuration(self): - try: - self.max_requests_per_period = Var.MAX_FILES_PER_PERIOD - self.rate_limit_period_seconds = Var.RATE_LIMIT_PERIOD_MINUTES * 60 - self.max_queue_size = Var.MAX_QUEUE_SIZE - self.enabled = Var.RATE_LIMIT_ENABLED - self.global_rate_limit_enabled = Var.GLOBAL_RATE_LIMIT - self.max_global_requests_per_minute = Var.MAX_GLOBAL_REQUESTS_PER_MINUTE - - if not self._validate_configuration(): - logger.warning("Rate limiter disabled due to invalid configuration.") - self.enabled = False - else: - logger.debug(f"Rate limiter initialized: enabled={self.enabled}, " - f"max_requests={self.max_requests_per_period}, " - f"period={self.rate_limit_period_seconds}s, " - f"queue_size={self.max_queue_size}, " - f"global_enabled={self.global_rate_limit_enabled}, " - f"max_global_requests={self.max_global_requests_per_minute}") - except Exception as e: - logger.critical(f"Critical error initializing rate limiter, using safe defaults: {e}", exc_info=True) - self.max_requests_per_period = 5 - self.rate_limit_period_seconds = 60 - self.max_queue_size = 100 - self.enabled = False - self.global_rate_limit_enabled = False - self.max_global_requests_per_minute = 60 - self._initialization_error = True - - def _validate_configuration(self) -> bool: - is_valid = True - if self.max_requests_per_period <= 0: - logger.error("Invalid MAX_FILES_PER_PERIOD: must be > 0.") - is_valid = False - if self.rate_limit_period_seconds <= 0: - logger.error("Invalid RATE_LIMIT_PERIOD_MINUTES: must be > 0.") - is_valid = False - if self.max_queue_size <= 0: - logger.error("Invalid MAX_QUEUE_SIZE: must be > 0.") - is_valid = False - if self.global_rate_limit_enabled and self.max_global_requests_per_minute <= 0: - logger.error("Invalid MAX_GLOBAL_REQUESTS_PER_MINUTE: must be > 0 when global rate limit is enabled.") - is_valid = False - return is_valid - - def is_owner(self, user_id: int) -> bool: - return user_id == Var.OWNER_ID - - async def is_authorized_user(self, user_id: int) -> bool: - current_time = time.time() - if user_id in self.auth_cache: - is_auth, timestamp = self.auth_cache[user_id] - if current_time - timestamp < self.auth_cache_ttl_seconds: - return is_auth - - try: - authorized_user = await db.authorized_users_col.find_one({"user_id": user_id}) - is_auth = bool(authorized_user) - self.auth_cache[user_id] = (is_auth, current_time) - return is_auth - except Exception as e: - logger.error(f"Database error checking authorized user {user_id}: {e}") - return False - - async def get_user_priority(self, user_id: int) -> str: - if self.is_owner(user_id): - return 'owner' - if await self.is_authorized_user(user_id): - return 'authorized' - return 'regular' - - async def check_limits(self, user_id: int, record: bool = True) -> bool: - if not self.enabled or self._initialization_error or self.is_owner(user_id): - return True - - current_time = time.time() - - if self.global_rate_limit_enabled: - while self.global_requests and self.global_requests[0] <= current_time - 60: - self.global_requests.popleft() - if len(self.global_requests) >= self.max_global_requests_per_minute: - return False - - user_timestamps = self.user_requests.setdefault(user_id, deque()) - while user_timestamps and user_timestamps[0] <= current_time - self.rate_limit_period_seconds: - user_timestamps.popleft() - if len(user_timestamps) >= self.max_requests_per_period: - return False - - if record: - if self.global_rate_limit_enabled: - self.global_requests.append(current_time) - user_timestamps.append(current_time) - return True - - async def _requeue_request(self, request_data: dict, queue_type: str): - async with self.request_lock: - if queue_type == "priority": - self.priority_queue.appendleft(request_data) - else: - self.request_queue.appendleft(request_data) - self.request_event.set() - logger.debug(f"Re-queued request for user {request_data['user_id']} to {queue_type} queue.") - - async def add_to_queue(self, func: Callable, user_id: int, file_identifier: Optional[str] = None, *args, **kwargs): - if not self.enabled: - await func(*args, **kwargs) - return - - request_data = { - 'func': func, 'user_id': user_id, 'args': args, 'kwargs': kwargs, - 'timestamp': time.time(), 'user_priority': await self.get_user_priority(user_id), - 'file_identifier': file_identifier - } - - async with self.request_lock: - total_queued = len(self.request_queue) + len(self.priority_queue) - if total_queued >= self.max_queue_size: - raise QueueFullError("Queue is full") - - if request_data['user_priority'] == 'authorized': - self.priority_queue.append(request_data) - queue_name = "priority" - else: - self.request_queue.append(request_data) - queue_name = "regular" - - self.user_queue_counts[user_id] = self.user_queue_counts.get(user_id, 0) + 1 - logger.debug(f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}") - self.request_event.set() - - async def request_executor(self): - logger.debug("Request executor started.") - while True: - try: - await self.request_event.wait() - - async with self.request_lock: - queue, queue_type = (self.priority_queue, "priority") if self.priority_queue else (self.request_queue, "regular") - if not queue: - self.request_event.clear() - continue - request_data = queue.popleft() - - user_id = request_data['user_id'] - processed = False - if not self.is_owner(user_id): - if not await self.check_limits(user_id, record=True): - await self._requeue_request(request_data, queue_type) - await asyncio.sleep(0.5) - continue - - logger.debug(f"Processing request for user {user_id} from {queue_type} queue.") - start_time = time.time() - try: - await request_data['func'](*request_data['args'], **request_data['kwargs']) - processing_time = time.time() - start_time - self.processing_times.append(processing_time) - if self.processing_times: - self.average_processing_time = sum(self.processing_times) / len(self.processing_times) - - file_identifier = request_data.get('file_identifier') - if file_identifier: - file_times = self.file_processing_times.setdefault(file_identifier, deque(maxlen=100)) - file_times.append(processing_time) - - processed = True - - except FloodWait as e: - logger.warning(f"FloodWait for user {user_id}, waiting {e.value}s before re-queuing.") - await asyncio.sleep(e.value) - await self._requeue_request(request_data, queue_type) - except Exception as e: - logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) - processed = True - finally: - async with self.request_lock: - if processed and user_id in self.user_queue_counts: - self.user_queue_counts[user_id] -= 1 - if self.user_queue_counts[user_id] <= 0: - self.user_queue_counts.pop(user_id, None) - - except asyncio.CancelledError: - logger.debug("Request executor cancelled, shutting down.") - break - except Exception as e: - logger.critical(f"Critical error in request executor: {e}", exc_info=True) - await asyncio.sleep(5) - - async def shutdown(self): - logger.debug("Shutting down rate limiter and clearing queues...") - async with self.request_lock: - self.request_queue.clear() - self.priority_queue.clear() - self.user_queue_counts.clear() - self.request_event.clear() - logger.debug("Rate limiter queues cleared.") - - def get_queue_status(self) -> dict: - return { - 'regular_queue_size': len(self.request_queue), - 'priority_queue_size': len(self.priority_queue), - 'total_queued': len(self.request_queue) + len(self.priority_queue), - 'max_queue_size': self.max_queue_size, - 'active_users_in_queue': len(self.user_queue_counts), - 'enabled': self.enabled, - } - - async def get_user_queue_position(self, user_id: int) -> dict: - user_priority = await self.get_user_priority(user_id) - position = -1 - queue_to_search = self.priority_queue if user_priority == 'authorized' else self.request_queue - - for idx, req in enumerate(queue_to_search): - if req.get('user_id') == user_id: - position = idx + 1 - break - - effective_position = position - if user_priority == 'regular' and position > -1: - effective_position += len(self.priority_queue) - - return { - 'user_priority': user_priority, - 'position_in_own_queue': position if position > -1 else None, - 'effective_position': effective_position if effective_position > -1 else None, - 'priority_queue_size': len(self.priority_queue), - 'regular_queue_size': len(self.request_queue), - 'bypasses_rate_limit': user_priority == 'owner' - } - - def _get_base_processing_time(self, file_identifier: Optional[str]) -> float: - if file_identifier and file_identifier in self.file_processing_times: - file_times = self.file_processing_times[file_identifier] - if file_times: - return sum(file_times) / len(file_times) - return self.average_processing_time - - async def _calculate_queue_wait(self, user_id: int, effective_processing_time: float) -> float: - pos_info = await self.get_user_queue_position(user_id) - items_ahead = (pos_info['effective_position'] - 1) if pos_info['effective_position'] else 0 - return items_ahead * effective_processing_time - - def _calculate_user_rate_limit_wait(self, user_id: int, future_time: float) -> float: - user_timestamps = self.user_requests.get(user_id, deque()) - future_user_timestamps = deque(ts for ts in user_timestamps if ts > future_time - self.rate_limit_period_seconds) - - if len(future_user_timestamps) >= self.max_requests_per_period: - reset_time = future_user_timestamps[0] + self.rate_limit_period_seconds - return max(0.0, reset_time - future_time) - return 0.0 - - def _calculate_global_rate_limit_wait(self, future_time: float) -> float: - if not self.global_rate_limit_enabled: - return 0.0 - - future_global_requests = deque(ts for ts in self.global_requests if ts > future_time - 60) - - if len(future_global_requests) >= self.max_global_requests_per_minute: - oldest_request_time = future_global_requests[0] - reset_time = oldest_request_time + 60 - return max(0.0, reset_time - future_time) - return 0.0 - - async def estimate_wait_time(self, user_id: int, file_identifier: Optional[str] = None) -> float: - if self.is_owner(user_id): - return 0.0 - - base_processing_time = self._get_base_processing_time(file_identifier) - min_time_per_request = self.rate_limit_period_seconds / self.max_requests_per_period if self.max_requests_per_period > 0 else 0 - effective_processing_time = max(base_processing_time, min_time_per_request) - - if self.global_rate_limit_enabled and self.max_global_requests_per_minute > 0: - min_time_per_global = 60 / self.max_global_requests_per_minute - effective_processing_time = max(effective_processing_time, min_time_per_global) - - queue_wait = await self._calculate_queue_wait(user_id, effective_processing_time) - future_time = time.time() + queue_wait - - rate_limit_wait = self._calculate_user_rate_limit_wait(user_id, future_time) - global_wait = self._calculate_global_rate_limit_wait(future_time) - - return queue_wait + rate_limit_wait + global_wait - - -rate_limiter = RateLimiter() - - -async def request_executor(): - await rate_limiter.request_executor() - - -async def handle_rate_limited_request(bot: Client, message: Message, handler: Callable, *args, **kwargs): - rl_user_id = kwargs.pop('rl_user_id', None) - user_id = rl_user_id if rl_user_id is not None else (message.from_user.id if message and message.from_user else None) - if not isinstance(user_id, int): - logger.error(f"Invalid user_id provided for rate limiting: {user_id}") - return - - file_identifier = message.document.file_unique_id if message and message.document else None - - if rate_limiter.is_owner(user_id): - logger.debug(f"Owner {user_id} bypassing rate limit.") - await handler(bot, message, *args, **kwargs) - return - - if await rate_limiter.check_limits(user_id, record=True): - logger.debug(f"User {user_id} within rate limits, executing immediately.") - await handler(bot, message, *args, **kwargs) - return - - is_channel = rl_user_id is not None and rl_user_id < 0 - - if not is_channel: - try: - user_priority = await rate_limiter.get_user_priority(user_id) - notification_msg = await send_queue_notification( - bot, message, is_priority=(user_priority == 'authorized'), file_identifier=file_identifier - ) - kwargs['notification_msg'] = notification_msg - except Exception as e: - logger.error(f"Error sending queue notification for user {user_id}: {e}", exc_info=True) - - try: - await rate_limiter.add_to_queue(handler, user_id, file_identifier, bot, message, *args, **kwargs) - logger.debug(f"Request for user {user_id} queued.") - except QueueFullError: - logger.warning(f"Queue full, request for user {user_id} rejected.") - if not is_channel: - await send_queue_full_message(bot, message, file_identifier) - except Exception as e: - logger.error(f"Error adding request to queue for user {user_id}: {e}", exc_info=True) - if not is_channel: - await send_queue_full_message(bot, message, file_identifier) - - -async def _send_notification(bot: Client, message: Message, template: str, file_identifier: Optional[str], **format_kwargs): - try: - if message.from_user: - user_id = message.from_user.id - wait_seconds = await rate_limiter.estimate_wait_time(user_id, file_identifier) - wait_estimate = max(1, math.ceil(wait_seconds / 60)) - - text = template.format(wait_estimate=wait_estimate, s="s" if wait_estimate > 1 else "", **format_kwargs) - - try: - return await bot.send_message( - chat_id=message.chat.id, - text=text, - reply_to_message_id=message.id - ) - except FloodWait as e: - await asyncio.sleep(e.value) - return await bot.send_message( - chat_id=message.chat.id, - text=text, - reply_to_message_id=message.id - ) - else: - logger.debug("Skipping notification for channel message (no from_user)") - return None - except (FloodWait, RPCError) as e: - user_id = message.from_user.id if message.from_user else "channel" - logger.warning(f"Error sending notification to user {user_id}: {e}") - except Exception as e: - logger.error(f"Unexpected error sending notification: {e}", exc_info=True) - return None - - -async def send_queue_notification(bot: Client, message: Message, is_priority: bool, file_identifier: Optional[str]): - if is_priority: - template = MSG_RATE_LIMIT_QUEUE_PRIORITY - params = {} - else: - template = MSG_RATE_LIMIT_QUEUE_REGULAR - time_window = rate_limiter.rate_limit_period_seconds // 60 - params = { - "max_requests": rate_limiter.max_requests_per_period, - "time_window": time_window, - "s1": "s" if rate_limiter.max_requests_per_period > 1 else "", - "s2": "s" if time_window > 1 else "" - } - user_id = message.from_user.id if message.from_user else "channel" - logger.debug(f"Sending {'priority' if is_priority else 'regular'} queue notification to user {user_id}") - return await _send_notification(bot, message, template, file_identifier, **params) - - -async def send_queue_full_message(bot: Client, message: Message, file_identifier: Optional[str]): - user_id = message.from_user.id if message.from_user else "channel" - logger.debug(f"Sending queue full message to user {user_id}") - await _send_notification(bot, message, MSG_RATE_LIMIT_QUEUE_FULL, file_identifier) +# Thunder/utils/rate_limiter.py + +import time +import math +import asyncio +from collections import deque +from typing import Callable, Dict, Optional, Tuple +from pyrogram import Client +from pyrogram.types import Message +from pyrogram.errors import FloodWait, RPCError +from Thunder.utils.logger import logger +from Thunder.utils.database import db +from Thunder.utils.messages import ( + MSG_RATE_LIMIT_QUEUE_PRIORITY, + MSG_RATE_LIMIT_QUEUE_REGULAR, + MSG_RATE_LIMIT_QUEUE_FULL +) +from Thunder.vars import Var + + +class QueueFullError(Exception): + pass + + +class RateLimiter: + def __init__(self): + self.request_queue: deque = deque() + self.priority_queue: deque = deque() + self.user_queue_counts: Dict[int, int] = {} + + self.request_event: asyncio.Event = asyncio.Event() + self.request_lock: asyncio.Lock = asyncio.Lock() + + self.user_requests: Dict[int, deque] = {} + self.global_requests: deque = deque() + + self.processing_times: deque = deque(maxlen=100) + self.file_processing_times: Dict[str, deque] = {} + self.average_processing_time: float = 1.0 + + self.auth_cache: Dict[int, Tuple[bool, float]] = {} + self.auth_cache_ttl_seconds: int = 300 + + self._initialization_error = False + self._load_configuration() + + def _load_configuration(self): + try: + self.max_requests_per_period = Var.MAX_FILES_PER_PERIOD + self.rate_limit_period_seconds = Var.RATE_LIMIT_PERIOD_MINUTES * 60 + self.max_queue_size = Var.MAX_QUEUE_SIZE + self.enabled = Var.RATE_LIMIT_ENABLED + self.global_rate_limit_enabled = Var.GLOBAL_RATE_LIMIT + self.max_global_requests_per_minute = Var.MAX_GLOBAL_REQUESTS_PER_MINUTE + + if not self._validate_configuration(): + logger.warning("Rate limiter disabled due to invalid configuration.") + self.enabled = False + else: + logger.debug(f"Rate limiter initialized: enabled={self.enabled}, " + f"max_requests={self.max_requests_per_period}, " + f"period={self.rate_limit_period_seconds}s, " + f"queue_size={self.max_queue_size}, " + f"global_enabled={self.global_rate_limit_enabled}, " + f"max_global_requests={self.max_global_requests_per_minute}") + except Exception as e: + logger.critical(f"Critical error initializing rate limiter, using safe defaults: {e}", exc_info=True) + self.max_requests_per_period = 5 + self.rate_limit_period_seconds = 60 + self.max_queue_size = 100 + self.enabled = False + self.global_rate_limit_enabled = False + self.max_global_requests_per_minute = 60 + self._initialization_error = True + + def _validate_configuration(self) -> bool: + is_valid = True + if self.max_requests_per_period <= 0: + logger.error("Invalid MAX_FILES_PER_PERIOD: must be > 0.") + is_valid = False + if self.rate_limit_period_seconds <= 0: + logger.error("Invalid RATE_LIMIT_PERIOD_MINUTES: must be > 0.") + is_valid = False + if self.max_queue_size <= 0: + logger.error("Invalid MAX_QUEUE_SIZE: must be > 0.") + is_valid = False + if self.global_rate_limit_enabled and self.max_global_requests_per_minute <= 0: + logger.error("Invalid MAX_GLOBAL_REQUESTS_PER_MINUTE: must be > 0 when global rate limit is enabled.") + is_valid = False + return is_valid + + def is_owner(self, user_id: int) -> bool: + return user_id == Var.OWNER_ID + + async def is_authorized_user(self, user_id: int) -> bool: + current_time = time.time() + if user_id in self.auth_cache: + is_auth, timestamp = self.auth_cache[user_id] + if current_time - timestamp < self.auth_cache_ttl_seconds: + return is_auth + + try: + authorized_user = await db.authorized_users_col.find_one({"user_id": user_id}) + is_auth = bool(authorized_user) + self.auth_cache[user_id] = (is_auth, current_time) + return is_auth + except Exception as e: + logger.error(f"Database error checking authorized user {user_id}: {e}") + return False + + async def get_user_priority(self, user_id: int) -> str: + if self.is_owner(user_id): + return 'owner' + if await self.is_authorized_user(user_id): + return 'authorized' + return 'regular' + + async def check_limits(self, user_id: int, record: bool = True) -> bool: + if not self.enabled or self._initialization_error or self.is_owner(user_id): + return True + + current_time = time.time() + + if self.global_rate_limit_enabled: + while self.global_requests and self.global_requests[0] <= current_time - 60: + self.global_requests.popleft() + if len(self.global_requests) >= self.max_global_requests_per_minute: + return False + + user_timestamps = self.user_requests.setdefault(user_id, deque()) + while user_timestamps and user_timestamps[0] <= current_time - self.rate_limit_period_seconds: + user_timestamps.popleft() + if len(user_timestamps) >= self.max_requests_per_period: + return False + + if record: + if self.global_rate_limit_enabled: + self.global_requests.append(current_time) + user_timestamps.append(current_time) + return True + + async def _requeue_request(self, request_data: dict, queue_type: str): + async with self.request_lock: + if queue_type == "priority": + self.priority_queue.appendleft(request_data) + else: + self.request_queue.appendleft(request_data) + self.request_event.set() + logger.debug(f"Re-queued request for user {request_data['user_id']} to {queue_type} queue.") + + async def add_to_queue(self, func: Callable, user_id: int, file_identifier: Optional[str] = None, *args, **kwargs): + if not self.enabled: + await func(*args, **kwargs) + return + + request_data = { + 'func': func, 'user_id': user_id, 'args': args, 'kwargs': kwargs, + 'timestamp': time.time(), 'user_priority': await self.get_user_priority(user_id), + 'file_identifier': file_identifier + } + + async with self.request_lock: + total_queued = len(self.request_queue) + len(self.priority_queue) + if total_queued >= self.max_queue_size: + raise QueueFullError("Queue is full") + + if request_data['user_priority'] == 'authorized': + self.priority_queue.append(request_data) + queue_name = "priority" + else: + self.request_queue.append(request_data) + queue_name = "regular" + + self.user_queue_counts[user_id] = self.user_queue_counts.get(user_id, 0) + 1 + logger.debug(f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}") + self.request_event.set() + + async def request_executor(self): + logger.debug("Request executor started.") + while True: + try: + await self.request_event.wait() + + async with self.request_lock: + queue, queue_type = (self.priority_queue, "priority") if self.priority_queue else (self.request_queue, "regular") + if not queue: + self.request_event.clear() + continue + request_data = queue.popleft() + + user_id = request_data['user_id'] + processed = False + if not self.is_owner(user_id): + if not await self.check_limits(user_id, record=True): + await self._requeue_request(request_data, queue_type) + await asyncio.sleep(0.5) + continue + + logger.debug(f"Processing request for user {user_id} from {queue_type} queue.") + start_time = time.time() + try: + await request_data['func'](*request_data['args'], **request_data['kwargs']) + processing_time = time.time() - start_time + self.processing_times.append(processing_time) + if self.processing_times: + self.average_processing_time = sum(self.processing_times) / len(self.processing_times) + + file_identifier = request_data.get('file_identifier') + if file_identifier: + file_times = self.file_processing_times.setdefault(file_identifier, deque(maxlen=100)) + file_times.append(processing_time) + + processed = True + + except FloodWait as e: + logger.warning(f"FloodWait for user {user_id}, waiting {e.value}s before re-queuing.") + await asyncio.sleep(e.value) + await self._requeue_request(request_data, queue_type) + except Exception as e: + logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) + processed = True + finally: + async with self.request_lock: + if processed and user_id in self.user_queue_counts: + self.user_queue_counts[user_id] -= 1 + if self.user_queue_counts[user_id] <= 0: + self.user_queue_counts.pop(user_id, None) + + except asyncio.CancelledError: + logger.debug("Request executor cancelled, shutting down.") + break + except Exception as e: + logger.critical(f"Critical error in request executor: {e}", exc_info=True) + await asyncio.sleep(5) + + async def shutdown(self): + logger.debug("Shutting down rate limiter and clearing queues...") + async with self.request_lock: + self.request_queue.clear() + self.priority_queue.clear() + self.user_queue_counts.clear() + self.request_event.clear() + logger.debug("Rate limiter queues cleared.") + + def get_queue_status(self) -> dict: + return { + 'regular_queue_size': len(self.request_queue), + 'priority_queue_size': len(self.priority_queue), + 'total_queued': len(self.request_queue) + len(self.priority_queue), + 'max_queue_size': self.max_queue_size, + 'active_users_in_queue': len(self.user_queue_counts), + 'enabled': self.enabled, + } + + async def get_user_queue_position(self, user_id: int) -> dict: + user_priority = await self.get_user_priority(user_id) + position = -1 + queue_to_search = self.priority_queue if user_priority == 'authorized' else self.request_queue + + for idx, req in enumerate(queue_to_search): + if req.get('user_id') == user_id: + position = idx + 1 + break + + effective_position = position + if user_priority == 'regular' and position > -1: + effective_position += len(self.priority_queue) + + return { + 'user_priority': user_priority, + 'position_in_own_queue': position if position > -1 else None, + 'effective_position': effective_position if effective_position > -1 else None, + 'priority_queue_size': len(self.priority_queue), + 'regular_queue_size': len(self.request_queue), + 'bypasses_rate_limit': user_priority == 'owner' + } + + def _get_base_processing_time(self, file_identifier: Optional[str]) -> float: + if file_identifier and file_identifier in self.file_processing_times: + file_times = self.file_processing_times[file_identifier] + if file_times: + return sum(file_times) / len(file_times) + return self.average_processing_time + + async def _calculate_queue_wait(self, user_id: int, effective_processing_time: float) -> float: + pos_info = await self.get_user_queue_position(user_id) + items_ahead = (pos_info['effective_position'] - 1) if pos_info['effective_position'] else 0 + return items_ahead * effective_processing_time + + def _calculate_user_rate_limit_wait(self, user_id: int, future_time: float) -> float: + user_timestamps = self.user_requests.get(user_id, deque()) + future_user_timestamps = deque(ts for ts in user_timestamps if ts > future_time - self.rate_limit_period_seconds) + + if len(future_user_timestamps) >= self.max_requests_per_period: + reset_time = future_user_timestamps[0] + self.rate_limit_period_seconds + return max(0.0, reset_time - future_time) + return 0.0 + + def _calculate_global_rate_limit_wait(self, future_time: float) -> float: + if not self.global_rate_limit_enabled: + return 0.0 + + future_global_requests = deque(ts for ts in self.global_requests if ts > future_time - 60) + + if len(future_global_requests) >= self.max_global_requests_per_minute: + oldest_request_time = future_global_requests[0] + reset_time = oldest_request_time + 60 + return max(0.0, reset_time - future_time) + return 0.0 + + async def estimate_wait_time(self, user_id: int, file_identifier: Optional[str] = None) -> float: + if self.is_owner(user_id): + return 0.0 + + base_processing_time = self._get_base_processing_time(file_identifier) + min_time_per_request = self.rate_limit_period_seconds / self.max_requests_per_period if self.max_requests_per_period > 0 else 0 + effective_processing_time = max(base_processing_time, min_time_per_request) + + if self.global_rate_limit_enabled and self.max_global_requests_per_minute > 0: + min_time_per_global = 60 / self.max_global_requests_per_minute + effective_processing_time = max(effective_processing_time, min_time_per_global) + + queue_wait = await self._calculate_queue_wait(user_id, effective_processing_time) + future_time = time.time() + queue_wait + + rate_limit_wait = self._calculate_user_rate_limit_wait(user_id, future_time) + global_wait = self._calculate_global_rate_limit_wait(future_time) + + return queue_wait + rate_limit_wait + global_wait + + +rate_limiter = RateLimiter() + + +async def request_executor(): + await rate_limiter.request_executor() + + +async def handle_rate_limited_request(bot: Client, message: Message, handler: Callable, *args, **kwargs): + rl_user_id = kwargs.pop('rl_user_id', None) + user_id = rl_user_id if rl_user_id is not None else (message.from_user.id if message and message.from_user else None) + if not isinstance(user_id, int): + logger.error(f"Invalid user_id provided for rate limiting: {user_id}") + return + + file_identifier = message.document.file_unique_id if message and message.document else None + + if rate_limiter.is_owner(user_id): + logger.debug(f"Owner {user_id} bypassing rate limit.") + await handler(bot, message, *args, **kwargs) + return + + if await rate_limiter.check_limits(user_id, record=True): + logger.debug(f"User {user_id} within rate limits, executing immediately.") + await handler(bot, message, *args, **kwargs) + return + + is_channel = rl_user_id is not None and rl_user_id < 0 + + if not is_channel: + try: + user_priority = await rate_limiter.get_user_priority(user_id) + notification_msg = await send_queue_notification( + bot, message, is_priority=(user_priority == 'authorized'), file_identifier=file_identifier + ) + kwargs['notification_msg'] = notification_msg + except Exception as e: + logger.error(f"Error sending queue notification for user {user_id}: {e}", exc_info=True) + + try: + await rate_limiter.add_to_queue(handler, user_id, file_identifier, bot, message, *args, **kwargs) + logger.debug(f"Request for user {user_id} queued.") + except QueueFullError: + logger.warning(f"Queue full, request for user {user_id} rejected.") + if not is_channel: + await send_queue_full_message(bot, message, file_identifier) + except Exception as e: + logger.error(f"Error adding request to queue for user {user_id}: {e}", exc_info=True) + if not is_channel: + await send_queue_full_message(bot, message, file_identifier) + + +async def _send_notification(bot: Client, message: Message, template: str, file_identifier: Optional[str], **format_kwargs): + try: + if message.from_user: + user_id = message.from_user.id + wait_seconds = await rate_limiter.estimate_wait_time(user_id, file_identifier) + wait_estimate = max(1, math.ceil(wait_seconds / 60)) + + text = template.format(wait_estimate=wait_estimate, s="s" if wait_estimate > 1 else "", **format_kwargs) + + try: + return await bot.send_message( + chat_id=message.chat.id, + text=text, + reply_to_message_id=message.id + ) + except FloodWait as e: + await asyncio.sleep(e.value) + return await bot.send_message( + chat_id=message.chat.id, + text=text, + reply_to_message_id=message.id + ) + else: + logger.debug("Skipping notification for channel message (no from_user)") + return None + except (FloodWait, RPCError) as e: + user_id = message.from_user.id if message.from_user else "channel" + logger.warning(f"Error sending notification to user {user_id}: {e}") + except Exception as e: + logger.error(f"Unexpected error sending notification: {e}", exc_info=True) + return None + + +async def send_queue_notification(bot: Client, message: Message, is_priority: bool, file_identifier: Optional[str]): + if is_priority: + template = MSG_RATE_LIMIT_QUEUE_PRIORITY + params = {} + else: + template = MSG_RATE_LIMIT_QUEUE_REGULAR + time_window = rate_limiter.rate_limit_period_seconds // 60 + params = { + "max_requests": rate_limiter.max_requests_per_period, + "time_window": time_window, + "s1": "s" if rate_limiter.max_requests_per_period > 1 else "", + "s2": "s" if time_window > 1 else "" + } + user_id = message.from_user.id if message.from_user else "channel" + logger.debug(f"Sending {'priority' if is_priority else 'regular'} queue notification to user {user_id}") + return await _send_notification(bot, message, template, file_identifier, **params) + + +async def send_queue_full_message(bot: Client, message: Message, file_identifier: Optional[str]): + user_id = message.from_user.id if message.from_user else "channel" + logger.debug(f"Sending queue full message to user {user_id}") + await _send_notification(bot, message, MSG_RATE_LIMIT_QUEUE_FULL, file_identifier) diff --git a/Thunder/utils/render_template.py b/Thunder/utils/render_template.py old mode 100644 new mode 100755 diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py old mode 100644 new mode 100755 diff --git a/Thunder/utils/speedtest.py b/Thunder/utils/speedtest.py old mode 100644 new mode 100755 index bdd94cc..bd578cc --- a/Thunder/utils/speedtest.py +++ b/Thunder/utils/speedtest.py @@ -1,43 +1,43 @@ -# Thunder/utils/speedtest.py - -import asyncio -from typing import Optional, Tuple, Dict, Any - -import speedtest -from Thunder.utils.logger import logger - - -async def run_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - try: - return await asyncio.to_thread(_perform_speedtest) - except Exception as e: - logger.error(f"Speedtest failed: {e}", exc_info=True) - return None, None - - -def _perform_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - try: - st = speedtest.Speedtest(timeout=15, secure=True) - st.get_best_server() - st.download() - st.upload(pre_allocate=False) - - results = st.results.dict() - download_mbps = st.results.download / 1_000_000 - upload_mbps = st.results.upload / 1_000_000 - - results['download_mbps'] = download_mbps - results['upload_mbps'] = upload_mbps - results['download_bps'] = st.results.download / 8 - results['upload_bps'] = st.results.upload / 8 - - logger.debug(f"Download: {download_mbps:.2f} Mbps | Upload: {upload_mbps:.2f} Mbps") - - try: - return results, st.results.share() - except Exception: - return results, None - - except Exception as e: - logger.error(f"Speedtest failed: {e}") - return None, None +# Thunder/utils/speedtest.py + +import asyncio +from typing import Optional, Tuple, Dict, Any + +import speedtest +from Thunder.utils.logger import logger + + +async def run_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + try: + return await asyncio.to_thread(_perform_speedtest) + except Exception as e: + logger.error(f"Speedtest failed: {e}", exc_info=True) + return None, None + + +def _perform_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: + try: + st = speedtest.Speedtest(timeout=15, secure=True) + st.get_best_server() + st.download() + st.upload(pre_allocate=False) + + results = st.results.dict() + download_mbps = st.results.download / 1_000_000 + upload_mbps = st.results.upload / 1_000_000 + + results['download_mbps'] = download_mbps + results['upload_mbps'] = upload_mbps + results['download_bps'] = st.results.download / 8 + results['upload_bps'] = st.results.upload / 8 + + logger.debug(f"Download: {download_mbps:.2f} Mbps | Upload: {upload_mbps:.2f} Mbps") + + try: + return results, st.results.share() + except Exception: + return results, None + + except Exception as e: + logger.error(f"Speedtest failed: {e}") + return None, None diff --git a/Thunder/utils/time_format.py b/Thunder/utils/time_format.py old mode 100644 new mode 100755 index 6900312..172712a --- a/Thunder/utils/time_format.py +++ b/Thunder/utils/time_format.py @@ -1,17 +1,17 @@ -# Thunder/utils/time_format.py - -from Thunder.utils.logger import logger - -_TIME_PERIODS = (('d', 86400), ('h', 3600), ('m', 60), ('s', 1)) - -def get_readable_time(seconds: int) -> str: - try: - result = [] - for suffix, period in _TIME_PERIODS: - if seconds >= period: - value, seconds = divmod(int(seconds), period) - result.append(f"{int(value)}{suffix}") - return ' '.join(result) if result else '0s' - except Exception as e: - logger.error(f"Error in get_readable_time: {e}", exc_info=True) - return "N/A" +# Thunder/utils/time_format.py + +from Thunder.utils.logger import logger + +_TIME_PERIODS = (('d', 86400), ('h', 3600), ('m', 60), ('s', 1)) + +def get_readable_time(seconds: int) -> str: + try: + result = [] + for suffix, period in _TIME_PERIODS: + if seconds >= period: + value, seconds = divmod(int(seconds), period) + result.append(f"{int(value)}{suffix}") + return ' '.join(result) if result else '0s' + except Exception as e: + logger.error(f"Error in get_readable_time: {e}", exc_info=True) + return "N/A" diff --git a/Thunder/utils/tokens.py b/Thunder/utils/tokens.py old mode 100644 new mode 100755 index 7602680..f9eeb42 --- a/Thunder/utils/tokens.py +++ b/Thunder/utils/tokens.py @@ -1,160 +1,160 @@ -# Thunder/utils/tokens.py - -import secrets -from datetime import datetime, timedelta -from typing import Optional, Dict, Any, List -import asyncio -import random -import pyrogram.errors -from Thunder.utils.database import db -from Thunder.vars import Var -from Thunder.utils.logger import logger - -async def check(user_id: int) -> bool: - try: - logger.debug(f"Token validation started for user: {user_id}") - if not getattr(Var, "TOKEN_ENABLED", False): - logger.debug("Token system disabled - access granted") - return True - if user_id == Var.OWNER_ID: - logger.debug("Owner access granted") - return True - current_time = datetime.utcnow() - auth_result = await db.authorized_users_col.find_one( - {"user_id": user_id}, - {"_id": 1} - ) - if auth_result: - return True - token_result = await db.token_col.find_one( - {"user_id": user_id, "expires_at": {"$gt": current_time}, "activated": True}, - {"_id": 1} - ) - access_granted = bool(token_result) - logger.debug(f"Token validation {'SUCCESS' if access_granted else 'FAILURE'} for user: {user_id}") - return access_granted - except Exception as e: - logger.error(f"Error in check for user {user_id}: {e}", exc_info=True) - raise - -async def generate(user_id: int) -> str: - try: - logger.debug(f"Token generation started for user: {user_id}") - existing_token_doc = await db.token_col.find_one( - {"user_id": user_id, "activated": False, "expires_at": {"$gt": datetime.utcnow()}}, - {"token": 1} - ) - if existing_token_doc: - logger.debug(f"Returning existing unactivated token for user: {user_id}") - return existing_token_doc["token"] - token_str = secrets.token_urlsafe(32) - masked_token = f"{token_str[:4]}...{token_str[-4:]}" - logger.debug(f"Generated new token: {masked_token}") - max_retries = 3 - base_delay = 0.5 - for attempt in range(max_retries): - try: - ttl_hours = getattr(Var, "TOKEN_TTL_HOURS", 24) - created_at = datetime.utcnow() - expires_at = created_at + timedelta(hours=ttl_hours) - await db.save_main_token( - user_id=user_id, - token_value=token_str, - expires_at=expires_at, - created_at=created_at, - activated=False - ) - logger.debug(f"New token generated and saved successfully for user: {user_id}") - return token_str - except pyrogram.errors.RPCError as e: - logger.error(f"Telegram API error while generating new token for user {user_id}: {e}", exc_info=True) - raise - except Exception as e: - if attempt < max_retries - 1: - delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1) - logger.warning(f"Database error (attempt {attempt+1}/{max_retries}) while saving new token: {e}. Retrying in {delay:.2f} seconds.", exc_info=True) - await asyncio.sleep(delay) - else: - logger.error(f"Failed to generate and save new token for user {user_id} after {max_retries} attempts: {e}", exc_info=True) - raise - return "" - except Exception as e: - logger.error(f"Error in generate for user {user_id}: {e}", exc_info=True) - raise - -async def allowed(user_id: int) -> bool: - try: - result = await db.authorized_users_col.find_one( - {"user_id": user_id}, - {"_id": 1} - ) - return bool(result) - except Exception as e: - logger.error(f"Error in allowed for user {user_id}: {e}", exc_info=True) - raise - -async def authorize(user_id: int, authorized_by: int) -> bool: - try: - auth_data = { - "user_id": user_id, - "authorized_by": authorized_by, - "authorized_at": datetime.utcnow() - } - await db.authorized_users_col.update_one( - {"user_id": user_id}, - {"$set": auth_data}, - upsert=True - ) - return True - except Exception as e: - logger.error(f"Error in authorize for user {user_id}: {e}", exc_info=True) - raise - -async def deauthorize(user_id: int) -> bool: - try: - result = await db.authorized_users_col.delete_one({"user_id": user_id}) - return result.deleted_count > 0 - except Exception as e: - logger.error(f"Error in deauthorize for user {user_id}: {e}", exc_info=True) - raise - -async def get_user(user_id: int) -> Optional[Dict[str, Any]]: - try: - return await db.token_col.find_one({"user_id": user_id}) - except Exception as e: - logger.error(f"Error in get_user for user {user_id}: {e}", exc_info=True) - return None - -async def list_allowed() -> List[Dict[str, Any]]: - try: - cursor = db.authorized_users_col.find( - {}, - {"user_id": 1, "authorized_by": 1, "authorized_at": 1} - ) - return await cursor.to_list(length=None) - except Exception as e: - logger.error(f"Error in list_allowed: {e}", exc_info=True) - return [] - -async def list_tokens() -> List[Dict[str, Any]]: - try: - current_time = datetime.utcnow() - cursor = db.token_col.find( - {"expires_at": {"$gt": current_time}}, - {"user_id": 1, "expires_at": 1, "created_at": 1, "activated": 1} - ) - return await cursor.to_list(length=None) - except Exception as e: - logger.error(f"Error in list_tokens: {e}", exc_info=True) - return [] - -async def cleanup_expired_tokens() -> int: - try: - current_time = datetime.utcnow() - logger.debug("Cleaning up expired tokens") - result = await db.token_col.delete_many({"expires_at": {"$lte": current_time}}) - logger.debug(f"Cleaned up {result.deleted_count} expired tokens") - return result.deleted_count - except Exception as e: - logger.error(f"Error in cleanup_expired_tokens: {e}", exc_info=True) - return 0 +# Thunder/utils/tokens.py + +import secrets +from datetime import datetime, timedelta +from typing import Optional, Dict, Any, List +import asyncio +import random +import pyrogram.errors +from Thunder.utils.database import db +from Thunder.vars import Var +from Thunder.utils.logger import logger + +async def check(user_id: int) -> bool: + try: + logger.debug(f"Token validation started for user: {user_id}") + if not getattr(Var, "TOKEN_ENABLED", False): + logger.debug("Token system disabled - access granted") + return True + if user_id == Var.OWNER_ID: + logger.debug("Owner access granted") + return True + current_time = datetime.utcnow() + auth_result = await db.authorized_users_col.find_one( + {"user_id": user_id}, + {"_id": 1} + ) + if auth_result: + return True + token_result = await db.token_col.find_one( + {"user_id": user_id, "expires_at": {"$gt": current_time}, "activated": True}, + {"_id": 1} + ) + access_granted = bool(token_result) + logger.debug(f"Token validation {'SUCCESS' if access_granted else 'FAILURE'} for user: {user_id}") + return access_granted + except Exception as e: + logger.error(f"Error in check for user {user_id}: {e}", exc_info=True) + raise + +async def generate(user_id: int) -> str: + try: + logger.debug(f"Token generation started for user: {user_id}") + existing_token_doc = await db.token_col.find_one( + {"user_id": user_id, "activated": False, "expires_at": {"$gt": datetime.utcnow()}}, + {"token": 1} + ) + if existing_token_doc: + logger.debug(f"Returning existing unactivated token for user: {user_id}") + return existing_token_doc["token"] + token_str = secrets.token_urlsafe(32) + masked_token = f"{token_str[:4]}...{token_str[-4:]}" + logger.debug(f"Generated new token: {masked_token}") + max_retries = 3 + base_delay = 0.5 + for attempt in range(max_retries): + try: + ttl_hours = getattr(Var, "TOKEN_TTL_HOURS", 24) + created_at = datetime.utcnow() + expires_at = created_at + timedelta(hours=ttl_hours) + await db.save_main_token( + user_id=user_id, + token_value=token_str, + expires_at=expires_at, + created_at=created_at, + activated=False + ) + logger.debug(f"New token generated and saved successfully for user: {user_id}") + return token_str + except pyrogram.errors.RPCError as e: + logger.error(f"Telegram API error while generating new token for user {user_id}: {e}", exc_info=True) + raise + except Exception as e: + if attempt < max_retries - 1: + delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1) + logger.warning(f"Database error (attempt {attempt+1}/{max_retries}) while saving new token: {e}. Retrying in {delay:.2f} seconds.", exc_info=True) + await asyncio.sleep(delay) + else: + logger.error(f"Failed to generate and save new token for user {user_id} after {max_retries} attempts: {e}", exc_info=True) + raise + return "" + except Exception as e: + logger.error(f"Error in generate for user {user_id}: {e}", exc_info=True) + raise + +async def allowed(user_id: int) -> bool: + try: + result = await db.authorized_users_col.find_one( + {"user_id": user_id}, + {"_id": 1} + ) + return bool(result) + except Exception as e: + logger.error(f"Error in allowed for user {user_id}: {e}", exc_info=True) + raise + +async def authorize(user_id: int, authorized_by: int) -> bool: + try: + auth_data = { + "user_id": user_id, + "authorized_by": authorized_by, + "authorized_at": datetime.utcnow() + } + await db.authorized_users_col.update_one( + {"user_id": user_id}, + {"$set": auth_data}, + upsert=True + ) + return True + except Exception as e: + logger.error(f"Error in authorize for user {user_id}: {e}", exc_info=True) + raise + +async def deauthorize(user_id: int) -> bool: + try: + result = await db.authorized_users_col.delete_one({"user_id": user_id}) + return result.deleted_count > 0 + except Exception as e: + logger.error(f"Error in deauthorize for user {user_id}: {e}", exc_info=True) + raise + +async def get_user(user_id: int) -> Optional[Dict[str, Any]]: + try: + return await db.token_col.find_one({"user_id": user_id}) + except Exception as e: + logger.error(f"Error in get_user for user {user_id}: {e}", exc_info=True) + return None + +async def list_allowed() -> List[Dict[str, Any]]: + try: + cursor = db.authorized_users_col.find( + {}, + {"user_id": 1, "authorized_by": 1, "authorized_at": 1} + ) + return await cursor.to_list(length=None) + except Exception as e: + logger.error(f"Error in list_allowed: {e}", exc_info=True) + return [] + +async def list_tokens() -> List[Dict[str, Any]]: + try: + current_time = datetime.utcnow() + cursor = db.token_col.find( + {"expires_at": {"$gt": current_time}}, + {"user_id": 1, "expires_at": 1, "created_at": 1, "activated": 1} + ) + return await cursor.to_list(length=None) + except Exception as e: + logger.error(f"Error in list_tokens: {e}", exc_info=True) + return [] + +async def cleanup_expired_tokens() -> int: + try: + current_time = datetime.utcnow() + logger.debug("Cleaning up expired tokens") + result = await db.token_col.delete_many({"expires_at": {"$lte": current_time}}) + logger.debug(f"Cleaned up {result.deleted_count} expired tokens") + return result.deleted_count + except Exception as e: + logger.error(f"Error in cleanup_expired_tokens: {e}", exc_info=True) + return 0 diff --git a/Thunder/vars.py b/Thunder/vars.py old mode 100644 new mode 100755 index 75090ac..5a988c2 --- a/Thunder/vars.py +++ b/Thunder/vars.py @@ -1,102 +1,213 @@ -# Thunder/vars.py - -import os - -from dotenv import load_dotenv -from typing import Set, Optional -from Thunder.utils.logger import logger - -load_dotenv("config.env") - -def str_to_bool(val: str) -> bool: - return val.lower() in ("true", "1", "t", "y", "yes") - -def str_to_int_set(val: str) -> Set[int]: - if not val: - return set() - result: Set[int] = set() - for x in val.split(): - try: - result.add(int(x)) - except (TypeError, ValueError): - continue - return result - - - -class Var: - API_ID: int = int(os.getenv("API_ID", "0")) - API_HASH: str = os.getenv("API_HASH", "") - BOT_TOKEN: str = os.getenv("BOT_TOKEN", "") - - if not all([API_ID, API_HASH, BOT_TOKEN]): - logger.critical("Missing required Telegram API configuration") - raise ValueError("Missing required Telegram API configuration") - - NAME: str = os.getenv("NAME", "ThunderF2L") - SLEEP_THRESHOLD: int = int(os.getenv("SLEEP_THRESHOLD", "600")) - WORKERS: int = int(os.getenv("WORKERS", "8")) - - BIN_CHANNEL: int = int(os.getenv("BIN_CHANNEL", "0")) - - if not BIN_CHANNEL: - logger.critical("BIN_CHANNEL is required") - raise ValueError("BIN_CHANNEL is required") - - PORT: int = int(os.getenv("PORT", "8080")) - BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0") - PING_INTERVAL: int = int(os.getenv("PING_INTERVAL", "840")) - NO_PORT: bool = str_to_bool(os.getenv("NO_PORT", "True")) - - OWNER_ID: int = int(os.getenv("OWNER_ID", "0")) - - if not OWNER_ID: - logger.warning("WARNING: OWNER_ID is not set. No user will be granted owner access.") - - FQDN: str = os.getenv("FQDN", "") or BIND_ADDRESS - HAS_SSL: bool = str_to_bool(os.getenv("HAS_SSL", "True")) - PROTOCOL: str = "https" if HAS_SSL else "http" - PORT_SEGMENT: str = "" if NO_PORT else f":{PORT}" - URL: str = f"{PROTOCOL}://{FQDN}{PORT_SEGMENT}/" - - SET_COMMANDS: bool = str_to_bool(os.getenv("SET_COMMANDS", "True")) - - DATABASE_URL: str = os.getenv("DATABASE_URL", "") - - if not DATABASE_URL: - logger.critical("DATABASE_URL is required") - raise ValueError("DATABASE_URL is required") - - MAX_BATCH_FILES: int = int(os.getenv("MAX_BATCH_FILES", "50")) - - CHANNEL: bool = str_to_bool(os.getenv("CHANNEL", "False")) - - BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", "")) - - MULTI_CLIENT: bool = False - - FORCE_CHANNEL_ID: Optional[int] = None - - force_channel_env = os.getenv("FORCE_CHANNEL_ID", "").strip() - - if force_channel_env: - try: - FORCE_CHANNEL_ID = int(force_channel_env) - except ValueError: - logger.warning(f"Invalid FORCE_CHANNEL_ID '{force_channel_env}' in environment; must be an integer.") - - TOKEN_ENABLED: bool = str_to_bool(os.getenv("TOKEN_ENABLED", "False")) - TOKEN_TTL_HOURS: int = int(os.getenv("TOKEN_TTL_HOURS", "24")) - - SHORTEN_ENABLED: bool = str_to_bool(os.getenv("SHORTEN_ENABLED", "False")) - SHORTEN_MEDIA_LINKS: bool = str_to_bool(os.getenv("SHORTEN_MEDIA_LINKS", "False")) - URL_SHORTENER_API_KEY: str = os.getenv("URL_SHORTENER_API_KEY", "") - URL_SHORTENER_SITE: str = os.getenv("URL_SHORTENER_SITE", "") - - GLOBAL_RATE_LIMIT: bool = str_to_bool(os.getenv("GLOBAL_RATE_LIMIT", "False")) - MAX_GLOBAL_REQUESTS_PER_MINUTE: int = int(os.getenv("MAX_GLOBAL_REQUESTS_PER_MINUTE", "4")) - - RATE_LIMIT_ENABLED: bool = str_to_bool(os.getenv("RATE_LIMIT_ENABLED", "False")) - MAX_FILES_PER_PERIOD: int = int(os.getenv("MAX_FILES_PER_PERIOD", "2")) - RATE_LIMIT_PERIOD_MINUTES: int = int(os.getenv("RATE_LIMIT_PERIOD_MINUTES", "1")) - MAX_QUEUE_SIZE: int = int(os.getenv("MAX_QUEUE_SIZE", "100")) +# Thunder/vars.py + +"""Central configuration (plan M6). + +Boot behaviour: + +* loads ``config.env`` then ``config.env.local`` (local layer wins); +* validates **all** variables and prints every problem together before + failing -- instead of the historical first-bad-``int()`` traceback; +* names the offending variable on conversion failure and enforces bounds; +* hard-fails on missing ``OWNER_ID`` (plan H7 -- the old warning meant every + owner check silently matched nobody). + +The ``Var`` facade is kept so no import site changes. +""" + +import os +from typing import List, Optional, Set + +from dotenv import load_dotenv + +from Thunder.utils.logger import logger + +load_dotenv("config.env") +load_dotenv("config.env.local") # optional local override layer + + +def str_to_bool(val: str) -> bool: + return val.lower() in ("true", "1", "t", "y", "yes") + + +def str_to_int_set(val: str) -> Set[int]: + if not val: + return set() + result: Set[int] = set() + for x in val.split(): + try: + result.add(int(x)) + except (TypeError, ValueError): + continue + return result + + +_config_errors: List[str] = [] +_config_warnings: List[str] = [] + + +def _get_int(name: str, default: str, *, min_val: Optional[int] = None, + max_val: Optional[int] = None) -> int: + raw = os.getenv(name, default) + try: + value = int(str(raw).strip()) + except (TypeError, ValueError): + _config_errors.append(f"{name}={raw!r} is not a valid integer") + return int(default) + if min_val is not None and value < min_val: + _config_errors.append(f"{name}={value} must be >= {min_val}") + if max_val is not None and value > max_val: + _config_errors.append(f"{name}={value} must be <= {max_val}") + return value + + +def _get_float(name: str, default: str, *, min_val: Optional[float] = None) -> float: + raw = os.getenv(name, default) + try: + value = float(str(raw).strip()) + except (TypeError, ValueError): + _config_errors.append(f"{name}={raw!r} is not a valid number") + return float(default) + if min_val is not None and value < min_val: + _config_errors.append(f"{name}={value} must be >= {min_val}") + return value + + +def _require(value: object, name: str, what: str) -> None: + if not value: + _config_errors.append(f"{name} is required ({what})") + + +class Var: + # ---- Required Telegram configuration ---- + API_ID: int = _get_int("API_ID", "0", min_val=1) + API_HASH: str = os.getenv("API_HASH", "") + BOT_TOKEN: str = os.getenv("BOT_TOKEN", "") + _require(API_ID, "API_ID", "numeric app id from my.telegram.org") + _require(API_HASH, "API_HASH", "app hash from my.telegram.org") + _require(BOT_TOKEN, "BOT_TOKEN", "bot token from @BotFather") + + NAME: str = os.getenv("NAME", "ThunderF2L") + SLEEP_THRESHOLD: int = _get_int("SLEEP_THRESHOLD", "600", min_val=0) + WORKERS: int = _get_int("WORKERS", "8", min_val=1, max_val=64) + + BIN_CHANNEL: int = _get_int("BIN_CHANNEL", "0") + _require(BIN_CHANNEL, "BIN_CHANNEL", "storage channel id, e.g. -1001234567890") + + PORT: int = _get_int("PORT", "8080", min_val=1, max_val=65535) + BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0") + PING_INTERVAL: int = _get_int("PING_INTERVAL", "840", min_val=30) + NO_PORT: bool = str_to_bool(os.getenv("NO_PORT", "True")) + + # H7: missing OWNER_ID is fatal -- every owner check matched nobody before. + OWNER_ID: int = _get_int("OWNER_ID", "0", min_val=1) + _require(OWNER_ID, "OWNER_ID", "your Telegram user id (get from @userinfobot)") + + FQDN: str = os.getenv("FQDN", "") or BIND_ADDRESS + if os.getenv("FQDN", "") == "": + _config_warnings.append( + "FQDN is not set; generated links will use the bind address and " + "will not be reachable from outside this machine." + ) + HAS_SSL: bool = str_to_bool(os.getenv("HAS_SSL", "True")) + PROTOCOL: str = "https" if HAS_SSL else "http" + PORT_SEGMENT: str = "" if NO_PORT else f":{PORT}" + URL: str = f"{PROTOCOL}://{FQDN}{PORT_SEGMENT}/" + + SET_COMMANDS: bool = str_to_bool(os.getenv("SET_COMMANDS", "True")) + + DATABASE_URL: str = os.getenv("DATABASE_URL", "") + _require(DATABASE_URL, "DATABASE_URL", "MongoDB connection string") + + MAX_BATCH_FILES: int = _get_int("MAX_BATCH_FILES", "50", min_val=1, max_val=100) + + CHANNEL: bool = str_to_bool(os.getenv("CHANNEL", "False")) + BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", "")) + + # Kept for backward compatibility of env parsing; no longer read at runtime. + MULTI_CLIENT: bool = False + + FORCE_CHANNEL_ID: Optional[int] = None + force_channel_env = os.getenv("FORCE_CHANNEL_ID", "").strip() + if force_channel_env: + try: + FORCE_CHANNEL_ID = int(force_channel_env) + except ValueError: + _config_errors.append( + f"FORCE_CHANNEL_ID={force_channel_env!r} must be an integer" + ) + + TOKEN_ENABLED: bool = str_to_bool(os.getenv("TOKEN_ENABLED", "False")) + TOKEN_TTL_HOURS: int = _get_int("TOKEN_TTL_HOURS", "24", min_val=1) + + SHORTEN_ENABLED: bool = str_to_bool(os.getenv("SHORTEN_ENABLED", "False")) + SHORTEN_MEDIA_LINKS: bool = str_to_bool(os.getenv("SHORTEN_MEDIA_LINKS", "False")) + URL_SHORTENER_API_KEY: str = os.getenv("URL_SHORTENER_API_KEY", "") + URL_SHORTENER_SITE: str = os.getenv("URL_SHORTENER_SITE", "") + if (SHORTEN_ENABLED or SHORTEN_MEDIA_LINKS) and not (URL_SHORTENER_SITE and URL_SHORTENER_API_KEY): + _config_warnings.append( + "Shortener enabled but URL_SHORTENER_SITE/URL_SHORTENER_API_KEY " + "missing; links will not be shortened." + ) + + GLOBAL_RATE_LIMIT: bool = str_to_bool(os.getenv("GLOBAL_RATE_LIMIT", "False")) + MAX_GLOBAL_REQUESTS_PER_MINUTE: int = _get_int( + "MAX_GLOBAL_REQUESTS_PER_MINUTE", "4", min_val=1) + GLOBAL_RPS_LIMIT: float = _get_float( + "GLOBAL_RPS_LIMIT", "0", min_val=0) # 0 = derive from per-minute value + + RATE_LIMIT_ENABLED: bool = str_to_bool(os.getenv("RATE_LIMIT_ENABLED", "False")) + MAX_FILES_PER_PERIOD: int = _get_int("MAX_FILES_PER_PERIOD", "2", min_val=1) + RATE_LIMIT_PERIOD_MINUTES: int = _get_int("RATE_LIMIT_PERIOD_MINUTES", "1", min_val=1) + MAX_QUEUE_SIZE: int = _get_int("MAX_QUEUE_SIZE", "100", min_val=1) + + # ---- New knobs introduced by the improvement plan ---- + + # M12: allowlist mode -- only owner + authorized users may use the bot. + PRIVATE_MODE: bool = str_to_bool(os.getenv("PRIVATE_MODE", "False")) + + # L1: legacy /watch/{hash}{id} URL family (default on; removal planned). + ENABLE_LEGACY_LINKS: bool = str_to_bool(os.getenv("ENABLE_LEGACY_LINKS", "True")) + + # L10: /shell kill-switch -- powerful owner command is opt-in. + ENABLE_SHELL: bool = str_to_bool(os.getenv("ENABLE_SHELL", "False")) + + # L2: optional expiry for file records (0 = keep forever). + FILE_TTL_DAYS: int = _get_int("FILE_TTL_DAYS", "0", min_val=0, max_val=3650) + + # H6b: queue executor worker pool. + EXECUTOR_WORKERS: int = _get_int("EXECUTOR_WORKERS", "5", min_val=1, max_val=32) + + # M4: worker pools for broadcast / batch. + BROADCAST_WORKERS: int = _get_int("BROADCAST_WORKERS", "4", min_val=1, max_val=16) + BATCH_WORKERS: int = _get_int("BATCH_WORKERS", "5", min_val=1, max_val=16) + + # M9: per-client admission cap for the streaming server. + MAX_CONCURRENT_STREAMS: int = _get_int("MAX_CONCURRENT_STREAMS", "8", min_val=1) + + # M14: touch-buffer flush cadence + cap. + TOUCH_FLUSH_SECONDS: int = _get_int("TOUCH_FLUSH_SECONDS", "3", min_val=1, max_val=60) + TOUCH_BUFFER_MAX: int = _get_int("TOUCH_BUFFER_MAX", "1000", min_val=100) + + # H8: default wall-clock budget for lightweight Telegram RPCs. + TG_RPC_TIMEOUT_SECONDS: float = _get_float("TG_RPC_TIMEOUT_SECONDS", "30", min_val=0) + + # H10: logging. + LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper() + LOG_FORMAT: str = os.getenv("LOG_FORMAT", "plain").lower() + + +if _config_errors: + logger.critical("Invalid configuration -- %d problem(s) found:" % len(_config_errors)) + for err in _config_errors: + logger.critical(f" βœ– {err}") + raise SystemExit( + f"Configuration invalid: fix {len(_config_errors)} problem(s) listed above " + "in config.env / environment and start again." + ) + +if _config_warnings: + for warn in _config_warnings: + logger.warning(f" ⚠ {warn}") + +mode_note = "PRIVATE" if Var.PRIVATE_MODE else "public" +logger.info(f"Gate mode: {mode_note}; legacy links: {'on' if Var.ENABLE_LEGACY_LINKS else 'off'}") diff --git a/config_sample.env b/config_sample.env old mode 100644 new mode 100755 index b83b434..973889b --- a/config_sample.env +++ b/config_sample.env @@ -1,125 +1,125 @@ -# ============================================================= -# Rename this file to config.env before using -# ============================================================= - -#################### -## REQUIRED SETTINGS -#################### - -# Telegram API credentials (from https://my.telegram.org/apps) -API_ID=0 # Example: 1234567 -API_HASH="" # Example: "abc123def456" - -# Bot token (from @BotFather) -BOT_TOKEN="" # Example: "123456789:ABCdef..." - -# Storage channel ID (create a channel and add bot as admin) -BIN_CHANNEL=0 # Example: -1001234567890 - -# Owner information (get ID from @userinfobot) -OWNER_ID=0 # Your Telegram user ID. Example: 123456789 - -# Database connection string -DATABASE_URL="" # Example: "mongodb+srv://user:pass@host/db" - -# Deployment configuration -FQDN="" # Your domain name -HAS_SSL="True" # Set to "True" if using HTTPS -PORT=8080 # Web server port -NO_PORT="True" # Hide port in URLs ("True" or "False") - -#################### -## OPTIONAL SETTINGS -#################### - -MAX_BATCH_FILES=50 - -# Set bot commands on startup (True/False) -SET_COMMANDS="True" - -# Force users to join a specific channel before using the bot -FORCE_CHANNEL_ID="" # Example: -1001234567890 (Leave empty if not needed) - -# Allow processing of channel messages (True/False) -CHANNEL="False" - -# Banned channels (files from these channels will be rejected) -BANNED_CHANNELS="" # Example: "-1001234567890 -100987654321" (Space-separated IDs, leave empty if none) - -# Multiple bot tokens (can add up to MULTI_TOKEN49) # Example: MULTI_TOKEN49="123456789:ABCdef..." -MULTI_TOKEN1="" - -#################### -## TOKEN SYSTEM SETTINGS -#################### - -# Enable token-based access (True/False) -TOKEN_ENABLED="False" - -# Default token validity in hours -TOKEN_TTL_HOURS="24" - -#################### -## URL SHORTENER SETTINGS -#################### - -# Enable URL shortening for tokens (True/False) -SHORTEN_ENABLED="False" - -# Enable URL shortening for media links (True/False) -SHORTEN_MEDIA_LINKS="False" - -# URL Shortener -URL_SHORTENER_API_KEY="" # Example: "abc123def456" -URL_SHORTENER_SITE="" # Example: "example.com" - -#################### -## GLOBAL RATE LIMITING SETTINGS -#################### - -# Enable global rate limiting (True/False) -GLOBAL_RATE_LIMIT="False" - -# Maximum number of requests allowed across all users per minute -MAX_GLOBAL_REQUESTS_PER_MINUTE=4 - -#################### -## RATE LIMITING SETTINGS -#################### - -# Enable rate limiting (True/False) -RATE_LIMIT_ENABLED="False" - -MAX_FILES_PER_PERIOD=2 - -# Time window in minutes for rate limiting -RATE_LIMIT_PERIOD_MINUTES=1 - -# Maximum number of requests that can be queued. -MAX_QUEUE_SIZE=100 - -#################### -## UPDATE SETTINGS -#################### - -# Git repository for updates -UPSTREAM_REPO="https://github.com/fyaz05/FileToLink" - -# Branch to update from -UPSTREAM_BRANCH="main" # Default branch for updates - -#################### -## ADVANCED SETTINGS (modify with caution) -#################### - -# Application name -NAME="ThunderF2L" # Bot application name - -# Performance settings -SLEEP_THRESHOLD=600 # Sleep time in seconds -WORKERS=8 # Number of worker processes - -# Web server configuration -BIND_ADDRESS="0.0.0.0" # Listen on all network interfaces -PING_INTERVAL=840 # Ping interval in seconds - +# ============================================================= +# Rename this file to config.env before using +# ============================================================= + +#################### +## REQUIRED SETTINGS +#################### + +# Telegram API credentials (from https://my.telegram.org/apps) +API_ID=0 # Example: 1234567 +API_HASH="" # Example: "abc123def456" + +# Bot token (from @BotFather) +BOT_TOKEN="" # Example: "123456789:ABCdef..." + +# Storage channel ID (create a channel and add bot as admin) +BIN_CHANNEL=0 # Example: -1001234567890 + +# Owner information (get ID from @userinfobot) +OWNER_ID=0 # Your Telegram user ID. Example: 123456789 + +# Database connection string +DATABASE_URL="" # Example: "mongodb+srv://user:pass@host/db" + +# Deployment configuration +FQDN="" # Your domain name +HAS_SSL="True" # Set to "True" if using HTTPS +PORT=8080 # Web server port +NO_PORT="True" # Hide port in URLs ("True" or "False") + +#################### +## OPTIONAL SETTINGS +#################### + +MAX_BATCH_FILES=50 + +# Set bot commands on startup (True/False) +SET_COMMANDS="True" + +# Force users to join a specific channel before using the bot +FORCE_CHANNEL_ID="" # Example: -1001234567890 (Leave empty if not needed) + +# Allow processing of channel messages (True/False) +CHANNEL="False" + +# Banned channels (files from these channels will be rejected) +BANNED_CHANNELS="" # Example: "-1001234567890 -100987654321" (Space-separated IDs, leave empty if none) + +# Multiple bot tokens (can add up to MULTI_TOKEN49) # Example: MULTI_TOKEN49="123456789:ABCdef..." +MULTI_TOKEN1="" + +#################### +## TOKEN SYSTEM SETTINGS +#################### + +# Enable token-based access (True/False) +TOKEN_ENABLED="False" + +# Default token validity in hours +TOKEN_TTL_HOURS="24" + +#################### +## URL SHORTENER SETTINGS +#################### + +# Enable URL shortening for tokens (True/False) +SHORTEN_ENABLED="False" + +# Enable URL shortening for media links (True/False) +SHORTEN_MEDIA_LINKS="False" + +# URL Shortener +URL_SHORTENER_API_KEY="" # Example: "abc123def456" +URL_SHORTENER_SITE="" # Example: "example.com" + +#################### +## GLOBAL RATE LIMITING SETTINGS +#################### + +# Enable global rate limiting (True/False) +GLOBAL_RATE_LIMIT="False" + +# Maximum number of requests allowed across all users per minute +MAX_GLOBAL_REQUESTS_PER_MINUTE=4 + +#################### +## RATE LIMITING SETTINGS +#################### + +# Enable rate limiting (True/False) +RATE_LIMIT_ENABLED="False" + +MAX_FILES_PER_PERIOD=2 + +# Time window in minutes for rate limiting +RATE_LIMIT_PERIOD_MINUTES=1 + +# Maximum number of requests that can be queued. +MAX_QUEUE_SIZE=100 + +#################### +## UPDATE SETTINGS +#################### + +# Git repository for updates +UPSTREAM_REPO="https://github.com/fyaz05/FileToLink" + +# Branch to update from +UPSTREAM_BRANCH="main" # Default branch for updates + +#################### +## ADVANCED SETTINGS (modify with caution) +#################### + +# Application name +NAME="ThunderF2L" # Bot application name + +# Performance settings +SLEEP_THRESHOLD=600 # Sleep time in seconds +WORKERS=8 # Number of worker processes + +# Web server configuration +BIND_ADDRESS="0.0.0.0" # Listen on all network interfaces +PING_INTERVAL=840 # Ping interval in seconds + diff --git a/heroku.yml b/heroku.yml old mode 100644 new mode 100755 index 24efeb8..8eec25b --- a/heroku.yml +++ b/heroku.yml @@ -1,3 +1,3 @@ -build: - docker: - web: Dockerfile +build: + docker: + web: Dockerfile diff --git a/requirements.txt b/requirements.txt old mode 100644 new mode 100755 index 0ab99f4..b4b19f8 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -aiohttp -cloudscraper -Jinja2 -pyrofork -pymongo -psutil -python-dotenv -speedtest-cli -tgcrypto -uvloop==0.21.0 +aiohttp +cloudscraper +Jinja2 +pyrofork +pymongo +psutil +python-dotenv +speedtest-cli +tgcrypto +uvloop==0.21.0 diff --git a/thunder.sh b/thunder.sh old mode 100644 new mode 100755 diff --git a/update.py b/update.py old mode 100644 new mode 100755 index 801e08c..0e34108 --- a/update.py +++ b/update.py @@ -1,41 +1,41 @@ -from os import path as opath, getenv, rename -from subprocess import run as srun -from dotenv import load_dotenv -from Thunder.utils.logger import logger - -load_dotenv('config.env', override=True) - -UPSTREAM_REPO = getenv('UPSTREAM_REPO', "") -UPSTREAM_BRANCH = getenv('UPSTREAM_BRANCH', "main") - -if UPSTREAM_REPO: - config_backup = '../config.env.tmp' - - try: - if opath.exists('config.env'): - rename('config.env', config_backup) - - if opath.exists('.git'): - srun(["rm", "-rf", ".git"]) - - git_commands = ( - f"git init -q && " - f"git config --global user.email thunder@update.local && " - f"git config --global user.name Thunder && " - f"git add . && " - f"git commit -sm update -q && " - f"git remote add origin {UPSTREAM_REPO} && " - f"git fetch origin -q && " - f"git reset --hard origin/{UPSTREAM_BRANCH} -q" - ) - - result = srun(git_commands, shell=True) - - if result.returncode == 0: - logger.info('Successfully updated with latest commit from UPSTREAM_REPO') - else: - logger.error('Something went wrong while updating, check UPSTREAM_REPO if valid or not!') - - finally: - if opath.exists(config_backup): - rename(config_backup, 'config.env') +from os import path as opath, getenv, rename +from subprocess import run as srun +from dotenv import load_dotenv +from Thunder.utils.logger import logger + +load_dotenv('config.env', override=True) + +UPSTREAM_REPO = getenv('UPSTREAM_REPO', "") +UPSTREAM_BRANCH = getenv('UPSTREAM_BRANCH', "main") + +if UPSTREAM_REPO: + config_backup = '../config.env.tmp' + + try: + if opath.exists('config.env'): + rename('config.env', config_backup) + + if opath.exists('.git'): + srun(["rm", "-rf", ".git"]) + + git_commands = ( + f"git init -q && " + f"git config --global user.email thunder@update.local && " + f"git config --global user.name Thunder && " + f"git add . && " + f"git commit -sm update -q && " + f"git remote add origin {UPSTREAM_REPO} && " + f"git fetch origin -q && " + f"git reset --hard origin/{UPSTREAM_BRANCH} -q" + ) + + result = srun(git_commands, shell=True) + + if result.returncode == 0: + logger.info('Successfully updated with latest commit from UPSTREAM_REPO') + else: + logger.error('Something went wrong while updating, check UPSTREAM_REPO if valid or not!') + + finally: + if opath.exists(config_backup): + rename(config_backup, 'config.env') From 3066f3d2e7a78382a46416c3281cfaae1f6c55ad Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 10:24:27 +0000 Subject: [PATCH 02/49] feat(P1): pinned deps + pyproject + quality CI gates + repo hygiene (H1, H3, L8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 β€” security dependency pass: - pyproject.toml declares 8 exact-pinned runtime deps (was 10 unpinned) - aiohttp 3.14.3 (OSV-clean; the 3 Jan-2026 CVEs), uvloop 0.22.1 (py3.13), tgcrypto -> tgcrypto-pyrofork 1.2.8 (maintained crypto backend) - requirements.txt kept as a generated export for the Docker path - pip-audit verified clean locally; enforced in CI H3 β€” quality.yml gates (replaces docker-only CI; docker build now runs after quality passes): - ruff check + format, mypy (permissive baseline), pytest unit tier, pip-audit, bandit -ll, vulture dead-code gate (whitelist.py), dependency-count gate (>8 direct deps fails the build) - .pre-commit-config.yaml mirrors the local workflow L8 β€” repo hygiene pack: - Dockerfile: non-root USER, HEALTHCHECK -> /health, git kept for H9 updates - Makefile (format/lint/test/audit/run), .dockerignore (image no longer ships .git/README/tests), dependabot.yml (pip + github-actions) - SECURITY.md, CONTRIBUTING.md - AGENTS.md rewritten: new conventions (tg_call, preflight chain, HTML escaping, budgets), command table generated from bot/registry.py - config_sample.env: every new env var annotated (PRIVATE_MODE, ENABLE_SHELL, ENABLE_LEGACY_LINKS, FILE_TTL_DAYS, worker pools, MAX_CONCURRENT_STREAMS, touch buffer, LOG_LEVEL/LOG_FORMAT, ...) --- .dockerignore | 19 ++++ .github/dependabot.yml | 10 ++ .github/workflows/dockerize.yml | 26 ------ .github/workflows/quality.yml | 73 +++++++++++++++ .gitignore | 6 ++ .pre-commit-config.yaml | 13 +++ AGENTS.md | 161 +++++++++++++++++++++++++------- CONTRIBUTING.md | 28 ++++++ Dockerfile | 12 ++- Makefile | 28 ++++++ SECURITY.md | 14 +++ Thunder/template/dl.html | 27 ------ Thunder/utils/speedtest.py | 43 --------- config_sample.env | 81 ++++++++++++++-- pyproject.toml | 73 +++++++++++++++ requirements.txt | 20 ++-- whitelist.py | 16 ++++ 17 files changed, 499 insertions(+), 151 deletions(-) create mode 100644 .dockerignore create mode 100644 .github/dependabot.yml delete mode 100755 .github/workflows/dockerize.yml create mode 100644 .github/workflows/quality.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 Makefile create mode 100644 SECURITY.md delete mode 100755 Thunder/template/dl.html delete mode 100755 Thunder/utils/speedtest.py create mode 100644 pyproject.toml create mode 100644 whitelist.py diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3ad3ef --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +.git +.gitignore +.gitattributes +.github +.venv +.vscode +__pycache__ +*.py[cod] +*.session* +logs/ +tests/ +htmlcov/ +.coverage +README.md +CONTRIBUTING.md +SECURITY.md +Makefile +config.env +config.env.local diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..563cc5c --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,10 @@ +version: 2 +updates: + - package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/dockerize.yml b/.github/workflows/dockerize.yml deleted file mode 100755 index 537af40..0000000 --- a/.github/workflows/dockerize.yml +++ /dev/null @@ -1,26 +0,0 @@ -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/.github/workflows/quality.yml b/.github/workflows/quality.yml new file mode 100644 index 0000000..de56113 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,73 @@ +name: Quality Gates + +on: + push: + branches: [main] + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install pytest pytest-asyncio pytest-cov ruff mypy bandit vulture pip-audit + + - name: Ruff (lint + format check) + run: | + ruff check Thunder/ update.py + ruff format --check Thunder/ update.py + + - name: Mypy (permissive baseline, tightened per phase) + run: mypy Thunder --ignore-missing-imports + continue-on-error: true + + - name: Unit tests + run: pytest -m unit + + - name: pip-audit + run: pip-audit -r requirements.txt --strict || pip-audit -r requirements.txt + + - name: Bandit (medium+ severity) + run: bandit -r Thunder -ll --skip B101 + + - name: Vulture (dead-code gate, whitelisted) + run: vulture Thunder whitelist.py --min-confidence 80 + + - name: Dependency count gate (leanness is permanent) + run: | + COUNT=$(grep -cE '^[a-zA-Z0-9_-]+==' requirements.txt) + echo "Direct runtime deps: $COUNT" + if [ "$COUNT" -gt 8 ]; then + echo "::error::Direct dependency count increased beyond the agreed 8; justify in the PR or remove." + exit 1 + fi + + docker: + if: github.repository == 'fyaz05/FileToLink' && github.event_name == 'push' + needs: quality + 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 index 514cd43..3ca1470 100755 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,12 @@ .venv/ .Python config.env +config.env.local +.coverage +htmlcov/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ log.text .vscode/ **/__pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3f6f8a8 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.8.4 + hooks: + - id: ruff + args: [--fix] + - id: ruff-format + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files diff --git a/AGENTS.md b/AGENTS.md index a8549d0..4227f81 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,80 +1,171 @@ # AGENTS.md β€” Thunder File-to-Link Bot -Python 3.13+ Telegram bot converting files to direct HTTP links. Uses Pyrofork, aiohttp, MongoDB, uvloop. +Python 3.13 Telegram bot converting files to direct HTTP links. Uses Pyrofork, aiohttp, MongoDB, uvloop. + +This file is the agent/dev handbook: conventions here are enforced by CI +(`quality.yml`) and by tests. Update it in the same PR that changes behavior +or configuration. ## Run ```bash python -m Thunder # Primary entry point -bash thunder.sh # Runs python3 update.py && python3 -m Thunder +bash thunder.sh # best-effort self-update (shell-free) + python3 -m Thunder ``` ## Dependencies +Managed in `pyproject.toml`, exported to `requirements.txt` (8 direct deps, +all exact-pinned; the CI dependency-count gate fails beyond 8): + ```bash pip install -r requirements.txt -# aiohttp, cloudscraper, Jinja2, pyrofork, pymongo, psutil, python-dotenv, speedtest-cli, tgcrypto, uvloop==0.21.0 +# aiohttp, pyrofork, tgcrypto-pyrofork, pymongo, Jinja2, python-dotenv, psutil, uvloop +``` + +`cloudscraper` and `speedtest-cli` were removed (unmaintained / archived). +For Cloudflare-protected shorteners install the optional extra: +`pip install .[shortener-cf]` (curl_cffi). + +## Development + +```bash +make format # ruff autofix + format +make lint # ruff + mypy (permissive) +make test # unit tier (hermetic: no network, no Mongo) +make audit # pip-audit + bandit + vulture + dependency-count ``` +## Test tiers + +- **Unit** (default, every PR): `pytest -m unit` β€” pure logic only. +- **Integration** (opt-in, needs Docker): `TEST_INTEGRATION=1 pytest -m integration` + β€” testcontainers MongoDB; ingest-claim locks, token-activation atomicity. +- Characterization tests pin behavior before refactors; update them + deliberately inside the PR that changes behavior. + ## Project Structure ```text Thunder/ β”œβ”€β”€ __init__.py # __version__, StartTime -β”œβ”€β”€ __main__.py # Entry: start_services() via asyncio -β”œβ”€β”€ vars.py # Configuration from env vars +β”œβ”€β”€ __main__.py # Entry: start_services(), executor pool, sweepers, M13 shutdown +β”œβ”€β”€ vars.py # Config: .env layering, collects ALL validation errors, named failures β”œβ”€β”€ bot/ β”‚ β”œβ”€β”€ __init__.py # StreamBot client, multi_clients, work_loads -β”‚ β”œβ”€β”€ clients.py # Multi-client management +β”‚ β”œβ”€β”€ registry.py # Single command registry β†’ menu / help / AGENTS.md (M1) +β”‚ β”œβ”€β”€ clients.py # Multi-client management + session-file chmod 0600 (L5) β”‚ └── plugins/ -β”‚ β”œβ”€β”€ admin.py # Owner commands: /users /broadcast /status /stats /restart /log /authorize /deauthorize /ban /unban /shell /speedtest -β”‚ β”œβ”€β”€ callbacks.py # Inline keyboard handlers +β”‚ β”œβ”€β”€ admin.py # Owner commands (speedtest removed; /log redacted; /shell opt-in) +β”‚ β”œβ”€β”€ callbacks.py # Inline keyboard handlers + panic-isolation guard (M11) β”‚ β”œβ”€β”€ common.py # User commands: /start /help /about /dc /ping -β”‚ └── stream.py # /link (groups), private/channel media handlers +β”‚ └── stream.py # /link (groups), private/channel media, batch worker pool (M4b) β”œβ”€β”€ server/ -β”‚ β”œβ”€β”€ __init__.py # web_server() β€” creates aiohttp app with routes -β”‚ β”œβ”€β”€ stream_routes.py # HTTP streaming endpoints -β”‚ └── exceptions.py # Custom HTTP exceptions -β”œβ”€β”€ utils/ # 20 modules β€” see imports below -└── template/ # dl.html, req.html (Jinja2) +β”‚ β”œβ”€β”€ __init__.py # web_server() + access-log middleware with hashed tokens (H10) +β”‚ β”œβ”€β”€ stream_routes.py # HTTP endpoints: /health /status /activate/{token} /f /watch +β”‚ └── exceptions.py # Custom exceptions +β”œβ”€β”€ utils/ # safe_call, flag_cache, media_types are new foundational modules +└── template/ # req.html (typed player: video/audio/image/other) ``` +## Commands (generated from Thunder/bot/registry.py β€” keep in sync) + +| Command | Access | Description | +|---|---|---| +| `/start` | user | Start the bot and get a welcome message | +| `/help` | user | Show help and usage instructions | +| `/link` | group | Generate a direct link for a file or batch | +| `/dc` | user | Retrieve the data center (DC) information of a user or file | +| `/ping` | user | Check the bot's status and response time | +| `/about` | user | Get information about the bot | +| `/users` | owner | Show the total number of users | +| `/status` | owner | View bot details and current workload | +| `/stats` | owner | View usage statistics and resource consumption | +| `/broadcast` | owner | Send a message to all users | +| `/ban` | owner | Ban a user | +| `/unban` | owner | Unban a user | +| `/log` | owner | Send redacted bot logs | +| `/restart` | owner | Update and restart the bot | +| `/shell` | owner | Execute a shell command (requires `ENABLE_SHELL=True`) | +| `/authorize` | owner | Grant permanent access to a user | +| `/deauthorize` | owner | Remove permanent access from a user | +| `/listauth` | owner | List all authorized users | + +Owner-only commands are hidden from the Telegram command menu. + ## Key Imports ```python -from Thunder.utils.logger import logger # Async-safe QueueHandler logger, writes to Thunder/logs/bot.txt -from Thunder.utils.database import db # AsyncMongoClient singleton -from Thunder.utils.rate_limiter import rate_limiter, request_executor, handle_rate_limited_request -from Thunder.utils.bot_utils import is_admin # async def is_admin(cli, chat_id_val) -> bool β€” checks bot membership, NOT a decorator -from Thunder.utils.decorators import owner_only # async guard function, not a decorator +from Thunder.utils.logger import logger # leveled logger; LOG_LEVEL/LOG_FORMAT envs +from Thunder.utils.logger import redact_secrets # shared token/Mongo-URI redaction (H10) +from Thunder.utils.database import db # AsyncMongoClient singleton, timeoutMS=5000 +from Thunder.utils.safe_call import tg_call # FloodWait-safe RPC helper (H4a) + wrappers +from Thunder.utils.flag_cache import flags # TTL+LRU flag cache (H7) +from Thunder.utils.rate_limiter import rate_limiter, handle_rate_limited_request, start_executors +from Thunder.utils.decorators import preflight # unified gate chain (M12) from Thunder.vars import Var # All env config ``` ## Code Conventions -- PEP 8, 4-space indent, 120-char lines -- Imports: stdlib β†’ third-party β†’ local -- All I/O is async; use `asyncio.sleep()` not `time.sleep()` -- Catch `FloodWait` from Telegram API with `await asyncio.sleep(e.value)` -- Log with `logger.error(..., exc_info=True)` for exceptions -- Admin access: `filters.user(Var.OWNER_ID)` on Pyrogram handlers (not `is_admin()`) +- PEP 8, 4-space indent; ruff (E,F,W,I,UP,B,SIM) enforced in CI +- Imports: stdlib β†’ third-party β†’ local (ruff `I` sorts them) +- All I/O is async; blocking calls go through `asyncio.to_thread` +- **Never write `try/except FloodWait` pairs**: call `tg_call(fn, *args, + retries=1, timeout=...)` or a wrapper (`reply_safe`, `send_safe`, + `edit_safe`, `delete_safe`, `answer_safe`). The only allowed inline + FloodWait loops are the streaming/pool paths in `custom_dl.py` and the + ingest retry loop in `canonical_files.py`. +- Every external call has a budget: Mongo `timeoutMS=5000`, Telegram RPC + `TG_RPC_TIMEOUT_SECONDS` (file transfers unbounded by default), shortener + and keepalive 10 s +- `html.escape()` every user-controlled string interpolated into HTML + messages (M7); user-facing link/welcome/help surfaces are HTML now +- Fail-closed: flag lookups deny on DB errors with `MSG_ERROR_TEMP` (H7) +- Admin access: `filters.user(Var.OWNER_ID)` on Pyrogram handlers - Naming: PascalCase classes, snake_case functions/vars, UPPER_SNAKE_CASE constants +## Access gates (M12 preflight chain β€” documented ordering) + +`banned β†’ private-mode β†’ token-activation β†’ force-sub β†’ shortener-status` + +- Owner bypasses everything; authorized users bypass all but the ban check. +- `/start` runs only `banned + private-mode` so the activation flow stays reachable. +- `PRIVATE_MODE=True` restricts the whole bot to owner + authorized users. +- Adding a new gate = one entry in `PREFLIGHT_GATES` + a row above. + ## Rate Limiting -Two-tier deque system in `rate_limiter.py`: -- Owners bypass queue entirely -- Authorized users β†’ `priority_queue` (drained first) -- Regular users β†’ `request_queue` -- `QueueFullError` raised on overflow +Two-tier deque system in `rate_limiter.py` (queue/wait-estimate UX is a +protected behavior β€” internals may change, the UX may not): + +- Owners bypass; authorized users β†’ `priority_queue` (drained first) +- Sliding window is charged at **execution** time (charge-at-exec, H6b) +- FloodWait inside a worker requeues the request with an attempt counter + (max 5) instead of stalling the pool +- Global RPS token-bucket breaker sheds bursts (burst = 2Γ— rate, H6c) +- Bookkeeping is bounded + swept every 5 min (H6a) + +## URL families + +- Canonical: `/f/<32-hex>/` (new uploads, L4) and `/watch/f/<32-hex>/` +- Legacy: `/watch/<6-char-hash>/` β€” still valid; controlled by + `ENABLE_LEGACY_LINKS` (default on; off β†’ 410). Legacy pages are cached. +- Both 20- and 32-hex canonical hashes validate side-by-side forever. ## Configuration -Copy `config_sample.env` β†’ `config.env`. Required vars: `API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `DATABASE_URL`. +Copy `config_sample.env` β†’ `config.env`. Required: `API_ID`, `API_HASH`, +`BOT_TOKEN`, `BIN_CHANNEL`, `OWNER_ID` (boot refuses without it), +`DATABASE_URL`. Optional local overrides go in `config.env.local`. +Every new env var ships with a safe default and an annotated +`config_sample.env` entry in the same PR. ## Debugging -- Logs: `Thunder/logs/bot.txt` -- Health check: admin `/status` command -- No linting/formatting tools configured β€” follow conventions manually -- No formal test suite β€” verify via bot interaction and link streaming \ No newline at end of file +- Logs: `Thunder/logs/bot.txt` (rotating 10 MiB Γ— 5; `/log` uploads are redacted) +- Liveness: `GET /health` (no dependencies touched); keepalive targets it +- Runtime status: `GET /status` (`Cache-Control: no-store`, includes DC id, + inflight counts, touch-buffer stats) and admin `/stats` (limiter occupancy) +- CI is the quality bar: ruff, mypy, pytest, pip-audit, bandit, vulture, + dependency-count β€” all must be green diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8371f93 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# Contributing + +## Setup + +```bash +python3.13 -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +pip install pytest pytest-asyncio pytest-cov ruff mypy bandit vulture +cp config_sample.env config.env # fill in your values +``` + +## Workflow + +1. Branch from `main` (`feat/...`, `fix/...`, `refactor/...`). +2. `make lint test` must pass locally; `quality.yml` enforces the same gates. +3. One concern per PR; behavior-preserving refactors carry characterization + tests committed beforehand. +4. New env vars: safe default + annotated entry in `config_sample.env` in the + same PR. +5. No new runtime dependency without a one-line justification; the + dependency-count CI gate fails beyond 8 direct deps. + +## Commands + +- `make format` β€” ruff autofix + format +- `make lint` β€” ruff + mypy (permissive) +- `make test` β€” unit tier +- `make audit` β€” pip-audit + bandit + vulture diff --git a/Dockerfile b/Dockerfile index edd9793..5a1fd30 100755 --- a/Dockerfile +++ b/Dockerfile @@ -11,13 +11,21 @@ RUN apt-get update && \ build-essential \ libssl-dev \ && apt-get clean && \ - rm -rf /var/lib/apt/lists/* + rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --shell /bin/bash thunder COPY requirements.txt . RUN pip install --upgrade pip && \ pip install --no-cache-dir -r requirements.txt -COPY . . +COPY --chown=thunder:thunder . . + +# L8: run as non-root +USER thunder + +# L8: container health follows /health (M3) +HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ + CMD python3 -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:'+os.getenv('PORT','8080')+'/health', timeout=5)" CMD ["bash", "thunder.sh"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f817148 --- /dev/null +++ b/Makefile @@ -0,0 +1,28 @@ +.PHONY: format lint test audit coverage run clean + +# L8: developer entry points (see CONTRIBUTING.md) + +format: + ruff check Thunder/ update.py --fix + ruff format Thunder/ update.py + +lint: + ruff check Thunder/ update.py + mypy Thunder --ignore-missing-imports || true + +test: + pytest -m unit + +coverage: + pytest -m unit --cov=Thunder --cov-report=html + +audit: + pip-audit -r requirements.txt + bandit -r Thunder -ll --skip B101 + vulture Thunder whitelist.py --min-confidence 80 + +run: + python3 -m Thunder + +clean: + rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..321f7ae --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Reporting a vulnerability + +Please open a private security advisory via GitHub's **Report a vulnerability** +button on the Security tab, or contact the owner directly. Do not open a +public issue for security reports. + +## Scope notes + +- `/shell` is disabled by default; enable only with `ENABLE_SHELL=True` on + trusted deployments (owner-only regardless). +- Session files are chmod 0600 after startup; keep them out of backups. +- `/log` uploads are regex-redacted (bot tokens, Mongo URIs) before upload. diff --git a/Thunder/template/dl.html b/Thunder/template/dl.html deleted file mode 100755 index 138cd56..0000000 --- a/Thunder/template/dl.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - Downloading: {{ file_name }} - - - - -
-
-

Your download for {{ file_name }} should start automatically.

-

If it doesn't, please click here to download.

-
-
- - \ No newline at end of file diff --git a/Thunder/utils/speedtest.py b/Thunder/utils/speedtest.py deleted file mode 100755 index bd578cc..0000000 --- a/Thunder/utils/speedtest.py +++ /dev/null @@ -1,43 +0,0 @@ -# Thunder/utils/speedtest.py - -import asyncio -from typing import Optional, Tuple, Dict, Any - -import speedtest -from Thunder.utils.logger import logger - - -async def run_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - try: - return await asyncio.to_thread(_perform_speedtest) - except Exception as e: - logger.error(f"Speedtest failed: {e}", exc_info=True) - return None, None - - -def _perform_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: - try: - st = speedtest.Speedtest(timeout=15, secure=True) - st.get_best_server() - st.download() - st.upload(pre_allocate=False) - - results = st.results.dict() - download_mbps = st.results.download / 1_000_000 - upload_mbps = st.results.upload / 1_000_000 - - results['download_mbps'] = download_mbps - results['upload_mbps'] = upload_mbps - results['download_bps'] = st.results.download / 8 - results['upload_bps'] = st.results.upload / 8 - - logger.debug(f"Download: {download_mbps:.2f} Mbps | Upload: {upload_mbps:.2f} Mbps") - - try: - return results, st.results.share() - except Exception: - return results, None - - except Exception as e: - logger.error(f"Speedtest failed: {e}") - return None, None diff --git a/config_sample.env b/config_sample.env index 973889b..034611e 100755 --- a/config_sample.env +++ b/config_sample.env @@ -1,5 +1,6 @@ # ============================================================= # Rename this file to config.env before using +# (a config.env.local overrides config.env for local-only tweaks) # ============================================================= #################### @@ -17,13 +18,15 @@ BOT_TOKEN="" # Example: "123456789:ABCdef..." BIN_CHANNEL=0 # Example: -1001234567890 # Owner information (get ID from @userinfobot) -OWNER_ID=0 # Your Telegram user ID. Example: 123456789 +# REQUIRED since the improvement plan: boot refuses to start without it, +# because owner checks silently matched nobody before. +OWNER_ID=0 # Example: 123456789 # Database connection string DATABASE_URL="" # Example: "mongodb+srv://user:pass@host/db" # Deployment configuration -FQDN="" # Your domain name +FQDN="" # Your domain name (warning logged when empty: links use the bind address) HAS_SSL="True" # Set to "True" if using HTTPS PORT=8080 # Web server port NO_PORT="True" # Hide port in URLs ("True" or "False") @@ -32,7 +35,7 @@ NO_PORT="True" # Hide port in URLs ("True" or "False") ## OPTIONAL SETTINGS #################### -MAX_BATCH_FILES=50 +MAX_BATCH_FILES=50 # 1-100 # Set bot commands on startup (True/False) SET_COMMANDS="True" @@ -50,10 +53,14 @@ BANNED_CHANNELS="" # Example: "-1001234567890 -100987654321" (Space-separated ID MULTI_TOKEN1="" #################### -## TOKEN SYSTEM SETTINGS +## ACCESS GATES #################### -# Enable token-based access (True/False) +# M12: allowlist mode. When True only OWNER_ID + authorized users can use +# the bot (default False = public). Boot log states the active gate mode. +PRIVATE_MODE="False" + +# Token system: require activation before use (True/False) TOKEN_ENABLED="False" # Default token validity in hours @@ -83,6 +90,10 @@ GLOBAL_RATE_LIMIT="False" # Maximum number of requests allowed across all users per minute MAX_GLOBAL_REQUESTS_PER_MINUTE=4 +# H6c: optional explicit global RPS for the token-bucket breaker. +# 0 = derive from MAX_GLOBAL_REQUESTS_PER_MINUTE (burst = 2x rate). +GLOBAL_RPS_LIMIT=0 + #################### ## RATE LIMITING SETTINGS #################### @@ -98,16 +109,71 @@ RATE_LIMIT_PERIOD_MINUTES=1 # Maximum number of requests that can be queued. MAX_QUEUE_SIZE=100 +# H6b: queue executor worker pool size (users processed concurrently) +EXECUTOR_WORKERS=5 + +# M4: worker pools +BROADCAST_WORKERS=4 +BATCH_WORKERS=5 + +#################### +## STREAM SERVER SETTINGS +#################### + +# M9: max concurrent streams per Telegram client; extra requests get +# 503 + Retry-After instead of queueing on an overloaded client. +MAX_CONCURRENT_STREAMS=8 + +#################### +## FILE LIFECYCLE +#################### + +# L2: expire file records after N days of inactivity (TTL index). +# 0 = keep forever (default; enabling requires a re-ingest-friendly setup). +FILE_TTL_DAYS=0 + +# M14: touch-buffer flush cadence + cap (bounded memory) +TOUCH_FLUSH_SECONDS=3 # 1-60 +TOUCH_BUFFER_MAX=1000 + +#################### +## SECURITY SWITCHES +#################### + +# L10: /shell command is DISABLED by default. Set True only on trusted +# deployments; it stays owner-only regardless. +ENABLE_SHELL="False" + +# L1: legacy /watch/<6-char-hash> URL family. Default True so all +# historical links keep working; switch to False to force the canonical +# /f// family only (legacy URLs answer 410). +ENABLE_LEGACY_LINKS="True" + #################### ## UPDATE SETTINGS #################### -# Git repository for updates +# Git repository for updates (H9: passed to `git pull --ff-only` as an +# argv element -- never through a shell; a failed update keeps the +# current code running) UPSTREAM_REPO="https://github.com/fyaz05/FileToLink" # Branch to update from UPSTREAM_BRANCH="main" # Default branch for updates +#################### +## OBSERVABILITY +#################### + +# H10: log level (DEBUG/INFO/WARNING/ERROR) and format (plain/json). +# /log uploads are redacted (bot tokens, Mongo URIs) before leaving. +LOG_LEVEL="INFO" +LOG_FORMAT="plain" + +# H8: default wall-clock budget (seconds) for lightweight Telegram RPCs. +# File transfers (copy/upload/stream) are unbounded by default. +TG_RPC_TIMEOUT_SECONDS=30 + #################### ## ADVANCED SETTINGS (modify with caution) #################### @@ -121,5 +187,4 @@ WORKERS=8 # Number of worker processes # Web server configuration BIND_ADDRESS="0.0.0.0" # Listen on all network interfaces -PING_INTERVAL=840 # Ping interval in seconds - +PING_INTERVAL=840 # Ping interval in seconds (health check based) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..663503a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,73 @@ +[project] +name = "thunder-filetolink" +version = "2.2.0" +description = "FileToLink β€” Telegram file-to-link streaming bot (pyrofork + aiohttp + MongoDB)" +requires-python = ">=3.13" +dependencies = [ + "aiohttp==3.14.3", # OSV-clean; 3 Jan-2026 CVEs fixed (H1) + "pyrofork==2.3.69", + "tgcrypto-pyrofork==1.2.8", # H1: maintained crypto backend (pyrofork[speedup]) + "pymongo==4.18.0", # async AsyncMongoClient; >= 4.9 for timeoutMS + "Jinja2==3.1.6", + "python-dotenv==1.2.3", + "psutil==7.2.2", + "uvloop==0.22.1", # py3.13 support (was pinned 0.21.0) +] + +[project.optional-dependencies] +# H5b/R8: escape hatch for Cloudflare-protected shortener providers. +# Never installed by default; shortener falls back to plain aiohttp. +shortener-cf = ["curl_cffi>=0.7"] + +[dependency-groups] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "pytest-cov>=5.0", + "ruff>=0.8", + "mypy>=1.13", + "bandit>=1.8", + "vulture>=2.14", +] + +[tool.ruff] +line-length = 100 +target-version = "py313" + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "SIM"] +ignore = [ + "E501", # long lines: message templates + "SIM105", # try/except/pass is idiomatic around best-effort teardown here + "UP047", # keep TypeVar generics (readable for this codebase) +] + +[tool.ruff.lint.per-file-ignores] +"tests/*" = ["B011", "SIM117"] + +[tool.mypy] +python_version = "3.13" +ignore_missing_imports = true +check_untyped_defs = false +warn_unused_ignores = false +exclude = ["tests/"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] +markers = [ + "unit: fast, hermetic tests (no network, no Mongo)", + "integration: testcontainers-backed tests (opt-in, see tests/integration)", +] +addopts = "-m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35" + +[tool.coverage.run] +source = ["Thunder"] +omit = ["Thunder/bot/plugins/*", "Thunder/__main__.py"] + +[tool.vulture] +min_confidence = 80 +paths = ["Thunder", "whitelist.py"] + +[tool.bandit] +exclude_dirs = ["tests", ".venv"] diff --git a/requirements.txt b/requirements.txt index b4b19f8..c9591cd 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -aiohttp -cloudscraper -Jinja2 -pyrofork -pymongo -psutil -python-dotenv -speedtest-cli -tgcrypto -uvloop==0.21.0 +# Generated export of pyproject [project.dependencies] (H1). +# The Dockerfile consumes this file; versions are exact pins. +aiohttp==3.14.3 +pyrofork==2.3.69 +tgcrypto-pyrofork==1.2.8 +pymongo==4.18.0 +Jinja2==3.1.6 +python-dotenv==1.2.3 +psutil==7.2.2 +uvloop==0.22.1 diff --git a/whitelist.py b/whitelist.py new file mode 100644 index 0000000..8421df6 --- /dev/null +++ b/whitelist.py @@ -0,0 +1,16 @@ +# Dead-code whitelist for the Vulture CI gate (H4b: make leanness permanent). +# Names referenced dynamically (pyrogram handlers, Jinja templates, etc.). +reply_safe +send_safe +edit_safe +delete_safe +answer_safe +tg_call +build_help_text +help_command_rows +bot_commands +touch_buffer_stats +occupancy +run_sweeper +shortener-cf +cls From 245dec65cf9d47455a6ff7b282b3a2882c96bc28 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 10:24:37 +0000 Subject: [PATCH 03/49] fix(H9): shell-free, non-destructive self-update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot path interpolated UPSTREAM_REPO/UPSTREAM_BRANCH into a shell=True command chain and ran 'rm -rf .git', 'git reset --hard' and mutated global git config β€” a live injection vector at every container boot, and a failed update could leave a half-wiped tree. - argv-list subprocess (shell=False); remote URL stays an argv element - 'git pull --ff-only' replaces the init/commit/reset dance - strictly best-effort: failures log and keep the current code running - no-ops cleanly when git is absent or the tree is not a repo - thunder.sh reduced to boot orchestration (update failure no longer blocks boot); /restart marker flow unchanged --- thunder.sh | 8 +++- update.py | 113 ++++++++++++++++++++++++++++++++++++++--------------- 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/thunder.sh b/thunder.sh index 8e84da0..2955071 100755 --- a/thunder.sh +++ b/thunder.sh @@ -1 +1,7 @@ -python3 update.py && python3 -m Thunder \ No newline at end of file +#!/usr/bin/env bash +# Boot orchestration only (H9): update is best-effort and shell-free; +# a failing update never blocks the bot from starting. +set -u + +python3 update.py || true +exec python3 -m Thunder diff --git a/update.py b/update.py index 0e34108..c4983c8 100755 --- a/update.py +++ b/update.py @@ -1,41 +1,92 @@ -from os import path as opath, getenv, rename -from subprocess import run as srun +"""Boot-time best-effort self-update (plan H9). + +Replaces the historical ``shell=True`` command chain that interpolated +``UPSTREAM_REPO``/``UPSTREAM_BRANCH`` env vars into a shell string executed +at every container boot (live injection vector), and that ran +``rm -rf .git``, ``git reset --hard`` and mutated **global** git config -- +a failed update could leave a half-wiped tree that no longer boots. + +New behaviour: + +* argv-list subprocess, ``shell=False``; the remote URL from the + environment is passed as an argv element, never through a shell; +* non-destructive: no ``rm -rf .git``, no ``reset --hard``, no global + config mutation -- ``pull --ff-only`` keeps local history intact; +* strictly best-effort: any failure logs and leaves the existing tree + running the old code; +* no-ops cleanly when ``git`` is missing or the tree is not a git repo + (some PaaS images). +""" + +import os +import shutil +import subprocess + from dotenv import load_dotenv + from Thunder.utils.logger import logger -load_dotenv('config.env', override=True) +load_dotenv("config.env", override=True) + +UPSTREAM_REPO = os.getenv("UPSTREAM_REPO", "") +UPSTREAM_BRANCH = os.getenv("UPSTREAM_BRANCH", "main") + +# config.env lives beside the app and must survive the pull +_CONFIG_BACKUP = "../config.env.tmp" -UPSTREAM_REPO = getenv('UPSTREAM_REPO', "") -UPSTREAM_BRANCH = getenv('UPSTREAM_BRANCH', "main") -if UPSTREAM_REPO: - config_backup = '../config.env.tmp' - +def _backup_config() -> bool: try: - if opath.exists('config.env'): - rename('config.env', config_backup) - - if opath.exists('.git'): - srun(["rm", "-rf", ".git"]) - - git_commands = ( - f"git init -q && " - f"git config --global user.email thunder@update.local && " - f"git config --global user.name Thunder && " - f"git add . && " - f"git commit -sm update -q && " - f"git remote add origin {UPSTREAM_REPO} && " - f"git fetch origin -q && " - f"git reset --hard origin/{UPSTREAM_BRANCH} -q" + if os.path.exists("config.env"): + os.replace("config.env", _CONFIG_BACKUP) + return True + except OSError as e: + logger.warning(f"Could not back up config.env: {e}") + return False + + +def _restore_config(backed_up: bool) -> None: + if backed_up and os.path.exists(_CONFIG_BACKUP): + try: + os.replace(_CONFIG_BACKUP, "config.env") + except OSError as e: + logger.error(f"Could not restore config.env: {e}") + + +def main() -> None: + if not UPSTREAM_REPO: + return + if shutil.which("git") is None: + logger.info("git not available; skipping self-update (image without git).") + return + if not os.path.isdir(".git"): + logger.info("Not a git repository; skipping self-update.") + + backed_up = _backup_config() + try: + # git >= 2.27 warns without the refspec; be explicit. + result = subprocess.run( + ["git", "-C", os.getcwd(), "pull", "--ff-only", UPSTREAM_REPO, UPSTREAM_BRANCH], + shell=False, + capture_output=True, + text=True, + timeout=120, ) - - result = srun(git_commands, shell=True) - if result.returncode == 0: - logger.info('Successfully updated with latest commit from UPSTREAM_REPO') + logger.info("Self-update pulled latest commit from UPSTREAM_REPO.") else: - logger.error('Something went wrong while updating, check UPSTREAM_REPO if valid or not!') - + # keep running the old code; never hard-fail the boot + logger.error( + "Self-update failed (non-destructive, keeping current code): " + f"{(result.stderr or result.stdout or '').strip()[:500]}" + ) + except subprocess.TimeoutExpired: + logger.error("Self-update timed out; keeping current code.") + except Exception as e: + logger.error(f"Self-update failed: {e}; keeping current code.") finally: - if opath.exists(config_backup): - rename(config_backup, 'config.env') + _restore_config(backed_up) + + +if __name__ == "__main__": + main() From 094ae686221d4c098a08560429c2825d5b36474f Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 10:25:13 +0000 Subject: [PATCH 04/49] feat(P2-P5): implement the FileToLink improvement plan (H2-H10, M1-M14, L1-L7, L9-L10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 β€” core hygiene (H4, H5, H10): - utils/safe_call.py: tg_call helper + reply/send/edit/delete/answer_safe wrappers; all ~90 inline try/except-FloodWait pairs migrated (budget: custom_dl streaming loop + canonical ingest loop only, as planned) - dead code purged: dl.html + its render branch, tokens.list_tokens/ get_user, bot_utils.notify_ch, rate_limiter.get_queue_status, file_properties.get_fids, canonical_files.touch_file_record + get_file_by_message_id (+ db twin), on_fallback_message stream path, Var.MULTI_CLIENT, MSG_STATS_ERROR, dead client_max_size - three drifted media maps unified into utils/media_types.py (H4c) - speedtest-cli removed (archived upstream, H5a); shortener rebuilt on aiohttp, cloudscraper dropped (H5b; curl_cffi optional extra) - H10: LOG_LEVEL/LOG_FORMAT, shared redact_secrets(), access-log middleware with sha256-hashed file tokens, /log uploads redacted + capped at 45 MiB P3 β€” runtime correctness (H6, H7, H8): - rate limiter: worker pool (EXECUTOR_WORKERS), charge-at-exec (the old code double-charged: enqueue + executor), requeue-on-FloodWait with attempt counter (long FloodWaits no longer stall every user), global RPS token-bucket breaker (burst 2x), bounded structures + 5-min sweep, limiter occupancy in /stats - flag_cache.py: TTL+LRU cache in front of ban/authorization checks, FAIL-CLOSED on DB errors (a Mongo outage no longer un-bans everyone), invalidated by admin mutators; OWNER_ID missing now refuses boot - H8: Mongo timeoutMS=5000, tg_call RPC budgets (transfers unbounded), shortener/keepalive 10 s, last sync psutil call moved to a thread P4 β€” robustness & UX parity (M1-M14): - M6: vars.py validates ALL config problems together with named vars, config.env.local layering, bounds enforcement - M1: bot/registry.py single command registry -> menu (owner-only hidden, 256-char truncation), /help section, AGENTS.md drift test - M3: /health endpoint (zero deps); keepalive repointed off the GitHub-redirecting root onto /health with status checking - M2: typed player page (video/audio/image/non-media cards) from mime type, CWD-independent Jinja loader, noindex/nofollow, noscript hint - M7: user-facing surfaces are HTML with html.escape()d file names, titles and ban reasons; markdown escaper retired - M8: atomic token activation (find_one_and_update conditioned on activated != True β€” exactly one concurrent /start wins) + /activate/{token} web route for shorteners - M4a: broadcast on a 4-worker queue pool with Mongo cursor streaming, 200 ms pacing, progress every 25 sends, cancel preserved - M4b: batch on a 5-worker pool, order-preserving results, skipped vs failed counters, 30+2n deadline, MAX_BATCH_FILES enforced - M5: shortener cache + singleflight, Bearer header auth, host validation, https-only, no redirects - M9: MAX_CONCURRENT_STREAMS env admission cap (503 + Retry-After kept) - M10: self-healing vault records β€” unresolved vault message => record deleted + 404 => next upload re-ingests; Content-Length verified against the actual vault media (stuck-at-99% fix) - M11: callback panic-isolation guard with error-ID owner notification; close_panel permission check; catch-all answered (was already present) - M12: unified preflight chain (banned -> private-mode -> token -> force-sub -> shortener) + PRIVATE_MODE allowlist; /start keeps the activation flow reachable; utils->bot inversions broken via client params (lazy fallback keeps compatibility) - M13: bounded shutdown drain (30 s, work_loads-based) + aggregated teardown errors, non-zero exit on failure; restart-marker preserved - M14: touch buffer capped with drop-on-overflow counter, env-tunable 1-60 s flush, single BulkWrite per cycle P5 β€” polish (L1-L7, L10): - L1: ENABLE_LEGACY_LINKS flag (default on; off => 410) + legacy page cache (repeat /watch views no longer hit Telegram) - L2: optional FILE_TTL_DAYS TTL index with backfill; expiry note in link messages only when enabled - L3: /status gains DC id, inflight, touch-buffer stats, no-store - L4: 32-hex public hash for new uploads; 20-hex links stay valid - L5: session files chmod 0600 after startup - L6: RFC 5987 filename* with ASCII fallback; 416 + Content-Range kept - L7: preconnect hints, inline fallback stylesheet + noscript so the player page stays usable with CDNs blocked - L10: /shell behind ENABLE_SHELL kill-switch (default off) + command log --- Thunder/__main__.py | 251 ++++++----- Thunder/bot/__init__.py | 1 + Thunder/bot/clients.py | 40 +- Thunder/bot/plugins/admin.py | 489 ++++++++++---------- Thunder/bot/plugins/callbacks.py | 331 +++++++------- Thunder/bot/plugins/common.py | 295 ++++++------ Thunder/bot/plugins/stream.py | 748 ++++++++++++++++--------------- Thunder/bot/registry.py | 69 +++ Thunder/logs/bot.txt | 56 +++ Thunder/server/__init__.py | 60 ++- Thunder/server/exceptions.py | 2 + Thunder/server/stream_routes.py | 265 +++++++---- Thunder/template/req.html | 72 ++- Thunder/utils/bot_utils.py | 187 ++++---- Thunder/utils/broadcast.py | 235 ++++++---- Thunder/utils/canonical_files.py | 256 ++++++----- Thunder/utils/commands.py | 41 +- Thunder/utils/config_parser.py | 24 +- Thunder/utils/custom_dl.py | 100 ++--- Thunder/utils/database.py | 258 ++++++----- Thunder/utils/decorators.py | 285 ++++++++---- Thunder/utils/file_properties.py | 75 +--- Thunder/utils/flag_cache.py | 108 +++++ Thunder/utils/force_channel.py | 34 +- Thunder/utils/human_readable.py | 3 +- Thunder/utils/keepalive.py | 39 +- Thunder/utils/logger.py | 94 +++- Thunder/utils/media_types.py | 85 ++++ Thunder/utils/messages.py | 241 +++++----- Thunder/utils/rate_limiter.py | 510 +++++++++++++++------ Thunder/utils/render_template.py | 135 ++++-- Thunder/utils/safe_call.py | 139 ++++++ Thunder/utils/shortener.py | 220 ++++++--- Thunder/utils/time_format.py | 5 +- Thunder/utils/tokens.py | 163 ++++--- Thunder/vars.py | 41 +- 36 files changed, 3668 insertions(+), 2289 deletions(-) create mode 100644 Thunder/bot/registry.py create mode 100644 Thunder/logs/bot.txt create mode 100644 Thunder/utils/flag_cache.py create mode 100644 Thunder/utils/media_types.py create mode 100644 Thunder/utils/safe_call.py diff --git a/Thunder/__main__.py b/Thunder/__main__.py index a65e6aa..66d60e4 100755 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -5,36 +5,41 @@ import importlib.util import sys from datetime import datetime - from pathlib import Path -if sys.platform == 'win32': +if sys.platform == "win32": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) try: from uvloop import install + install() except ImportError: pass from aiohttp import web from pyrogram import idle -from pyrogram.errors import FloodWait, MessageNotModified +from pyrogram.errors import MessageNotModified from Thunder import __version__ -from Thunder.bot import StreamBot -from Thunder.bot.clients import cleanup_clients, initialize_clients +from Thunder.bot import StreamBot, work_loads +from Thunder.bot.clients import ( + _harden_session_files, + cleanup_clients, + initialize_clients, +) from Thunder.server import web_server +from Thunder.utils.canonical_files import drain_background_touch_tasks from Thunder.utils.commands import set_commands from Thunder.utils.database import db +from Thunder.utils.flag_cache import flags from Thunder.utils.keepalive import ping_server -from Thunder.utils.canonical_files import drain_background_touch_tasks from Thunder.utils.logger import logger from Thunder.utils.messages import MSG_ADMIN_RESTART_DONE -from Thunder.utils.rate_limiter import rate_limiter, request_executor +from Thunder.utils.rate_limiter import rate_limiter, start_executors +from Thunder.utils.safe_call import tg_call from Thunder.utils.tokens import cleanup_expired_tokens from Thunder.vars import Var - PLUGIN_PATH = "Thunder/bot/plugins/*.py" VERSION = __version__ @@ -44,7 +49,7 @@ def print_banner(): ╔═══════════════════════════════════════════════════════════════════╗ β•‘ β•‘ β•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β•‘ -β•‘ β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— β•‘ +β•‘ β•šβ•β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— β•‘ β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β–ˆβ–ˆβ•— β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β•‘ β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β• β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•— β•‘ β•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘ β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘ β–ˆβ–ˆβ•‘ β•‘ @@ -58,8 +63,7 @@ def print_banner(): def schedule_index_ensure() -> None: task = asyncio.create_task( - db.ensure_indexes(raise_on_error=False), - name="ensure_database_indexes" + db.ensure_indexes(raise_on_error=False), name="ensure_database_indexes" ) def _log_index_failure(done_task: asyncio.Task) -> None: @@ -91,9 +95,7 @@ async def import_plugins(): plugin_name = plugin_path.stem import_path = f"Thunder.bot.plugins.{plugin_name}" - spec = importlib.util.spec_from_file_location( - import_path, plugin_path - ) + spec = importlib.util.spec_from_file_location(import_path, plugin_path) if spec is None or spec.loader is None: logger.error(f"Invalid plugin specification for {plugin_name}") failed_plugins.append(plugin_name) @@ -109,10 +111,7 @@ async def import_plugins(): logger.error(f" βœ– Failed to import plugin {plugin_name}: {e}") failed_plugins.append(plugin_name) - print( - f" β–Ά Total: {len(plugins)} | Success: {success_count} | " - f"Failed: {len(failed_plugins)}" - ) + print(f" β–Ά Total: {len(plugins)} | Success: {success_count} | Failed: {len(failed_plugins)}") if failed_plugins: print(f" β–Ά Failed plugins: {', '.join(failed_plugins)}") @@ -126,60 +125,34 @@ async def start_services(): print(" β–Ά Starting Telegram Bot initialization...") try: - try: - await StreamBot.start() - except FloodWait as e: - logger.debug(f"FloodWait in bot start, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await StreamBot.start() - - try: - bot_info = await StreamBot.get_me() - except FloodWait as e: - logger.debug(f"FloodWait in get_me, sleeping for {e.value}s") - await asyncio.sleep(e.value) - bot_info = await StreamBot.get_me() - + await tg_call(StreamBot.start) + bot_info = await tg_call(StreamBot.get_me) StreamBot.username = bot_info.username print(f" βœ“ Bot initialized successfully as @{StreamBot.username}") await set_commands() print(" βœ“ Bot commands set successfully.") schedule_index_ensure() + _harden_session_files() restart_message_data = await db.get_restart_message() if restart_message_data: try: - try: - await StreamBot.edit_message_text( - chat_id=restart_message_data["chat_id"], - message_id=restart_message_data["message_id"], - text=MSG_ADMIN_RESTART_DONE, - ) - except FloodWait as e: - logger.debug(f"FloodWait in restart message edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await StreamBot.edit_message_text( - chat_id=restart_message_data["chat_id"], - message_id=restart_message_data["message_id"], - text=MSG_ADMIN_RESTART_DONE, - ) - except MessageNotModified: - pass - await db.delete_restart_message( - restart_message_data["message_id"] + await tg_call( + StreamBot.edit_message_text, + chat_id=restart_message_data["chat_id"], + message_id=restart_message_data["message_id"], + text=MSG_ADMIN_RESTART_DONE, + retries=1, ) + await db.delete_restart_message(restart_message_data["message_id"]) + except MessageNotModified: + pass except Exception as e: - logger.error( - f"Error processing restart message: {e}", exc_info=True - ) - else: - pass + logger.error(f"Error processing restart message: {e}", exc_info=True) except Exception as e: - logger.error( - f" βœ– Failed to initialize Telegram Bot: {e}", exc_info=True - ) + logger.error(f" βœ– Failed to initialize Telegram Bot: {e}", exc_info=True) return print(" β–Ά Starting Client initialization...") @@ -193,14 +166,11 @@ async def start_services(): print(" β–Ά Starting Request Executor initialization...") try: - request_executor_task = asyncio.create_task( - request_executor(), name="request_executor_task" - ) - print(" βœ“ Request executor service started") + # H6b: small worker pool instead of a single serial executor + executor_tasks = start_executors() + print(f" βœ“ Request executor pool started ({len(executor_tasks)} workers)") except Exception as e: - logger.error( - f" βœ– Failed to start request executor: {e}", exc_info=True - ) + logger.error(f" βœ– Failed to start request executor: {e}", exc_info=True) return print(" β–Ά Starting Web Server initialization...") @@ -211,42 +181,25 @@ async def start_services(): site = web.TCPSite(app_runner, bind_address, Var.PORT) await site.start() - keepalive_task = asyncio.create_task( - ping_server(), name="keepalive_task" - ) + keepalive_task = asyncio.create_task(ping_server(), name="keepalive_task") print(" βœ“ Keep-alive service started") token_cleanup_task = asyncio.create_task( schedule_token_cleanup(), name="token_cleanup_task" ) + # H6a: bounded bookkeeping -- periodic sweepers + limiter_sweeper_task = asyncio.create_task( + schedule_limiter_sweep(), name="limiter_sweeper_task" + ) + flag_sweeper_task = asyncio.create_task(flags.run_sweeper(), name="flag_cache_sweeper_task") except Exception as e: logger.error(f" βœ– Failed to start Web Server: {e}", exc_info=True) - if 'request_executor_task' in locals() and not request_executor_task.done(): - request_executor_task.cancel() - try: - await request_executor_task - except asyncio.CancelledError: - pass - try: - await StreamBot.stop() - except Exception: - pass - try: - await cleanup_clients() - except Exception: - pass - try: - await rate_limiter.shutdown() - except Exception: - pass - try: - await db.close() - except Exception as e: - logger.error(f"Error during database cleanup: {e}", exc_info=True) - try: - await drain_background_touch_tasks() - except Exception as e: - logger.error(f"Error during canonical touch task cleanup: {e}", exc_info=True) + for task in locals().get("executor_tasks", []): + task.cancel() + await _safe_teardown_step(rate_limiter.shutdown, "rate limiter") + await _safe_teardown_step(cleanup_clients, "clients") + await _safe_teardown_step(db.close, "database") + await _safe_teardown_step(drain_background_touch_tasks, "touch buffer") return elapsed_time = (datetime.now() - start_time).total_seconds() @@ -259,50 +212,85 @@ async def start_services(): print(" β–Ά Bot is now running! Press CTRL+C to stop.") background_tasks = [ - request_executor_task, + *executor_tasks, keepalive_task, - token_cleanup_task + token_cleanup_task, + limiter_sweeper_task, + flag_sweeper_task, ] try: await idle() finally: - print(" β–Ά Shutting down services...") + # M13: ordered teardown with a bounded drain + error aggregation + await shutdown_services(background_tasks, app_runner) - for task in background_tasks: - if not task.done(): - task.cancel() - try: - await task - except asyncio.CancelledError: - pass - try: - await rate_limiter.shutdown() - except Exception as e: - logger.error(f"Error during rate limiter cleanup: {e}") +async def _safe_teardown_step(step, name: str, errors: list | None = None): + try: + await asyncio.wait_for(step(), timeout=30) + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"Error during {name} cleanup: {e}", exc_info=True) + if errors is not None: + errors.append((name, e)) + +async def shutdown_services(background_tasks, app_runner) -> None: + """M13: restart-marker-safe, bounded drain, aggregated errors.""" + from Thunder.utils.canonical_files import touch_buffer_stats + + print(" β–Ά Shutting down services...") + errors: list = [] + + # 1. stop accepting new work + for task in background_tasks: + if not task.done(): + task.cancel() + + for task in background_tasks: try: - await cleanup_clients() + await asyncio.wait_for(task, timeout=10) + except asyncio.CancelledError: + pass except Exception as e: - logger.error(f"Error during client cleanup: {e}") - + errors.append((task.get_name(), e)) + logger.error(f"Background task {task.get_name()} failed at shutdown: {e}") + + # 2. bounded drain: wait (<= 30 s) for in-flight streams to finish + drain_deadline = asyncio.get_event_loop().time() + 30 + while sum(work_loads.values()) > 0 and asyncio.get_event_loop().time() < drain_deadline: + await asyncio.sleep(0.25) + remaining = sum(work_loads.values()) + if remaining: + logger.warning(f"Drain deadline hit with {remaining} stream(s) still active.") + + # 3. ordered teardown + await _safe_teardown_step(rate_limiter.shutdown, "rate limiter", errors) + await _safe_teardown_step(drain_background_touch_tasks, "touch buffer", errors) + await _safe_teardown_step(cleanup_clients, "clients", errors) + + if app_runner is not None: try: - await drain_background_touch_tasks() + await asyncio.wait_for(app_runner.cleanup(), timeout=30) except Exception as e: - logger.error(f"Error during canonical touch task cleanup: {e}", exc_info=True) + errors.append(("web server", e)) + logger.error(f"Error during web server cleanup: {e}") - if 'app_runner' in locals() and app_runner is not None: - try: - await app_runner.cleanup() - except Exception as e: - logger.error(f"Error during web server cleanup: {e}") + await _safe_teardown_step(db.close, "database", errors) + if not errors: + print(" βœ“ Database connection closed") - try: - await db.close() - print(" βœ“ Database connection closed") - except Exception as e: - logger.error("Error during database cleanup", exc_info=True) + logger.info(f"Touch buffer final state: {touch_buffer_stats()}") + + # M13: aggregate, log everything, exit non-zero when anything failed + if errors: + logger.error( + f"Shutdown completed with {len(errors)} error(s): " + + ", ".join(name for name, _ in errors) + ) + sys.exit(1) async def schedule_token_cleanup(): @@ -316,7 +304,22 @@ async def schedule_token_cleanup(): except Exception as e: logger.error(f"Token cleanup error: {e}", exc_info=True) -if __name__ == '__main__': + +async def schedule_limiter_sweep(): + """H6a: prune limiter bookkeeping every 5 minutes.""" + while True: + try: + await asyncio.sleep(300) + stats = await rate_limiter.sweep() + logger.debug(f"Limiter sweep: {stats}") + except asyncio.CancelledError: + logger.debug("schedule_limiter_sweep cancelled cleanly.") + break + except Exception as e: + logger.error(f"Limiter sweep error: {e}", exc_info=True) + + +if __name__ == "__main__": try: loop = asyncio.get_event_loop() loop.run_until_complete(start_services()) diff --git a/Thunder/bot/__init__.py b/Thunder/bot/__init__.py index 1e28c42..c45c4d7 100755 --- a/Thunder/bot/__init__.py +++ b/Thunder/bot/__init__.py @@ -1,6 +1,7 @@ # Thunder/bot/__init__.py from pyrogram import Client + from Thunder.vars import Var StreamBot = Client( diff --git a/Thunder/bot/clients.py b/Thunder/bot/clients.py index ac811a7..a6951f7 100755 --- a/Thunder/bot/clients.py +++ b/Thunder/bot/clients.py @@ -1,6 +1,8 @@ # Thunder/bot/clients.py import asyncio +import glob +import os from pyrogram import Client from pyrogram.errors import FloodWait @@ -8,19 +10,32 @@ from Thunder.bot import StreamBot, multi_clients, work_loads from Thunder.utils.config_parser import TokenParser from Thunder.utils.logger import logger +from Thunder.utils.safe_call import tg_call from Thunder.vars import Var + +def _harden_session_files() -> None: + """L5: session files contain bearer-equivalent credentials -> 0600. + + Best-effort: pyrogram (re)creates session files lazily, so this runs + after startup and tolerates absent files. + """ + for path in glob.glob("*.session"): + try: + os.chmod(path, 0o600) + logger.debug(f"Hardened session file permissions: {path}") + except OSError as e: + logger.warning(f"Could not chmod {path}: {e}") + + async def cleanup_clients(): for client in multi_clients.values(): try: - try: - await client.stop() - except FloodWait as e: - await asyncio.sleep(e.value) - await client.stop() + await tg_call(client.stop) except Exception as e: logger.error(f"Error stopping client: {e}", exc_info=True) + async def initialize_clients(): print("╠══════════════════ INITIALIZING CLIENTS ═══════════════════╣") multi_clients[0] = StreamBot @@ -48,7 +63,7 @@ async def start_client(client_id, token): name=str(client_id), no_updates=True, max_concurrent_transmissions=1000, - sleep_threshold=Var.SLEEP_THRESHOLD + sleep_threshold=Var.SLEEP_THRESHOLD, ) try: await client.start() @@ -62,20 +77,23 @@ async def start_client(client_id, token): logger.error(f" βœ– Failed to start Client ID {client_id}. Error: {e}", exc_info=True) return None - clients = await asyncio.gather(*[start_client(i, token) for i, token in all_tokens.items() if token]) + clients = await asyncio.gather( + *[start_client(i, token) for i, token in all_tokens.items() if token] + ) clients = [client for client in clients if client] multi_clients.update(dict(clients)) - + + _harden_session_files() + if len(multi_clients) > 1: - Var.MULTI_CLIENT = True print("╠══════════════════════ MULTI-CLIENT ═══════════════════════╣") print(f" β—Ž Total Clients: {len(multi_clients)} (Including primary client)") - + print(" β–Ά Initial workload distribution:") for client_id, load in work_loads.items(): print(f" β€’ Client {client_id}: {load} tasks") - + else: print("╠═══════════════════════════════════════════════════════════╣") print(" β–Ά No additional clients were initialized") diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py index e552e6e..d12b951 100755 --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -4,7 +4,6 @@ import html import os import shutil -import sys import time from io import BytesIO @@ -12,7 +11,7 @@ from pyrogram import filters from pyrogram.client import Client from pyrogram.enums import ParseMode -from pyrogram.errors import FloodWait, MessageNotModified +from pyrogram.errors import MessageNotModified from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from Thunder import StartTime, __version__ @@ -20,58 +19,92 @@ from Thunder.utils.bot_utils import get_user, reply from Thunder.utils.broadcast import broadcast_message from Thunder.utils.database import db +from Thunder.utils.flag_cache import flags from Thunder.utils.human_readable import humanbytes -from Thunder.utils.logger import LOG_FILE, logger +from Thunder.utils.logger import LOG_FILE, logger, redact_secrets from Thunder.utils.messages import ( - MSG_ADMIN_AUTH_LIST_HEADER, MSG_ADMIN_NO_BAN_REASON, - MSG_ADMIN_USER_BANNED, MSG_ADMIN_USER_UNBANNED, MSG_AUTHORIZE_FAILED, - MSG_AUTHORIZE_SUCCESS, MSG_AUTHORIZE_USAGE, MSG_AUTH_USER_INFO, - MSG_BAN_REASON_SUFFIX, MSG_BAN_USAGE, MSG_BROADCAST_USAGE, - MSG_BUTTON_CLOSE, MSG_CANNOT_BAN_OWNER, MSG_CHANNEL_BANNED, - MSG_CHANNEL_BANNED_REASON_SUFFIX, MSG_CHANNEL_NOT_BANNED, - MSG_CHANNEL_UNBANNED, MSG_DB_ERROR, MSG_DB_STATS, - MSG_DEAUTHORIZE_FAILED, MSG_DEAUTHORIZE_SUCCESS, - MSG_DEAUTHORIZE_USAGE, MSG_ERROR_GENERIC, MSG_INVALID_BROADCAST_CMD, - MSG_INVALID_USER_ID, MSG_LOG_FILE_CAPTION, MSG_LOG_FILE_EMPTY, - MSG_LOG_FILE_MISSING, MSG_NO_AUTH_USERS, MSG_RESTARTING, MSG_SHELL_ERROR, - MSG_SHELL_EXECUTING, MSG_SHELL_NO_OUTPUT, MSG_SHELL_OUTPUT, - MSG_SHELL_OUTPUT_STDERR, MSG_SHELL_OUTPUT_STDOUT, MSG_SHELL_USAGE, - MSG_SPEEDTEST_ERROR, MSG_SPEEDTEST_INIT, MSG_SPEEDTEST_RESULT, - MSG_STATUS_ERROR, MSG_SYSTEM_STATS, MSG_SYSTEM_STATUS, - MSG_UNBAN_USAGE, MSG_USER_BANNED_NOTIFICATION, - MSG_USER_NOT_IN_BAN_LIST, MSG_USER_UNBANNED_NOTIFICATION, - MSG_WORKLOAD_ITEM + MSG_ADMIN_AUTH_LIST_HEADER, + MSG_ADMIN_NO_BAN_REASON, + MSG_ADMIN_USER_BANNED, + MSG_ADMIN_USER_UNBANNED, + MSG_AUTH_USER_INFO, + MSG_AUTHORIZE_FAILED, + MSG_AUTHORIZE_SUCCESS, + MSG_AUTHORIZE_USAGE, + MSG_BAN_REASON_SUFFIX, + MSG_BAN_USAGE, + MSG_BROADCAST_USAGE, + MSG_BUTTON_CLOSE, + MSG_CANNOT_BAN_OWNER, + MSG_CHANNEL_BANNED, + MSG_CHANNEL_BANNED_REASON_SUFFIX, + MSG_CHANNEL_NOT_BANNED, + MSG_CHANNEL_UNBANNED, + MSG_DB_ERROR, + MSG_DB_STATS, + MSG_DEAUTHORIZE_FAILED, + MSG_DEAUTHORIZE_SUCCESS, + MSG_DEAUTHORIZE_USAGE, + MSG_ERROR_GENERIC, + MSG_INVALID_BROADCAST_CMD, + MSG_INVALID_USER_ID, + MSG_LOG_FILE_CAPTION, + MSG_LOG_FILE_EMPTY, + MSG_LOG_FILE_MISSING, + MSG_NO_AUTH_USERS, + MSG_RESTARTING, + MSG_SHELL_DISABLED, + MSG_SHELL_ERROR, + MSG_SHELL_EXECUTING, + MSG_SHELL_NO_OUTPUT, + MSG_SHELL_OUTPUT, + MSG_SHELL_OUTPUT_STDERR, + MSG_SHELL_OUTPUT_STDOUT, + MSG_SHELL_USAGE, + MSG_STATUS_ERROR, + MSG_SYSTEM_STATS, + MSG_SYSTEM_STATUS, + MSG_UNBAN_USAGE, + MSG_USER_BANNED_NOTIFICATION, + MSG_USER_NOT_IN_BAN_LIST, + MSG_USER_UNBANNED_NOTIFICATION, + MSG_WORKLOAD_ITEM, +) +from Thunder.utils.rate_limiter import rate_limiter +from Thunder.utils.safe_call import ( + delete_safe, + edit_safe, + send_safe, + tg_call, ) from Thunder.utils.time_format import get_readable_time from Thunder.utils.tokens import authorize, deauthorize, list_allowed -from Thunder.utils.speedtest import run_speedtest from Thunder.vars import Var owner_filter = filters.private & filters.user(Var.OWNER_ID) -_MARKDOWN_ESCAPE_TRANS = str.maketrans({ - "\\": "\\\\", - "_": "\\_", - "*": "\\*", - "[": "\\[", - "]": "\\]", - "`": "\\`", -}) +# H10: /log tail cap (mirrors ThunderGo handlers_owner.go handleLog) +_LOG_TAIL_BYTES = 45 * 1024 * 1024 -def _escape_markdown(text: str) -> str: - return text.translate(_MARKDOWN_ESCAPE_TRANS) +def _invalidate_gates() -> None: + """H7: admin mutators flush the flag cache so changes apply within one + message instead of one TTL.""" + flags.clear() @StreamBot.on_message(filters.command("users") & owner_filter) async def get_total_users(client: Client, message: Message): try: total = await db.total_users_count() - await reply(message, - text=MSG_DB_STATS.format(total_users=total), - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) + await reply( + message, + text=MSG_DB_STATS.format(total_users=total), + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + ), + ) except Exception as e: logger.error(f"Error in get_total_users: {e}", exc_info=True) await reply(message, text=MSG_DB_ERROR) @@ -93,7 +126,7 @@ async def broadcast_handler(client: Client, message: Message): await reply( message, text=f"❌ **Invalid argument:** `{safe_arg}`\n\n{MSG_BROADCAST_USAGE}", - parse_mode=ParseMode.MARKDOWN + parse_mode=ParseMode.MARKDOWN, ) return @@ -111,18 +144,25 @@ async def show_status(client: Client, message: Message): sorted_workloads = sorted(work_loads.items(), key=lambda item: item[0]) for client_id, load_val in sorted_workloads: workload_items += MSG_WORKLOAD_ITEM.format( - bot_name=f"πŸ”Ή Client {client_id}", load=load_val) + bot_name=f"πŸ”Ή Client {client_id}", load=load_val + ) total_workload = sum(work_loads.values()) status_text_str = MSG_SYSTEM_STATUS.format( - uptime=uptime_str, active_bots=len(multi_clients), - total_workload=total_workload, workload_items=workload_items, - version=__version__) - await reply(message, - text=status_text_str, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) + uptime=uptime_str, + active_bots=len(multi_clients), + total_workload=total_workload, + workload_items=workload_items, + version=__version__, + ) + await reply( + message, + text=status_text_str, + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + ), + ) except Exception as e: logger.error(f"Error in show_status: {e}", exc_info=True) await reply(message, text=MSG_STATUS_ERROR) @@ -144,8 +184,14 @@ async def show_stats(client: Client, message: Message): ram_used = humanbytes(ram_info.used) ram_free = humanbytes(ram_info.free) - total_disk, used_disk, free_disk = await asyncio.to_thread( - shutil.disk_usage, '.') + total_disk, used_disk, free_disk = await asyncio.to_thread(shutil.disk_usage, ".") + + # H8: the last synchronous psutil call is off the event loop too + disk_percent = (await asyncio.to_thread(psutil.disk_usage, ".")).percent + + limiter_line = ( + ", ".join(f"{k}={v}" for k, v in rate_limiter.occupancy().items()) or "disabled" + ) stats_text_val = MSG_SYSTEM_STATS.format( sys_uptime=sys_uptime_str, @@ -156,19 +202,23 @@ async def show_stats(client: Client, message: Message): ram_total=ram_total, ram_used=ram_used, ram_free=ram_free, - disk_percent=psutil.disk_usage('.').percent, + disk_percent=disk_percent, total=humanbytes(total_disk), used=humanbytes(used_disk), free=humanbytes(free_disk), upload=humanbytes(net_io_counters.bytes_sent), - download=humanbytes(net_io_counters.bytes_recv) + download=humanbytes(net_io_counters.bytes_recv), + limiter=limiter_line, ) - await reply(message, - text=stats_text_val, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) + await reply( + message, + text=stats_text_val, + parse_mode=ParseMode.MARKDOWN, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + ), + ) except Exception as e: logger.error(f"Error in show_stats: {e}", exc_info=True) await reply(message, text=MSG_STATUS_ERROR) @@ -186,17 +236,22 @@ async def send_logs(client: Client, message: Message): if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) == 0: await reply( message, - text=(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY) + text=(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY), ) return - + try: - try: - await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) - except FloodWait as e: - logger.debug(f"FloodWait in log file sending, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) + # H10: never upload raw logs -- stream the (capped) tail through the + # shared redaction regexes so bot tokens / Mongo URIs cannot leak. + with open(LOG_FILE, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(max(0, size - _LOG_TAIL_BYTES)) + payload = redact_secrets(f.read().decode("utf-8", errors="replace")) + + doc = BytesIO(payload.encode("utf-8")) + doc.name = "bot_redacted.txt" + await message.reply_document(doc, caption=MSG_LOG_FILE_CAPTION) except Exception as e: logger.error(f"Error sending log file: {e}", exc_info=True) await reply(message, text=MSG_ERROR_GENERIC) @@ -205,14 +260,21 @@ async def send_logs(client: Client, message: Message): @StreamBot.on_message(filters.command("authorize") & owner_filter) async def authorize_command(client: Client, message: Message): if len(message.command) != 2: - return await reply( - message, text=MSG_AUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) - + return await reply(message, text=MSG_AUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) + try: user_id = int(message.command[1]) success = await authorize(user_id, message.from_user.id) - await reply(message, - text=((MSG_AUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_AUTHORIZE_FAILED.format(user_id=user_id)))) + if success: + _invalidate_gates() + await reply( + message, + text=( + MSG_AUTHORIZE_SUCCESS.format(user_id=user_id) + if success + else MSG_AUTHORIZE_FAILED.format(user_id=user_id) + ), + ) except ValueError: await reply(message, text=MSG_INVALID_USER_ID) except Exception as e: @@ -223,14 +285,21 @@ async def authorize_command(client: Client, message: Message): @StreamBot.on_message(filters.command("deauthorize") & owner_filter) async def deauthorize_command(client: Client, message: Message): if len(message.command) != 2: - return await reply( - message, text=MSG_DEAUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) - + return await reply(message, text=MSG_DEAUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) + try: user_id = int(message.command[1]) success = await deauthorize(user_id) - await reply(message, - text=((MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id)))) + if success: + _invalidate_gates() + await reply( + message, + text=( + MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id) + if success + else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id) + ), + ) except ValueError: await reply(message, text=MSG_INVALID_USER_ID) except Exception as e: @@ -242,33 +311,38 @@ async def deauthorize_command(client: Client, message: Message): async def list_authorized_command(client: Client, message: Message): users = await list_allowed() if not users: - return await reply( - message, text=MSG_NO_AUTH_USERS) - + return await reply(message, text=MSG_NO_AUTH_USERS) + + # M7: HTML + html.escape for user-controlled display names text = MSG_ADMIN_AUTH_LIST_HEADER for i, user in enumerate(users, 1): display_name = "Unknown" try: - tg_user = await get_user(client, user['user_id']) + tg_user = await get_user(client, user["user_id"]) if tg_user is not None: - raw_display_name = f"@{tg_user.username}" if tg_user.username else tg_user.first_name or "Unknown" - display_name = _escape_markdown(raw_display_name) + raw_display_name = ( + f"@{tg_user.username}" if tg_user.username else tg_user.first_name or "Unknown" + ) + display_name = html.escape(raw_display_name) except Exception: - logger.error("Failed to fetch tg_user for user_id=%s", user['user_id'], exc_info=True) + logger.error("Failed to fetch tg_user for user_id=%s", user["user_id"], exc_info=True) text += MSG_AUTH_USER_INFO.format( i=i, display_name=display_name, - user_id=user['user_id'], - authorized_by=user['authorized_by'], - auth_time=user['authorized_at'] + user_id=user["user_id"], + authorized_by=user["authorized_by"], + auth_time=user["authorized_at"], ) - - await reply(message, - text=text, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) + + await reply( + message, + text=text, + parse_mode=ParseMode.HTML, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] + ), + ) @StreamBot.on_message(filters.command("ban") & owner_filter) @@ -278,48 +352,33 @@ async def ban_command(client: Client, message: Message): try: target_id = int(message.command[1]) - reason = " ".join(message.command[2:]) or MSG_ADMIN_NO_BAN_REASON + # M7: reason is user-controlled and the ban messages are HTML now + reason = html.escape(" ".join(message.command[2:])) or MSG_ADMIN_NO_BAN_REASON banned_by_id = message.from_user.id if message.from_user else None if target_id == Var.OWNER_ID: return await reply(message, text=MSG_CANNOT_BAN_OWNER) if target_id < 0: - await db.add_banned_channel( - channel_id=target_id, - reason=reason, - banned_by=banned_by_id - ) + await db.add_banned_channel(channel_id=target_id, reason=reason, banned_by=banned_by_id) + _invalidate_gates() text = MSG_CHANNEL_BANNED.format(channel_id=target_id) if reason != MSG_ADMIN_NO_BAN_REASON: text += MSG_CHANNEL_BANNED_REASON_SUFFIX.format(reason=reason) - await reply(message, text=text) + await reply(message, text=text, parse_mode=ParseMode.HTML) try: - try: - await client.leave_chat(target_id) - except FloodWait as e: - logger.debug(f"FloodWait in leave_chat, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.leave_chat(target_id) + await tg_call(client.leave_chat, target_id, retries=1) except Exception as e: logger.warning(f"Could not leave banned channel {target_id}: {e}", exc_info=True) else: - await db.add_banned_user( - user_id=target_id, - reason=reason, - banned_by=banned_by_id - ) + await db.add_banned_user(user_id=target_id, reason=reason, banned_by=banned_by_id) + _invalidate_gates() text = MSG_ADMIN_USER_BANNED.format(user_id=target_id) if reason != MSG_ADMIN_NO_BAN_REASON: text += MSG_BAN_REASON_SUFFIX.format(reason=reason) - await reply(message, text=text) + await reply(message, text=text, parse_mode=ParseMode.HTML) try: - try: - await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) - except FloodWait as e: - logger.debug(f"FloodWait in ban notification, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) + await send_safe(client, target_id, text=MSG_USER_BANNED_NOTIFICATION) except Exception as e: logger.warning(f"Could not notify banned user {target_id}: {e}", exc_info=True) @@ -340,21 +399,20 @@ async def unban_command(client: Client, message: Message): if target_id < 0: if await db.remove_banned_channel(channel_id=target_id): + _invalidate_gates() await reply(message, text=MSG_CHANNEL_UNBANNED.format(channel_id=target_id)) else: await reply(message, text=MSG_CHANNEL_NOT_BANNED.format(channel_id=target_id)) else: if await db.remove_banned_user(user_id=target_id): + _invalidate_gates() await reply(message, text=MSG_ADMIN_USER_UNBANNED.format(user_id=target_id)) try: - try: - await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) - except FloodWait as e: - logger.debug(f"FloodWait in unban notification, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) + await send_safe(client, target_id, text=MSG_USER_UNBANNED_NOTIFICATION) except Exception as e: - logger.warning(f"Could not notify unbanned user {target_id}: {e}", exc_info=True) + logger.warning( + f"Could not notify unbanned user {target_id}: {e}", exc_info=True + ) else: await reply(message, text=MSG_USER_NOT_IN_BAN_LIST.format(user_id=target_id)) except ValueError: @@ -366,164 +424,75 @@ async def unban_command(client: Client, message: Message): @StreamBot.on_message(filters.command("shell") & owner_filter) async def run_shell_command(client: Client, message: Message): + # L10: env kill-switch -- the powerful command is opt-in. + if not getattr(Var, "ENABLE_SHELL", False): + return await reply(message, text=MSG_SHELL_DISABLED, parse_mode=ParseMode.HTML) + if len(message.command) < 2: - return await reply( - message, text=MSG_SHELL_USAGE, parse_mode=ParseMode.HTML) - + return await reply(message, text=MSG_SHELL_USAGE, parse_mode=ParseMode.HTML) + command = " ".join(message.command[1:]) - status_msg = await reply(message, - text=MSG_SHELL_EXECUTING.format( - command=html.escape(command)), - parse_mode=ParseMode.HTML) - + # L10: command log -- who ran what, when. + logger.info( + f"/shell invoked by {message.from_user.id if message.from_user else 'unknown'}: {command}" + ) + + status_msg = await reply( + message, + text=MSG_SHELL_EXECUTING.format(command=html.escape(command)), + parse_mode=ParseMode.HTML, + ) + try: process = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + command, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) - - stdout, stderr = await process.communicate() - + + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60) + except TimeoutError: + process.kill() + await process.communicate() + raise TimeoutError("shell command exceeded 60s") from None + output = "" if stdout: output += MSG_SHELL_OUTPUT_STDOUT.format( - output=html.escape(stdout.decode(errors='ignore'))) + output=html.escape(stdout.decode(errors="ignore")) + ) if stderr: output += MSG_SHELL_OUTPUT_STDERR.format( - error=html.escape(stderr.decode(errors='ignore'))) - + error=html.escape(stderr.decode(errors="ignore")) + ) + output = output.strip() or MSG_SHELL_NO_OUTPUT - + try: - await status_msg.delete() - except FloodWait as e: - logger.debug(f"FloodWait in shell status message delete, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.delete() - + await delete_safe(status_msg) + except Exception: + pass + if len(output) > 4096: file = BytesIO(output.encode()) file.name = "shell_output.txt" - try: - await message.reply_document( - file, - caption=MSG_SHELL_OUTPUT.format( - command=html.escape(command))) - except FloodWait as e: - logger.debug(f"FloodWait in shell output document, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_document( - file, - caption=MSG_SHELL_OUTPUT.format( - command=html.escape(command))) + await message.reply_document( + file, caption=MSG_SHELL_OUTPUT.format(command=html.escape(command)) + ) else: await reply(message, text=output, parse_mode=ParseMode.HTML) - + except Exception as e: try: - try: - await status_msg.edit_text( - MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - except FloodWait as e: - logger.debug(f"FloodWait in shell error message edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - except MessageNotModified: - pass + await edit_safe( + status_msg, + MSG_SHELL_ERROR.format(error=html.escape(str(e))), + parse_mode=ParseMode.HTML, + ) + except MessageNotModified: + pass except Exception: await reply( message, text=MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - - -@StreamBot.on_message(filters.command("speedtest") & owner_filter) -async def speedtest_command(client: Client, message: Message): - status_msg = await reply(message, text=MSG_SPEEDTEST_INIT) - try: - result_dict, image_url = await run_speedtest() - if result_dict is None: - try: - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest error edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except MessageNotModified: - pass - return - - result_text = _format_speedtest_result(result_dict) - await _send_result(message, status_msg, result_text, image_url) - except Exception as e: - logger.error(f"Error in speedtest_command: {e}", exc_info=True) - try: - try: - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest exception error edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except MessageNotModified: - pass - except Exception: - await reply(message, text=MSG_SPEEDTEST_ERROR) - - -def _format_speedtest_result(result_dict: dict) -> str: - s, c = result_dict['server'], result_dict['client'] - return MSG_SPEEDTEST_RESULT.format( - download_mbps=_fmt(result_dict['download_mbps']), - upload_mbps=_fmt(result_dict['upload_mbps']), - download_bps=humanbytes(result_dict['download_bps']), - upload_bps=humanbytes(result_dict['upload_bps']), - ping=_fmt(result_dict['ping']), - timestamp=result_dict['timestamp'], - bytes_sent=humanbytes(result_dict['bytes_sent']), - bytes_received=humanbytes(result_dict['bytes_received']), - server_name=s['name'], - server_country=f"{s['country']} ({s['cc']})", - server_sponsor=s['sponsor'], - server_latency=_fmt(s['latency']), - server_lat=_fmt(s['lat'], 4), - server_lon=_fmt(s['lon'], 4), - client_ip=c['ip'], - client_lat=_fmt(c['lat'], 4), - client_lon=_fmt(c['lon'], 4), - client_isp=c['isp'], - client_isprating=c['isprating'], - client_country=c['country'] - ) - - -async def _send_result(message: Message, status_msg: Message, result_text: str, image_url: str): - if image_url: - try: - await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest photo reply, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) - try: - await status_msg.delete() - except FloodWait as e: - logger.debug(f"FloodWait in speedtest status delete, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.delete() - else: - try: - await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest result edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) - except MessageNotModified: - pass - - -def _fmt(value, decimals: int = 2) -> str: - return f"{float(value):.{decimals}f}" + parse_mode=ParseMode.HTML, + ) diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py index c1133b1..57747f9 100755 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -1,223 +1,216 @@ # Thunder/bot/plugins/callbacks.py -import asyncio +import functools +import secrets from pyrogram import Client, filters -from pyrogram.errors import FloodWait, MessageNotModified, MessageDeleteForbidden -from pyrogram.types import (CallbackQuery, InlineKeyboardButton, - InlineKeyboardMarkup) +from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup from Thunder.bot import StreamBot +from Thunder.bot.registry import help_command_rows from Thunder.utils.broadcast import broadcast_ids from Thunder.utils.decorators import owner_only from Thunder.utils.logger import logger from Thunder.utils.messages import ( - MSG_ABOUT, MSG_BROADCAST_CANCEL, MSG_BUTTON_ABOUT, MSG_BUTTON_CLOSE, - MSG_BUTTON_GET_HELP, MSG_BUTTON_GITHUB, MSG_BUTTON_JOIN_CHANNEL, - MSG_ERROR_BROADCAST_INSTRUCTION, MSG_ERROR_BROADCAST_RESTART, - MSG_ERROR_CALLBACK_UNSUPPORTED, MSG_HELP + MSG_ABOUT, + MSG_BROADCAST_CANCEL, + MSG_BUTTON_ABOUT, + MSG_BUTTON_CLOSE, + MSG_BUTTON_GET_HELP, + MSG_BUTTON_GITHUB, + MSG_BUTTON_JOIN_CHANNEL, + MSG_ERROR_BROADCAST_INSTRUCTION, + MSG_ERROR_BROADCAST_RESTART, + MSG_ERROR_CALLBACK_UNSUPPORTED, + MSG_ERROR_CLOSE_NOT_ALLOWED, + MSG_HELP_COMMANDS_HEADER, + MSG_HELP_INTRO, + MSG_HELP_TIPS, ) +from Thunder.utils.safe_call import answer_safe, edit_safe, tg_call from Thunder.vars import Var + +def guard_callback(fn): + """M11: panic isolation + standardized owner error-ID notification. + + Any unhandled exception is logged with a correlate-able error ID, the + owner is notified, and the query is answered so stale buttons never + leave a perpetual spinner. + """ + + @functools.wraps(fn) + async def wrapper(client: Client, callback_query: CallbackQuery): + try: + return await fn(client, callback_query) + except Exception as e: + error_id = secrets.token_hex(6) + logger.error(f"Callback error {error_id} in {fn.__name__}: {e}", exc_info=True) + try: + await answer_safe( + callback_query, "An error occurred. Please try again.", show_alert=True + ) + except Exception: + pass + try: + from Thunder.utils.bot_utils import notify_own + from Thunder.utils.messages import MSG_CRITICAL_ERROR + + await notify_own( + client, + MSG_CRITICAL_ERROR.format( + error=f"callback:{fn.__name__}: {e}", error_id=error_id + ), + ) + except Exception: + logger.debug("Owner notification for callback failure also failed", exc_info=True) + + return wrapper + + async def get_force_channel_button(client: Client): if not Var.FORCE_CHANNEL_ID: return None try: - try: - chat = await client.get_chat(Var.FORCE_CHANNEL_ID) - except FloodWait as e: - await asyncio.sleep(e.value) - chat = await client.get_chat(Var.FORCE_CHANNEL_ID) + chat = await tg_call(client.get_chat, Var.FORCE_CHANNEL_ID, retries=1) if chat: - invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None) + invite_link = chat.invite_link or ( + f"https://t.me/{chat.username}" if chat.username else None + ) if invite_link: - return [InlineKeyboardButton( - MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title or "Channel"), - url=invite_link - )] + return [ + InlineKeyboardButton( + MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title or "Channel"), + url=invite_link, + ) + ] except Exception as e: logger.error(f"Error getting force channel button: {e}", exc_info=True) return None + @StreamBot.on_callback_query(filters.regex(r"^help_command$")) +@guard_callback async def help_callback(client: Client, callback_query: CallbackQuery): + await answer_safe(callback_query) + buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] + force_button = await get_force_channel_button(client) + if force_button: + buttons.append(force_button) + buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) + help_text = ( + MSG_HELP_INTRO.format(max_files=Var.MAX_BATCH_FILES) + + MSG_HELP_COMMANDS_HEADER + + help_command_rows() + + MSG_HELP_TIPS + ) try: - await callback_query.answer() - buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] - force_button = await get_force_channel_button(client) - if force_button: - buttons.append(force_button) - buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) - try: - await callback_query.message.edit_text( - text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except MessageNotModified: - pass + await edit_safe( + callback_query.message, + help_text, + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True, + ) except Exception as e: - logger.error(f"Error in help callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) + logger.debug(f"Could not edit help panel: {e}") + @StreamBot.on_callback_query(filters.regex(r"^about_command$")) +@guard_callback async def about_callback(client: Client, callback_query: CallbackQuery): + await answer_safe(callback_query) + buttons = [ + [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], + [ + InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ], + ] try: - await callback_query.answer() - buttons = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], - [ - InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel") - ] - ] - try: - await callback_query.message.edit_text( - text=MSG_ABOUT, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - text=MSG_ABOUT, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except MessageNotModified: - pass + await edit_safe( + callback_query.message, + MSG_ABOUT, + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True, + ) except Exception as e: - logger.error(f"Error in about callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) + logger.debug(f"Could not edit about panel: {e}") + @StreamBot.on_callback_query(filters.regex(r"^restart_broadcast$")) +@guard_callback async def restart_broadcast_callback(client: Client, callback_query: CallbackQuery): if not await owner_only(client, callback_query): return - try: - try: - await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True) - buttons = [ - [ - InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel") - ] + await answer_safe(callback_query, MSG_ERROR_BROADCAST_RESTART, show_alert=True) + buttons = [ + [ + InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), ] - try: - await callback_query.message.edit_text( - MSG_ERROR_BROADCAST_INSTRUCTION, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - MSG_ERROR_BROADCAST_INSTRUCTION, - reply_markup=InlineKeyboardMarkup(buttons), - disable_web_page_preview=True - ) + ] + try: + await edit_safe( + callback_query.message, + MSG_ERROR_BROADCAST_INSTRUCTION, + reply_markup=InlineKeyboardMarkup(buttons), + disable_web_page_preview=True, + ) except Exception as e: - logger.error(f"Error in restart broadcast callback: {e}", exc_info=True) - try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) + logger.debug(f"Could not edit restart-broadcast panel: {e}") + @StreamBot.on_callback_query(filters.regex(r"^close_panel$")) +@guard_callback async def close_panel_callback(client: Client, callback_query: CallbackQuery): - try: - try: - await callback_query.answer() - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer() + # M11: permission check -- previously any group member who saw a Close + # button could trigger deletion attempts. + closer_id = callback_query.from_user.id if callback_query.from_user else None + message = callback_query.message + owner_id = getattr(message.from_user, "id", None) if message and message.from_user else None + + is_allowed = closer_id == Var.OWNER_ID or (owner_id is not None and closer_id == owner_id) + if not is_allowed: + await answer_safe(callback_query, MSG_ERROR_CLOSE_NOT_ALLOWED, show_alert=True) + return + + await answer_safe(callback_query) + if message: try: - try: - await callback_query.message.delete() - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.delete() - except MessageDeleteForbidden: - logger.debug(f"Failed to delete callback query message due to permissions. Message ID: {callback_query.message.id}") + await message.delete() except Exception as e: - logger.error(f"Error deleting callback query message: {e}", exc_info=True) + logger.debug( + f"Failed to delete callback query message {getattr(message, 'id', '?')}: {e}" + ) - if callback_query.message.reply_to_message: + if message.reply_to_message: try: - reply_msg = callback_query.message.reply_to_message - try: - await reply_msg.delete() - except FloodWait as e: - await asyncio.sleep(e.value) - await reply_msg.delete() - except MessageDeleteForbidden: - logger.debug(f"Failed to delete replied message due to permissions. Message ID: {reply_msg.id}") + await message.reply_to_message.delete() except Exception as e: - logger.error(f"Error deleting replied message: {e}", exc_info=True) - except Exception as e: - logger.error(f"General error in close panel callback: {e}", exc_info=True) + logger.debug(f"Failed to delete replied message: {e}") + @StreamBot.on_callback_query(filters.regex(r"^cancel_")) +@guard_callback async def cancel_broadcast(client: Client, callback_query: CallbackQuery): - try: - broadcast_id = callback_query.data.split("_")[1] - if broadcast_id in broadcast_ids: - broadcast_ids[broadcast_id]["cancelled"] = True - try: - await callback_query.message.edit_text( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.message.edit_text( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) - ) - else: - try: - await callback_query.answer( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), - show_alert=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer( - MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), - show_alert=True - ) - except Exception as e: - logger.error(f"Error in cancel broadcast callback: {e}", exc_info=True) + broadcast_id = callback_query.data.split("_")[1] + if broadcast_id in broadcast_ids: + broadcast_ids[broadcast_id]["cancelled"] = True try: - await callback_query.answer("An error occurred. Please try again.", show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer("An error occurred. Please try again.", show_alert=True) + await edit_safe( + callback_query.message, MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) + ) + except Exception as e: + logger.debug(f"Could not edit cancel panel: {e}") + else: + await answer_safe( + callback_query, MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), show_alert=True + ) + @StreamBot.on_callback_query() +@guard_callback async def fallback_callback(client: Client, callback_query: CallbackQuery): - try: - try: - await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) - except FloodWait as e: - await asyncio.sleep(e.value) - await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) - except Exception as e: - logger.error(f"Error in fallback callback: {e}", exc_info=True) + """M11: catch-all -- unknown/stale buttons are answered within a second + instead of leaving a perpetual spinner.""" + await answer_safe(callback_query, MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py index e5d1af2..a8f9ed3 100755 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -1,119 +1,132 @@ # Thunder/bot/plugins/common.py -import asyncio +import html import time -from datetime import datetime, timedelta from pyrogram import Client, filters -from pyrogram.errors import FloodWait, MessageNotModified -from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup, - Message, User) +from pyrogram.errors import MessageNotModified +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, User from Thunder.bot import StreamBot -from Thunder.utils.bot_utils import (gen_dc_txt, get_user, log_newusr, - reply_user_err) -from Thunder.utils.database import db +from Thunder.utils.bot_utils import gen_dc_txt, get_user, log_newusr, reply_user_err +from Thunder.utils.commands import build_help_text from Thunder.utils.decorators import check_banned from Thunder.utils.file_properties import get_fname, get_fsize, parse_fid from Thunder.utils.force_channel import force_channel_check, get_force_info from Thunder.utils.human_readable import humanbytes from Thunder.utils.logger import logger from Thunder.utils.messages import ( - MSG_ABOUT, MSG_BUTTON_ABOUT, MSG_BUTTON_CLOSE, MSG_BUTTON_GET_HELP, - MSG_BUTTON_GITHUB, MSG_BUTTON_JOIN_CHANNEL, MSG_BUTTON_VIEW_PROFILE, - MSG_COMMUNITY_CHANNEL, MSG_DC_ANON_ERROR, MSG_DC_FILE_ERROR, - MSG_DC_FILE_INFO, MSG_DC_INVALID_USAGE, MSG_DC_UNKNOWN, - MSG_ERROR_USER_INFO, MSG_FILE_TYPE_ANIMATION, MSG_FILE_TYPE_AUDIO, - MSG_FILE_TYPE_DOCUMENT, MSG_FILE_TYPE_PHOTO, MSG_FILE_TYPE_STICKER, - MSG_FILE_TYPE_UNKNOWN, MSG_FILE_TYPE_VIDEO, MSG_FILE_TYPE_VIDEO_NOTE, - MSG_FILE_TYPE_VOICE, MSG_HELP, MSG_PING_RESPONSE, MSG_PING_START, - MSG_TOKEN_ACTIVATED, MSG_TOKEN_FAILED, MSG_TOKEN_INVALID, MSG_WELCOME + MSG_ABOUT, + MSG_BUTTON_ABOUT, + MSG_BUTTON_CLOSE, + MSG_BUTTON_GET_HELP, + MSG_BUTTON_GITHUB, + MSG_BUTTON_JOIN_CHANNEL, + MSG_BUTTON_VIEW_PROFILE, + MSG_COMMUNITY_CHANNEL, + MSG_DC_ANON_ERROR, + MSG_DC_FILE_ERROR, + MSG_DC_FILE_INFO, + MSG_DC_INVALID_USAGE, + MSG_DC_UNKNOWN, + MSG_ERROR_USER_INFO, + MSG_FILE_TYPE_ANIMATION, + MSG_FILE_TYPE_AUDIO, + MSG_FILE_TYPE_DOCUMENT, + MSG_FILE_TYPE_PHOTO, + MSG_FILE_TYPE_STICKER, + MSG_FILE_TYPE_UNKNOWN, + MSG_FILE_TYPE_VIDEO, + MSG_FILE_TYPE_VIDEO_NOTE, + MSG_FILE_TYPE_VOICE, + MSG_PING_RESPONSE, + MSG_PING_START, + MSG_TOKEN_ACTIVATED, + MSG_TOKEN_FAILED, + MSG_TOKEN_INVALID, + MSG_WELCOME, ) +from Thunder.utils.safe_call import edit_safe, reply_safe +from Thunder.utils.tokens import consume from Thunder.vars import Var +# M7: surfaces that interpolate user-controlled values are HTML now; +# every interpolation is html.escape()d. + + @StreamBot.on_message(filters.command("start") & filters.private) async def start_command(bot: Client, msg: Message): + # M12: /start runs banned + private-mode only so the activation flow + # stays reachable for token-gated users. if not await check_banned(bot, msg): return + from Thunder.utils.decorators import check_private_mode + + if not await check_private_mode(bot, msg): + return user = msg.from_user if user: await log_newusr(bot, user.id, user.first_name) - + if len(msg.command) == 2: payload = msg.command[1] - + if payload == "start": pass else: - token = await db.token_col.find_one({"token": payload}) - if token: - if token["user_id"] != user.id: - try: - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="This activation link is not for your account.", - error_id=str(int(time.time()))[-8:] - )) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="This activation link is not for your account.", - error_id=str(int(time.time()))[-8:] - )) - - if token.get("activated"): - try: - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="Token has already been activated.", - error_id=str(int(time.time()))[-8:] - )) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_FAILED.format( - reason="Token has already been activated.", - error_id=str(int(time.time()))[-8:] - )) - - now = datetime.utcnow() - exp = now + timedelta(hours=Var.TOKEN_TTL_HOURS) - - await db.token_col.update_one( - {"token": payload, "user_id": user.id}, - {"$set": {"activated": True, "created_at": now, "expires_at": exp}} + # M8: atomic activation -- exactly one concurrent /start wins. + status, hours = await consume(payload, user.id) + if status == "wrong_user": + return await reply_safe( + msg, + text=MSG_TOKEN_FAILED.format( + reason="This activation link is not for your account.", + error_id=str(int(time.time()))[-8:], + ), + ) + if status == "already": + return await reply_safe( + msg, + text=MSG_TOKEN_FAILED.format( + reason="Token has already been activated.", + error_id=str(int(time.time()))[-8:], + ), ) - - hrs = round((exp - now).total_seconds() / 3600, 1) - try: - return await msg.reply_text(text=MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) - else: - try: - return await msg.reply_text(text=MSG_TOKEN_INVALID) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(text=MSG_TOKEN_INVALID) - - txt = MSG_WELCOME.format(user_name=user.first_name if user else "Unknown") + if status == "ok": + return await reply_safe(msg, text=MSG_TOKEN_ACTIVATED.format(duration_hours=hours)) + return await reply_safe(msg, text=MSG_TOKEN_INVALID) + + txt = MSG_WELCOME.format(user_name=html.escape(user.first_name if user else "Unknown")) link, title = await get_force_info(bot) if link: - txt += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=title)}" - + txt += "\n\n" + MSG_COMMUNITY_CHANNEL.format(channel_title=html.escape(title or "Channel")) + btns = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")], - [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + [ + InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command"), + ], + [ + InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ], ] - + if link: - btns.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)]) - - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + btns.append( + [InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)] + ) + + await _send_html(msg, txt, btns) + + +async def _send_html(msg: Message, txt: str, btns): + from pyrogram import enums + + await reply_safe( + msg, text=txt, parse_mode=enums.ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns) + ) + @StreamBot.on_message(filters.command("help") & filters.private) async def help_command(bot: Client, msg: Message): @@ -121,20 +134,19 @@ async def help_command(bot: Client, msg: Message): return if msg.from_user: await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) - - txt = MSG_HELP.format(max_files=Var.MAX_BATCH_FILES) + + txt = build_help_text(Var.MAX_BATCH_FILES) btns = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]] - + link, title = await get_force_info(bot) if link: - btns.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)]) - + btns.append( + [InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), url=link)] + ) + btns.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + await _send_html(msg, txt, btns) + @StreamBot.on_message(filters.command("about") & filters.private) async def about_command(bot: Client, msg: Message): @@ -142,37 +154,33 @@ async def about_command(bot: Client, msg: Message): return if msg.from_user: await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) - + btns = [ [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")], - [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + [ + InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ], ] - - try: - await msg.reply_text(text=MSG_ABOUT, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=MSG_ABOUT, reply_markup=InlineKeyboardMarkup(btns)) + + await _send_html(msg, MSG_ABOUT, btns) + async def send_user_dc(msg: Message, user: User): txt = await gen_dc_txt(user) url = f"https://t.me/{user.username}" if user.username else f"tg://user?id={user.id}" btns = [ [InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=url)], - [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")], ] - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) + await reply_safe(msg, text=txt, reply_markup=InlineKeyboardMarkup(btns)) + async def send_file_dc(msg: Message, file_msg: Message): try: fname = get_fname(file_msg) or "Untitled File" fsize = humanbytes(get_fsize(file_msg)) - + type_map = { "document": MSG_FILE_TYPE_DOCUMENT, "photo": MSG_FILE_TYPE_PHOTO, @@ -181,44 +189,43 @@ async def send_file_dc(msg: Message, file_msg: Message): "voice": MSG_FILE_TYPE_VOICE, "sticker": MSG_FILE_TYPE_STICKER, "animation": MSG_FILE_TYPE_ANIMATION, - "video_note": MSG_FILE_TYPE_VIDEO_NOTE + "video_note": MSG_FILE_TYPE_VIDEO_NOTE, } - + file_type = next((attr for attr in type_map if getattr(file_msg, attr, None)), "unknown") type_display = type_map.get(file_type, MSG_FILE_TYPE_UNKNOWN) - + dc_id = MSG_DC_UNKNOWN fid = parse_fid(file_msg) if fid: dc_id = fid.dc_id - + txt = MSG_DC_FILE_INFO.format( - file_name=fname, - file_size=fsize, - file_type=type_display, - dc_id=dc_id + file_name=fname, file_size=fsize, file_type=type_display, dc_id=dc_id ) - + btns = [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] - try: - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text(text=txt, reply_markup=InlineKeyboardMarkup(btns)) - + await reply_safe(msg, text=txt, reply_markup=InlineKeyboardMarkup(btns)) + except Exception as e: logger.error(f"File DC error: {e}", exc_info=True) await reply_user_err(msg, MSG_DC_FILE_ERROR) + @StreamBot.on_message(filters.command("dc")) async def dc_command(bot: Client, msg: Message): + # M12: full preflight chain for /dc (banned -> private -> token -> force-sub) if not await check_banned(bot, msg): return + from Thunder.utils.decorators import check_private_mode + + if not await check_private_mode(bot, msg): + return if not await force_channel_check(bot, msg): return if not msg.from_user and not msg.reply_to_message: return await reply_user_err(msg, MSG_DC_ANON_ERROR) - + args = msg.text.strip().split(maxsplit=1) if len(args) > 1: user = await get_user(bot, args[1].strip()) @@ -227,7 +234,7 @@ async def dc_command(bot: Client, msg: Message): else: await reply_user_err(msg, MSG_ERROR_USER_INFO) return - + if msg.reply_to_message: ref = msg.reply_to_message if ref.media: @@ -237,44 +244,46 @@ async def dc_command(bot: Client, msg: Message): else: await reply_user_err(msg, MSG_DC_INVALID_USAGE) return - + if msg.from_user: await send_user_dc(msg, msg.from_user) else: await reply_user_err(msg, MSG_DC_ANON_ERROR) + @StreamBot.on_message(filters.command("ping") & filters.private) async def ping_command(bot: Client, msg: Message): if not await check_banned(bot, msg): return + from Thunder.utils.decorators import check_private_mode + + if not await check_private_mode(bot, msg): + return if not await force_channel_check(bot, msg): return start = time.time() try: - sent = await msg.reply_text(text=MSG_PING_START) - except FloodWait as e: - await asyncio.sleep(e.value) - sent = await msg.reply_text(text=MSG_PING_START) + sent = await reply_safe(msg, text=MSG_PING_START) + except Exception: + return end = time.time() ms = (end - start) * 1000 - + btns = [ - [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), - InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + [ + InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ] ] - + try: - await sent.edit_text( - MSG_PING_RESPONSE.format(time_taken_ms=ms), - reply_markup=InlineKeyboardMarkup(btns), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await sent.edit_text( + await edit_safe( + sent, MSG_PING_RESPONSE.format(time_taken_ms=ms), reply_markup=InlineKeyboardMarkup(btns), - disable_web_page_preview=True + disable_web_page_preview=True, ) except MessageNotModified: pass + except Exception as e: + logger.debug(f"Could not edit ping message: {e}") diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index 638c594..c798417 100755 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -2,134 +2,142 @@ import asyncio import secrets -from typing import Any, Dict, Optional +import time +from typing import Any from pyrogram import Client, enums, filters -from pyrogram.errors import FloodWait, MessageNotModified, MessageDeleteForbidden, MessageIdInvalid -from pyrogram.types import (InlineKeyboardButton, InlineKeyboardMarkup, - Message) +from pyrogram.errors import MessageDeleteForbidden, MessageIdInvalid, MessageNotModified +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from Thunder.bot import StreamBot -from Thunder.utils.bot_utils import (gen_canonical_links, gen_links, is_admin, - log_newusr, notify_own, reply_user_err) +from Thunder.utils.bot_utils import ( + format_link_message, + gen_canonical_links, + gen_links, + is_admin, + log_newusr, + notify_own, + reply_user_err, +) from Thunder.utils.canonical_files import get_or_create_canonical_file from Thunder.utils.database import db -from Thunder.utils.decorators import (check_banned, get_shortener_status, - require_token) -from Thunder.utils.force_channel import force_channel_check +from Thunder.utils.decorators import preflight from Thunder.utils.logger import logger from Thunder.utils.messages import ( - MSG_BATCH_LINKS_READY, MSG_BUTTON_DOWNLOAD, MSG_BUTTON_START_CHAT, - MSG_BUTTON_STREAM_NOW, MSG_CRITICAL_ERROR, MSG_DM_BATCH_PREFIX, - MSG_DM_SINGLE_PREFIX, MSG_ERROR_DM_FAILED, MSG_ERROR_INVALID_NUMBER, - MSG_ERROR_NO_FILE, MSG_ERROR_NOT_ADMIN, MSG_ERROR_NUMBER_RANGE, - MSG_ERROR_PROCESSING_MEDIA, MSG_ERROR_REPLY_FILE, MSG_ERROR_START_BOT, - MSG_LINKS, MSG_NEW_FILE_REQUEST, MSG_PROCESSING_BATCH, - MSG_PROCESSING_FILE, MSG_PROCESSING_REQUEST, MSG_PROCESSING_RESULT, - MSG_PROCESSING_STATUS + MSG_BATCH_LINKS_READY, + MSG_BUTTON_DOWNLOAD, + MSG_BUTTON_START_CHAT, + MSG_BUTTON_STREAM_NOW, + MSG_CRITICAL_ERROR, + MSG_DM_BATCH_PREFIX, + MSG_DM_SINGLE_PREFIX, + MSG_ERROR_DM_FAILED, + MSG_ERROR_INVALID_NUMBER, + MSG_ERROR_NO_FILE, + MSG_ERROR_NOT_ADMIN, + MSG_ERROR_NUMBER_RANGE, + MSG_ERROR_PROCESSING_MEDIA, + MSG_ERROR_REPLY_FILE, + MSG_ERROR_START_BOT, + MSG_NEW_FILE_REQUEST, + MSG_PROCESSING_BATCH, + MSG_PROCESSING_FILE, + MSG_PROCESSING_REQUEST, + MSG_PROCESSING_RESULT, + MSG_PROCESSING_STATUS, ) from Thunder.utils.rate_limiter import handle_rate_limited_request +from Thunder.utils.safe_call import ( + delete_safe, + edit_safe, + reply_safe, + send_safe, + tg_call, +) from Thunder.vars import Var BATCH_SIZE = 10 LINK_CHUNK_SIZE = 20 BATCH_UPDATE_INTERVAL = 5 MESSAGE_DELAY = 0.5 +# M4b: overall batch deadline = 30 + 2n seconds +_BATCH_DEADLINE_BASE = 30 -async def fwd_media(m_msg: Message) -> Optional[Message]: +async def fwd_media(m_msg: Message) -> Message | None: try: - try: - return await m_msg.copy(chat_id=Var.BIN_CHANNEL) - except FloodWait as e: - await asyncio.sleep(e.value) - return await m_msg.copy(chat_id=Var.BIN_CHANNEL) + return await tg_call(m_msg.copy, chat_id=Var.BIN_CHANNEL) except Exception as e: if "MEDIA_CAPTION_TOO_LONG" in str(e): logger.debug(f"MEDIA_CAPTION_TOO_LONG error, retrying without caption: {e}") try: - return await m_msg.copy(chat_id=Var.BIN_CHANNEL, caption=None) - except FloodWait as e: - await asyncio.sleep(e.value) - return await m_msg.copy(chat_id=Var.BIN_CHANNEL, caption=None) + return await tg_call(m_msg.copy, chat_id=Var.BIN_CHANNEL, caption=None) + except Exception as e2: + logger.error(f"Error fwd_media copy (no caption): {e2}", exc_info=True) + return None logger.error(f"Error fwd_media copy: {e}", exc_info=True) return None def get_link_buttons(links): - return InlineKeyboardMarkup([[ - InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=links['stream_link']), - InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=links['online_link']) - ]]) - -async def validate_request_common(client: Client, message: Message) -> Optional[bool]: - if not await check_banned(client, message): - return None - if not await require_token(client, message): + return InlineKeyboardMarkup( + [ + [ + InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=links["stream_link"]), + InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=links["online_link"]), + ] + ] + ) + + +async def validate_request_common(client: Client, message: Message) -> bool | None: + """M12: one preflight chain for every stream entry point. + + Order (documented contract, see AGENTS.md): + banned -> private-mode -> token-activation -> force-sub -> shortener-status + """ + shortener_val = await preflight(client, message) + if shortener_val is None: return None - if not await force_channel_check(client, message): + from Thunder.utils.decorators import force_sub_gate + + if not await force_sub_gate(client, message): return None - return await get_shortener_status(client, message) + return shortener_val async def send_channel_links( - links: Dict[str, Any], + links: dict[str, Any], source_info: str, source_id: int, *, - target_msg: Optional[Message] = None, - reply_to_message_id: Optional[int] = None + target_msg: Message | None = None, + reply_to_message_id: int | None = None, ): + text = MSG_NEW_FILE_REQUEST.format( + source_info=source_info, + id_=source_id, + online_link=links["online_link"], + stream_link=links["stream_link"], + ) try: - text = MSG_NEW_FILE_REQUEST.format( - source_info=source_info, - id_=source_id, - online_link=links['online_link'], - stream_link=links['stream_link'] - ) - if target_msg: - await target_msg.reply_text( - text, - disable_web_page_preview=True, - quote=True - ) - else: - await StreamBot.send_message( - chat_id=Var.BIN_CHANNEL, - text=text, - disable_web_page_preview=True, - reply_to_message_id=reply_to_message_id - ) - except FloodWait as e: - await asyncio.sleep(e.value) - text = MSG_NEW_FILE_REQUEST.format( - source_info=source_info, - id_=source_id, - online_link=links['online_link'], - stream_link=links['stream_link'] - ) if target_msg: - await target_msg.reply_text( - text, - disable_web_page_preview=True, - quote=True - ) + await tg_call(target_msg.reply_text, text, disable_web_page_preview=True, quote=True) else: - await StreamBot.send_message( - chat_id=Var.BIN_CHANNEL, + await send_safe( + StreamBot, + Var.BIN_CHANNEL, text=text, disable_web_page_preview=True, - reply_to_message_id=reply_to_message_id + reply_to_message_id=reply_to_message_id, ) + except Exception as e: + logger.error(f"Error sending channel links: {e}", exc_info=True) async def safe_edit_message(message: Message, text: str, **kwargs): try: - try: - return await message.edit_text(text, **kwargs) - except FloodWait as e: - await asyncio.sleep(e.value) - return await message.edit_text(text, **kwargs) + return await edit_safe(message, text, **kwargs) except MessageNotModified: pass except MessageDeleteForbidden: @@ -140,75 +148,38 @@ async def safe_edit_message(message: Message, text: str, **kwargs): async def safe_delete_message(message: Message): try: - try: - await message.delete() - except FloodWait as e: - await asyncio.sleep(e.value) - await message.delete() + await delete_safe(message) except MessageDeleteForbidden: logger.debug(f"Failed to delete message {message.id} due to permissions.") except Exception as e: logger.error(f"Error deleting message {message.id}: {e}", exc_info=True) -async def send_dm_links(bot: Client, user_id: int, links: Dict[str, Any], chat_title: str): +async def send_dm_links(bot: Client, user_id: int, links: dict[str, Any], chat_title: str): try: - dm_text = MSG_DM_SINGLE_PREFIX.format(chat_title=chat_title) + "\n" + \ - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ) - try: - await bot.send_message( - chat_id=user_id, - text=dm_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.MARKDOWN, - reply_markup=get_link_buttons(links) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await bot.send_message( - chat_id=user_id, - text=dm_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.MARKDOWN, - reply_markup=get_link_buttons(links) - ) + dm_text = ( + MSG_DM_SINGLE_PREFIX.format(chat_title=chat_title) + "\n" + format_link_message(links) + ) + await send_safe( + bot, + user_id, + text=dm_text, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML, + reply_markup=get_link_buttons(links), + ) except Exception as e: logger.error(f"Error sending DM to user {user_id}: {e}", exc_info=True) -async def send_link(msg: Message, links: Dict[str, Any]): - try: - await msg.reply_text( - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ), - quote=True, - parse_mode=enums.ParseMode.MARKDOWN, - disable_web_page_preview=True, - reply_markup=get_link_buttons(links) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text( - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ), - quote=True, - parse_mode=enums.ParseMode.MARKDOWN, - disable_web_page_preview=True, - reply_markup=get_link_buttons(links) - ) +async def send_link(msg: Message, links: dict[str, Any]): + await reply_safe( + msg, + format_link_message(links), + parse_mode=enums.ParseMode.HTML, + disable_web_page_preview=True, + reply_markup=get_link_buttons(links), + ) @StreamBot.on_message(filters.command("link") & ~filters.private) @@ -220,36 +191,33 @@ async def _actual_link_handler(client: Client, message: Message, **handler_kwarg if message.from_user and not await db.is_user_exist(message.from_user.id): invite_link = f"https://t.me/{client.me.username}?start=start" try: - await message.reply_text( - MSG_ERROR_START_BOT.format(invite_link=invite_link), - disable_web_page_preview=True, - parse_mode=enums.ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]), - quote=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await message.reply_text( + await reply_safe( + message, MSG_ERROR_START_BOT.format(invite_link=invite_link), disable_web_page_preview=True, parse_mode=enums.ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]), - quote=True + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]] + ), ) + except Exception as e: + logger.error(f"Error sending start-bot hint: {e}", exc_info=True) return - if (message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP] - and not await is_admin(client, message.chat.id)): + if message.chat.type in [ + enums.ChatType.GROUP, + enums.ChatType.SUPERGROUP, + ] and not await is_admin(client, message.chat.id): await reply_user_err(message, MSG_ERROR_NOT_ADMIN) return if not message.reply_to_message or not message.reply_to_message.media: await reply_user_err( - message, - MSG_ERROR_REPLY_FILE if not message.reply_to_message else MSG_ERROR_NO_FILE) + message, MSG_ERROR_REPLY_FILE if not message.reply_to_message else MSG_ERROR_NO_FILE + ) return - notification_msg = handler_kwargs.get('notification_msg') + notification_msg = handler_kwargs.get("notification_msg") parts = message.text.split() num_files = 1 @@ -258,33 +226,55 @@ async def _actual_link_handler(client: Client, message: Message, **handler_kwarg num_files = int(parts[1]) if not 1 <= num_files <= Var.MAX_BATCH_FILES: await reply_user_err( - message, - MSG_ERROR_NUMBER_RANGE.format(max_files=Var.MAX_BATCH_FILES)) + message, MSG_ERROR_NUMBER_RANGE.format(max_files=Var.MAX_BATCH_FILES) + ) return except ValueError: await reply_user_err(message, MSG_ERROR_INVALID_NUMBER) return try: - status_msg = await message.reply_text(MSG_PROCESSING_REQUEST, quote=True) - except FloodWait as e: - await asyncio.sleep(e.value) - status_msg = await message.reply_text(MSG_PROCESSING_REQUEST, quote=True) - shortener_val = handler_kwargs.get('shortener', shortener_val) + status_msg = await reply_safe(message, MSG_PROCESSING_REQUEST) + except Exception as e: + logger.error(f"Could not send processing status: {e}", exc_info=True) + return + shortener_val = handler_kwargs.get("shortener", shortener_val) if num_files == 1: - await process_single(client, message, message.reply_to_message, status_msg, shortener_val, notification_msg=notification_msg) + await process_single( + client, + message, + message.reply_to_message, + status_msg, + shortener_val, + notification_msg=notification_msg, + ) else: - await process_batch(client, message, message.reply_to_message.id, num_files, status_msg, shortener_val, notification_msg=notification_msg) + await process_batch( + client, + message, + message.reply_to_message.id, + num_files, + status_msg, + shortener_val, + notification_msg=notification_msg, + ) await handle_rate_limited_request(bot, msg, _actual_link_handler, **kwargs) @StreamBot.on_message( - filters.private & - filters.incoming & - (filters.document | filters.video | filters.photo | filters.audio | - filters.voice | filters.animation | filters.video_note), - group=4 + filters.private + & filters.incoming + & ( + filters.document + | filters.video + | filters.photo + | filters.audio + | filters.voice + | filters.animation + | filters.video_note + ), + group=4, ) async def private_receive_handler(bot: Client, msg: Message, **kwargs): async def _actual_private_receive_handler(client: Client, message: Message, **handler_kwargs): @@ -294,54 +284,57 @@ async def _actual_private_receive_handler(client: Client, message: Message, **ha if not message.from_user: return - notification_msg = handler_kwargs.get('notification_msg') + notification_msg = handler_kwargs.get("notification_msg") await log_newusr(client, message.from_user.id, message.from_user.first_name or "") try: - status_msg = await message.reply_text(MSG_PROCESSING_FILE, quote=True) - except FloodWait as e: - await asyncio.sleep(e.value) - status_msg = await message.reply_text(MSG_PROCESSING_FILE, quote=True) - await process_single(client, message, message, status_msg, shortener_val, notification_msg=notification_msg) + status_msg = await reply_safe(message, MSG_PROCESSING_FILE) + except Exception as e: + logger.error(f"Could not send processing status: {e}", exc_info=True) + return + await process_single( + client, message, message, status_msg, shortener_val, notification_msg=notification_msg + ) await handle_rate_limited_request(bot, msg, _actual_private_receive_handler, **kwargs) @StreamBot.on_message( - filters.channel & - filters.incoming & - (filters.document | filters.video | filters.audio) & - ~filters.chat(Var.BIN_CHANNEL), - group=-1 + filters.channel + & filters.incoming + & (filters.document | filters.video | filters.audio) + & ~filters.chat(Var.BIN_CHANNEL), + group=-1, ) async def channel_receive_handler(bot: Client, msg: Message): async def _actual_channel_receive_handler(client: Client, message: Message, **handler_kwargs): if not Var.CHANNEL: return - notification_msg = handler_kwargs.get('notification_msg') + notification_msg = handler_kwargs.get("notification_msg") - is_banned_statically = hasattr(Var, 'BANNED_CHANNELS') and message.chat.id in Var.BANNED_CHANNELS + is_banned_statically = ( + hasattr(Var, "BANNED_CHANNELS") and message.chat.id in Var.BANNED_CHANNELS + ) is_banned_dynamically = await db.is_channel_banned(message.chat.id) is not None if is_banned_statically or is_banned_dynamically: try: - try: - await client.leave_chat(message.chat.id) - except FloodWait as e: - await asyncio.sleep(e.value) - await client.leave_chat(message.chat.id) + await tg_call(client.leave_chat, message.chat.id, retries=1) except Exception as e: logger.error(f"Error leaving banned channel {message.chat.id}: {e}") return if not await is_admin(client, message.chat.id): logger.debug( f"Bot is not admin in channel {message.chat.id} " - f"({message.chat.title or 'Unknown'}). Ignoring message.") + f"({message.chat.title or 'Unknown'}). Ignoring message." + ) return try: - shortener_val = await get_shortener_status(client, message) - canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(message, fwd_media) + shortener_val = await _shortener_status_for(client, message) + canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file( + message, fwd_media, client + ) if reused_existing and stored_msg: await safe_delete_message(stored_msg) stored_msg = None @@ -350,7 +343,7 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha file_name=canonical_record["file_name"], file_size=int(canonical_record.get("file_size", 0) or 0), public_hash=canonical_record["public_hash"], - shortener=shortener_val + shortener=shortener_val, ) reply_to_message_id = int(canonical_record["canonical_message_id"]) else: @@ -358,7 +351,8 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha stored_msg = await fwd_media(message) if not stored_msg: logger.error( - f"Failed to forward media from channel {message.chat.id}. Ignoring.") + f"Failed to forward media from channel {message.chat.id}. Ignoring." + ) return links = await gen_links(stored_msg, shortener=shortener_val) reply_to_message_id = stored_msg.id @@ -369,35 +363,26 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha if notification_msg: try: - try: - await notification_msg.edit_text( - MSG_NEW_FILE_REQUEST.format( - source_info=source_info, - id_=message.chat.id, - online_link=links['online_link'], - stream_link=links['stream_link'] - ), - disable_web_page_preview=True - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await notification_msg.edit_text( - MSG_NEW_FILE_REQUEST.format( - source_info=source_info, - id_=message.chat.id, - online_link=links['online_link'], - stream_link=links['stream_link'] - ), - disable_web_page_preview=True - ) + await edit_safe( + notification_msg, + MSG_NEW_FILE_REQUEST.format( + source_info=source_info, + id_=message.chat.id, + online_link=links["online_link"], + stream_link=links["stream_link"], + ), + disable_web_page_preview=True, + ) except Exception as e: - logger.error(f"Error editing notification message with links: {e}", exc_info=True) + logger.error( + f"Error editing notification message with links: {e}", exc_info=True + ) await send_channel_links( links, source_info, message.chat.id, target_msg=stored_msg, - reply_to_message_id=reply_to_message_id + reply_to_message_id=reply_to_message_id, ) else: await send_channel_links( @@ -405,36 +390,50 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha source_info, message.chat.id, target_msg=stored_msg, - reply_to_message_id=reply_to_message_id + reply_to_message_id=reply_to_message_id, ) try: - try: - await message.edit_reply_markup(reply_markup=get_link_buttons(links)) - except FloodWait as e: - await asyncio.sleep(e.value) - await message.edit_reply_markup(reply_markup=get_link_buttons(links)) + await tg_call(message.edit_reply_markup, reply_markup=get_link_buttons(links)) except (MessageNotModified, MessageDeleteForbidden, MessageIdInvalid): - logger.debug(f"Failed to edit reply markup for message {message.id} due to not modified, permissions or invalid ID. Sending new link instead.") + logger.debug( + f"Failed to edit reply markup for message {message.id} due to not modified, permissions or invalid ID. Sending new link instead." + ) await send_link(message, links) except Exception as e: - logger.error(f"Error editing reply markup for message {message.id}: {e}", exc_info=True) + logger.error( + f"Error editing reply markup for message {message.id}: {e}", exc_info=True + ) await send_link(message, links) except Exception as e: - logger.error(f"Error in _actual_channel_receive_handler for message {message.id}: {e}", exc_info=True) + logger.error( + f"Error in _actual_channel_receive_handler for message {message.id}: {e}", + exc_info=True, + ) rl_user_id = None if msg.sender_chat and msg.sender_chat.id: rl_user_id = msg.sender_chat.id elif msg.from_user: rl_user_id = msg.from_user.id - + if rl_user_id is None: - logger.debug(f"No identifiable user/channel for rate limiting for message {msg.id}. Skipping rate limit check and processing directly.") + logger.debug( + f"No identifiable user/channel for rate limiting for message {msg.id}. Skipping rate limit check and processing directly." + ) await _actual_channel_receive_handler(bot, msg) return - await handle_rate_limited_request(bot, msg, _actual_channel_receive_handler, rl_user_id=rl_user_id) + await handle_rate_limited_request( + bot, msg, _actual_channel_receive_handler, rl_user_id=rl_user_id + ) + + +async def _shortener_status_for(client: Client, message: Message) -> bool: + """Channel messages skip the user gates but still honor shortener config.""" + from Thunder.utils.decorators import get_shortener_status + + return await get_shortener_status(client, message) async def process_single( @@ -443,11 +442,13 @@ async def process_single( file_msg: Message, status_msg: Message, shortener_val: bool, - original_request_msg: Optional[Message] = None, - notification_msg: Optional[Message] = None + original_request_msg: Message | None = None, + notification_msg: Message | None = None, ): try: - canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(file_msg, fwd_media) + canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file( + file_msg, fwd_media, bot + ) if reused_existing and stored_msg: await safe_delete_message(stored_msg) stored_msg = None @@ -456,7 +457,7 @@ async def process_single( file_name=canonical_record["file_name"], file_size=int(canonical_record.get("file_size", 0) or 0), public_hash=canonical_record["public_hash"], - shortener=shortener_val + shortener=shortener_val, ) canonical_reply_id = int(canonical_record["canonical_message_id"]) else: @@ -470,15 +471,10 @@ async def process_single( if notification_msg: result = await safe_edit_message( notification_msg, - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ), - parse_mode=enums.ParseMode.MARKDOWN, + format_link_message(links), + parse_mode=enums.ParseMode.HTML, disable_web_page_preview=True, - reply_markup=get_link_buttons(links) + reply_markup=get_link_buttons(links), ) if not result: await send_link(msg, links) @@ -492,29 +488,23 @@ async def process_single( if source_msg.from_user: source_info = source_msg.from_user.full_name if not source_info: - source_info = f"@{source_msg.from_user.username}" if source_msg.from_user.username else "Unknown User" + source_info = ( + f"@{source_msg.from_user.username}" + if source_msg.from_user.username + else "Unknown User" + ) source_id = source_msg.from_user.id elif source_msg.chat.type == enums.ChatType.CHANNEL: source_info = source_msg.chat.title or "Unknown Channel" source_id = source_msg.chat.id if source_info and source_id: - try: - await send_channel_links( - links, - source_info, - source_id, - target_msg=stored_msg, - reply_to_message_id=canonical_reply_id - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await send_channel_links( - links, - source_info, - source_id, - target_msg=stored_msg, - reply_to_message_id=canonical_reply_id - ) + await send_channel_links( + links, + source_info, + source_id, + target_msg=stored_msg, + reply_to_message_id=canonical_reply_id, + ) if status_msg: await safe_delete_message(status_msg) return links @@ -522,11 +512,10 @@ async def process_single( logger.error(f"Error processing single file for message {file_msg.id}: {e}", exc_info=True) if status_msg: await safe_edit_message(status_msg, MSG_ERROR_PROCESSING_MEDIA) - - await notify_own(bot, MSG_CRITICAL_ERROR.format( - error=str(e), - error_id=secrets.token_hex(6) - )) + + await notify_own( + bot, MSG_CRITICAL_ERROR.format(error=str(e), error_id=secrets.token_hex(6)) + ) return None @@ -537,132 +526,155 @@ async def process_batch( count: int, status_msg: Message, shortener_val: bool, - notification_msg: Optional[Message] = None + notification_msg: Message | None = None, ): - processed = 0 - failed = 0 - links_list = [] - for batch_start in range(0, count, BATCH_SIZE): - batch_size = min(BATCH_SIZE, count - batch_start) - batch_ids = list(range(start_id + batch_start, start_id + batch_start + batch_size)) + """M4b: worker-pooled batch with order preservation. + + * messages are pre-fetched in chunks of ``BATCH_SIZE`` (same API usage + as before); processing then runs on ``BATCH_WORKERS`` workers; + * results are collected into an index-keyed dict so link order is + preserved regardless of completion order; + * non-media messages count as **skipped** (ThunderGo semantics), not + failed; + * the whole batch runs under a ``30 + 2n`` second deadline; + * progress edits are throttled to every 5 completions. + """ + total_started = time.time() + deadline = total_started + _BATCH_DEADLINE_BASE + 2 * count + worker_count = max(1, int(getattr(Var, "BATCH_WORKERS", 5))) + + ids: list[int] = list(range(start_id, start_id + count)) + results: dict[int, dict[str, Any] | None] = {} + skipped = 0 + counters = {"done": 0, "failed": 0} + + # ---- pre-fetch phase (chunked, same as the historical behavior) ---- + fetched: dict[int, Message | None] = {} + for chunk_start in range(0, count, BATCH_SIZE): + if time.time() > deadline: + break + chunk_ids = ids[chunk_start : chunk_start + BATCH_SIZE] try: - try: - await status_msg.edit_text( - MSG_PROCESSING_BATCH.format( - batch_number=(batch_start // BATCH_SIZE) + 1, - total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, - file_count=batch_size - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_BATCH.format( - batch_number=(batch_start // BATCH_SIZE) + 1, - total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, - file_count=batch_size - ) - ) - except MessageNotModified: - pass - try: - try: - messages = await bot.get_messages(msg.chat.id, batch_ids) - except FloodWait as e: - await asyncio.sleep(e.value) - messages = await bot.get_messages(msg.chat.id, batch_ids) - if messages is None: - messages = [] + messages = await tg_call(bot.get_messages, msg.chat.id, chunk_ids, retries=1) + messages = list(messages) if messages else [] except Exception as e: logger.error(f"Error getting messages in batch: {e}", exc_info=True) messages = [] - for m in messages: - if m and m.media: - links = await process_single(bot, msg, m, None, shortener_val, original_request_msg=msg) - if links: - links_list.append(links['online_link']) - processed += 1 - else: - failed += 1 - else: - failed += 1 - if (processed + failed) % BATCH_UPDATE_INTERVAL == 0 or (processed + failed) == count: + for mid, m in zip(chunk_ids, messages, strict=False): + fetched[mid] = m if (m is not None and getattr(m, "media", None)) else None + + queue: asyncio.Queue[int | None] = asyncio.Queue() + for mid in ids: + queue.put_nowait(mid) + for _ in range(worker_count): + queue.put_nowait(None) + + async def progress_edit(): + try: + await edit_safe( + status_msg, + MSG_PROCESSING_STATUS.format( + processed=counters["done"] - counters["failed"], + total=count, + failed=counters["failed"], + ), + ) + except MessageNotModified: + pass + except Exception: + pass + + async def worker(): + nonlocal skipped + while True: try: - try: - await status_msg.edit_text( - MSG_PROCESSING_STATUS.format( - processed=processed, - total=count, - failed=failed - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_STATUS.format( - processed=processed, - total=count, - failed=failed - ) + mid = queue.get_nowait() + except asyncio.QueueEmpty: + await asyncio.sleep(0.05) + continue + try: + if mid is None: + return + if time.time() > deadline: + results[mid] = None + skipped += 1 + counters["done"] += 1 + continue + m = fetched.get(mid) + if m is not None: + links = await process_single( + bot, msg, m, None, shortener_val, original_request_msg=msg ) - except MessageNotModified: - pass + results[mid] = links + if not links: + counters["failed"] += 1 + else: + results[mid] = None + skipped += 1 + counters["done"] += 1 + if counters["done"] % BATCH_UPDATE_INTERVAL == 0 and counters["done"] < count: + await progress_edit() + finally: + queue.task_done() + + # initial status + await edit_safe( + status_msg, + MSG_PROCESSING_BATCH.format( + batch_number=1, + total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, + file_count=count, + ), + ) + + workers = [asyncio.create_task(worker(), name=f"batch_worker_{i}") for i in range(worker_count)] + await asyncio.gather(*workers) + + failed = counters["failed"] + processed = sum(1 for r in results.values() if r) + + links_list = [results[mid]["online_link"] for mid in ids if results.get(mid)] for i in range(0, len(links_list), LINK_CHUNK_SIZE): - chunk = links_list[i:i+LINK_CHUNK_SIZE] - chunk_text = MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n{chr(10).join(chunk)}" + chunk = links_list[i : i + LINK_CHUNK_SIZE] + chunk_text = ( + MSG_BATCH_LINKS_READY.format(count=len(chunk)) + + f"\n\n{chr(10).join(chunk)}" + ) try: - await msg.reply_text( - chunk_text, - quote=True, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text( - chunk_text, - quote=True, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML + await reply_safe( + msg, chunk_text, disable_web_page_preview=True, parse_mode=enums.ParseMode.HTML ) + except Exception as e: + logger.error(f"Error sending batch chunk: {e}", exc_info=True) if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user: try: - try: - await bot.send_message( - chat_id=msg.from_user.id, - text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await bot.send_message( - chat_id=msg.from_user.id, - text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) + await send_safe( + bot, + msg.from_user.id, + text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + + "\n" + + chunk_text, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML, + ) except Exception as e: logger.error(f"Error sending DM in batch: {e}", exc_info=True) await reply_user_err(msg, MSG_ERROR_DM_FAILED) if i + LINK_CHUNK_SIZE < len(links_list): await asyncio.sleep(MESSAGE_DELAY) + try: - await status_msg.edit_text( + await edit_safe( + status_msg, MSG_PROCESSING_RESULT.format( processed=processed, total=count, - failed=failed - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_RESULT.format( - processed=processed, - total=count, - failed=failed - ) + failed=failed, + ), ) + except MessageNotModified: + pass + except Exception as e: + logger.debug(f"Could not finalize batch status: {e}") if notification_msg: await safe_delete_message(notification_msg) diff --git a/Thunder/bot/registry.py b/Thunder/bot/registry.py new file mode 100644 index 0000000..5d50055 --- /dev/null +++ b/Thunder/bot/registry.py @@ -0,0 +1,69 @@ +# Thunder/bot/registry.py + +"""Single command registry (plan M1). + +One table drives all three surfaces: the Telegram command menu (owner-only +commands hidden, descriptions auto-truncated to Telegram's 256-char limit), +the /help command section, and the command table in AGENTS.md (a unit test +fails if AGENTS.md drifts from this registry). +""" + +from typing import NamedTuple + +from pyrogram.types import BotCommand + +from Thunder.utils.messages import MSG_HELP_COMMAND_ROW + + +class Command(NamedTuple): + name: str + description: str + owner_only: bool = False + hidden: bool = False # not listed anywhere (none today) + + +COMMANDS: list[Command] = [ + Command("start", "Start the bot and get a welcome message"), + Command("help", "Show help and usage instructions"), + Command("link", "(Group) Generate a direct link for a file or batch"), + Command("dc", "Retrieve the data center (DC) information of a user or file"), + Command("ping", "Check the bot's status and response time"), + Command("about", "Get information about the bot"), + Command("users", "Show the total number of users", owner_only=True), + Command("status", "View bot details and current workload", owner_only=True), + Command("stats", "View usage statistics and resource consumption", owner_only=True), + Command("broadcast", "Send a message to all users", owner_only=True), + Command("ban", "Ban a user", owner_only=True), + Command("unban", "Unban a user", owner_only=True), + Command("log", "Send bot logs", owner_only=True), + Command("restart", "Update and restart the bot", owner_only=True), + Command("shell", "Execute a shell command (requires ENABLE_SHELL)", owner_only=True), + Command("authorize", "Grant permanent access to a user", owner_only=True), + Command("deauthorize", "Remove permanent access from a user", owner_only=True), + Command("listauth", "List all authorized users", owner_only=True), +] + +# Telegram's set_bot_commands description limit +_MAX_DESC_LEN = 256 + + +def bot_commands() -> list[BotCommand]: + """Menu surface: owner-only commands are hidden (M1 fix).""" + return [ + BotCommand(cmd.name, cmd.description[:_MAX_DESC_LEN]) + for cmd in COMMANDS + if not cmd.owner_only and not cmd.hidden + ] + + +def help_command_rows() -> str: + """/help surface: same public commands, same order.""" + rows = "" + for cmd in COMMANDS: + if cmd.owner_only or cmd.hidden: + continue + rows += MSG_HELP_COMMAND_ROW.format(name=cmd.name, description=cmd.description) + return rows + + +__all__ = ["Command", "COMMANDS", "bot_commands", "help_command_rows"] diff --git a/Thunder/logs/bot.txt b/Thunder/logs/bot.txt new file mode 100644 index 0000000..390f7a7 --- /dev/null +++ b/Thunder/logs/bot.txt @@ -0,0 +1,56 @@ +2026-09-06 10:14:29,282 - ThunderBot - INFO - Gate mode: public; legacy links: on +2026-09-06 10:15:02,579 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: +2026-09-06 10:15:02,579 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer +2026-09-06 10:15:02,579 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) +2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: +2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer +2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) +2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer +2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) +2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer +2026-09-06 10:15:38,814 - ThunderBot - INFO - Gate mode: public; legacy links: on +2026-09-06 10:16:56,293 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: +2026-09-06 10:16:56,293 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer +2026-09-06 10:16:56,293 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) +2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: +2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer +2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) +2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer +2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) +2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer +2026-09-06 10:18:46,850 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: +2026-09-06 10:18:46,850 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer +2026-09-06 10:18:46,850 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) +2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: +2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer +2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) +2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer +2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) +2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer +2026-09-06 10:20:07,765 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: +2026-09-06 10:20:07,765 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer +2026-09-06 10:20:07,765 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) +2026-09-06 10:20:07,834 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: +2026-09-06 10:20:07,834 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer +2026-09-06 10:20:07,834 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) +2026-09-06 10:20:07,835 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer +2026-09-06 10:20:07,835 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) +2026-09-06 10:20:07,835 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer +2026-09-06 10:22:36,612 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: +2026-09-06 10:22:36,612 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer +2026-09-06 10:22:36,612 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) +2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: +2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer +2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) +2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer +2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) +2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer +2026-09-06 10:23:21,576 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: +2026-09-06 10:23:21,576 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer +2026-09-06 10:23:21,576 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) +2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: +2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer +2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) +2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer +2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) +2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py index 7a31faa..b8c9e58 100755 --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -1,10 +1,68 @@ # Thunder/server/__init__.py +import time + from aiohttp import web + from .stream_routes import routes +# H10: access log middleware -- logs method, redacted path (file tokens are +# replaced by their sha256 prefix), status, bytes and duration. +# Modeled on ThunderGo's http/server.go logMiddleware + redactPath. +_REDACT_SEGMENTS = ("f/", "watch/") + + +def _redact_path(path: str) -> str: + import re + + # canonical: /f/<32-hex>/ or /watch/f/<32-hex>/ + path = re.sub( + r"(?<=/f/)[0-9a-f]{20,32}", + lambda m: _hash_token(m.group(0)), + path, + ) + # legacy: /watch/<6-char-hash>/ -> hash part + path = re.sub( + r"(?<=/watch/)[a-zA-Z0-9_-]{6}\d+", + lambda m: _hash_token(m.group(0)[: -len(m.group(0))]) + "…", + path, + ) + return path + + +def _hash_token(token: str) -> str: + from Thunder.utils.logger import hash_path_token + + return hash_path_token(token) + + +@web.middleware +async def access_log_middleware(request: web.Request, handler): + start = time.perf_counter() + try: + response = await handler(request) + except web.HTTPException as e: + response = e + raise + finally: + duration_ms = (time.perf_counter() - start) * 1000 + try: + from Thunder.utils.logger import logger + + status = getattr(response, "status", 500) + size = getattr(response, "content_length", None) + logger.info( + f'{request.remote} "{request.method} {_redact_path(request.path)}" ' + f"{status} {size if size is not None else '-'} {duration_ms:.1f}ms" + ) + except Exception: + pass + return response + async def web_server(): - web_app = web.Application(client_max_size=50 * 1024 * 1024) + # client_max_size removed (H4b): this is a GET-only server; the old 50 MiB + # cap only governed request bodies that can never legitimately arrive. + web_app = web.Application(middlewares=[access_log_middleware]) web_app.add_routes(routes) return web_app diff --git a/Thunder/server/exceptions.py b/Thunder/server/exceptions.py index da8095d..1f0d9e9 100755 --- a/Thunder/server/exceptions.py +++ b/Thunder/server/exceptions.py @@ -1,7 +1,9 @@ # Thunder/server/exceptions.py + class InvalidHash(Exception): pass + class FileNotFound(Exception): pass diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index febfd11..551297d 100755 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -7,13 +7,16 @@ from aiohttp import web -from Thunder import __version__, StartTime +from Thunder import StartTime, __version__ from Thunder.bot import StreamBot, multi_clients, work_loads from Thunder.server.exceptions import FileNotFound, InvalidHash from Thunder.utils.bot_utils import quote_media_name from Thunder.utils.canonical_files import ( + LEGACY_PUBLIC_HASH_LENGTH, PUBLIC_HASH_LENGTH, + forget_stale_record, get_file_by_hash, + touch_buffer_stats, update_cached_file_id, ) from Thunder.utils.custom_dl import ByteStreamer @@ -25,17 +28,23 @@ routes = web.RouteTableDef() +# legacy 6-char capability hash family (L1: kept while ENABLE_LEGACY_LINKS=on) SECURE_HASH_LENGTH = 6 CHUNK_SIZE = 1024 * 1024 -MAX_CONCURRENT_PER_CLIENT = 8 +# M9: per-client admission cap (env-tunable; was a hardcoded constant) +MAX_CONCURRENT_PER_CLIENT = max(1, int(getattr(Var, "MAX_CONCURRENT_STREAMS", 8))) OVERLOAD_RETRY_AFTER_SECONDS = 2 RANGE_REGEX = re.compile(r"^bytes=(?P\d*)-(?P\d*)$") -PATTERN_HASH_FIRST = re.compile( - rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$") +PATTERN_HASH_FIRST = re.compile(rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$") PATTERN_ID_FIRST = re.compile(r"^(\d+)(?:/.*)?$") -VALID_HASH_REGEX = re.compile(r'^[a-zA-Z0-9_-]+$') -VALID_PUBLIC_HASH_REGEX = re.compile(rf'^[0-9a-f]{{{PUBLIC_HASH_LENGTH}}}$') +VALID_HASH_REGEX = re.compile(r"^[a-zA-Z0-9_-]+$") +# L4: both hash families validate side-by-side forever (20 = legacy links, +# 32 = new ingestions), so existing links never break. +VALID_PUBLIC_HASH_REGEX = re.compile( + rf"^[0-9a-f]{{{LEGACY_PUBLIC_HASH_LENGTH}}}$|^[0-9a-f]{{{PUBLIC_HASH_LENGTH}}}$" +) VALID_DISPOSITIONS = {"inline", "attachment"} +_ASCII_FALLBACK_RE = re.compile(r"[^A-Za-z0-9._ -]") CORS_HEADERS = { "Access-Control-Allow-Origin": "*", @@ -54,15 +63,14 @@ def get_streamer(client_id: int) -> ByteStreamer: def parse_media_request(path: str, query: dict) -> tuple[int, str]: - clean_path = unquote(path).strip('/') + clean_path = unquote(path).strip("/") match = PATTERN_HASH_FIRST.match(clean_path) if match: try: message_id = int(match.group(2)) secure_hash = match.group(1) - if (len(secure_hash) == SECURE_HASH_LENGTH and - VALID_HASH_REGEX.match(secure_hash)): + if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash): return message_id, secure_hash except ValueError as e: raise InvalidHash(f"Invalid message ID format in path: {e}") from e @@ -72,8 +80,7 @@ def parse_media_request(path: str, query: dict) -> tuple[int, str]: try: message_id = int(match.group(1)) secure_hash = query.get("hash", "").strip() - if (len(secure_hash) == SECURE_HASH_LENGTH and - VALID_HASH_REGEX.match(secure_hash)): + if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash): return message_id, secure_hash else: raise InvalidHash("Invalid or missing hash in query parameter") @@ -85,7 +92,7 @@ def parse_media_request(path: str, query: dict) -> tuple[int, str]: def validate_public_hash(public_hash: str) -> str: secure_hash = public_hash.strip().lower() - if len(secure_hash) != PUBLIC_HASH_LENGTH or not VALID_PUBLIC_HASH_REGEX.match(secure_hash): + if not VALID_PUBLIC_HASH_REGEX.match(secure_hash): raise InvalidHash("Invalid canonical file hash") return secure_hash @@ -93,16 +100,17 @@ def validate_public_hash(public_hash: str) -> str: def select_optimal_client() -> tuple[int, ByteStreamer]: if not work_loads: raise web.HTTPInternalServerError( - text=("No available clients to handle the request. " - "Please try again later."), + text=("No available clients to handle the request. Please try again later."), headers=CORS_HEADERS, ) available_clients = [ - (cid, load) for cid, load in work_loads.items() - if load < MAX_CONCURRENT_PER_CLIENT] + (cid, load) for cid, load in work_loads.items() if load < MAX_CONCURRENT_PER_CLIENT + ] if not available_clients: + # M9 admission control: refuse instead of stacking unlimited + # handlers on one client; always advertise Retry-After. loads = list(work_loads.values()) load_range = f"~{min(loads)}–{max(loads)}" if min(loads) != max(loads) else f"~{min(loads)}" raise web.HTTPServiceUnavailable( @@ -125,6 +133,13 @@ def get_content_disposition(request: web.Request) -> str: return disposition if disposition in VALID_DISPOSITIONS else "attachment" +def build_content_disposition(disposition: str, filename: str) -> str: + """L6: RFC 5987 ``filename*`` plus an ASCII fallback so non-Latin names + survive on clients that ignore RFC 5987.""" + ascii_name = _ASCII_FALLBACK_RE.sub("_", filename).strip() or "file" + return f"{disposition}; filename=\"{ascii_name}\"; filename*=UTF-8''{quote(filename, safe='')}" + + def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: if not range_header: return 0, file_size - 1 @@ -144,14 +159,14 @@ def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: suffix_len = int(end_str) if suffix_len <= 0: raise web.HTTPRequestRangeNotSatisfiable( - headers={"Content-Range": f"bytes */{file_size}"}) + headers={"Content-Range": f"bytes */{file_size}"} + ) start = max(file_size - suffix_len, 0) end = file_size - 1 if start < 0 or end >= file_size or start > end: - raise web.HTTPRequestRangeNotSatisfiable( - headers={"Content-Range": f"bytes */{file_size}"} - ) + # L6: 416 discipline with Content-Range + raise web.HTTPRequestRangeNotSatisfiable(headers={"Content-Range": f"bytes */{file_size}"}) return start, end @@ -168,8 +183,8 @@ def _resolve_filename(file_info: dict, mime_type: str) -> str: if filename: return filename - ext = mime_type.split('/')[-1] if '/' in mime_type else 'bin' - ext_map = {'jpeg': 'jpg', 'mpeg': 'mp3', 'octet-stream': 'bin'} + ext = mime_type.split("/")[-1] if "/" in mime_type else "bin" + ext_map = {"jpeg": "jpg", "mpeg": "mp3", "octet-stream": "bin"} ext = ext_map.get(ext, ext) return f"file_{secrets.token_hex(4)}.{ext}" @@ -180,11 +195,9 @@ async def _serve_media_response( file_info: dict, streamer: ByteStreamer, client_id: int, - media_ref: int | str, - fallback_message_id: int | None = None, - on_fallback_message=None + media_ref: int | object, ): - file_size = int(file_info.get('file_size', 0) or 0) + file_size = int(file_info.get("file_size", 0) or 0) if file_size == 0: raise FileNotFound("File size is reported as zero or unavailable.") @@ -195,34 +208,29 @@ async def _serve_media_response( if start == 0 and end == file_size - 1: range_header = "" - mime_type = file_info.get('mime_type') or 'application/octet-stream' + mime_type = file_info.get("mime_type") or "application/octet-stream" filename = _resolve_filename(file_info, mime_type) disposition = get_content_disposition(request) headers = { "Content-Type": mime_type, "Content-Length": str(content_length), - "Content-Disposition": ( - f"{disposition}; filename*=UTF-8''{quote(filename, safe='')}"), + "Content-Disposition": build_content_disposition(disposition, filename), "Accept-Ranges": "bytes", "Cache-Control": "public, max-age=31536000", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Range, Content-Type, *", - "Access-Control-Expose-Headers": ( - "Content-Length, Content-Range, Content-Disposition"), - "X-Content-Type-Options": "nosniff" + "Access-Control-Expose-Headers": ("Content-Length, Content-Range, Content-Disposition"), + "X-Content-Type-Options": "nosniff", } if range_header: headers["Content-Range"] = f"bytes {start}-{end}/{file_size}" - if request.method == 'HEAD': + if request.method == "HEAD": work_loads[client_id] -= 1 - return web.Response( - status=206 if range_header else 200, - headers=headers - ) + return web.Response(status=206 if range_header else 200, headers=headers) async def stream_generator(): try: @@ -233,8 +241,6 @@ async def stream_generator(): media_ref, offset=start, limit=content_length, - fallback_message_id=fallback_message_id, - on_fallback_message=on_fallback_message ): if bytes_to_skip > 0: if len(chunk) <= bytes_to_skip: @@ -257,9 +263,7 @@ async def stream_generator(): work_loads[client_id] -= 1 return web.Response( - status=206 if range_header else 200, - body=stream_generator(), - headers=headers + status=206 if range_header else 200, body=stream_generator(), headers=headers ) @@ -268,47 +272,70 @@ async def root_redirect(request): raise web.HTTPFound("https://github.com/fyaz05/FileToLink") +@routes.get("/health", allow_head=True) +async def health_endpoint(request): + """M3: zero-dependency liveness endpoint (keepalive now targets this).""" + return web.json_response( + {"status": "ok"}, + headers={"Cache-Control": "no-store"}, + ) + + +@routes.get("/activate/{token}") +async def activate_endpoint(request: web.Request): + """M8: web entry for activation -- shorteners can produce real URLs.""" + token = request.match_info.get("token", "").strip() + username = getattr(StreamBot, "username", None) + if not token: + raise web.HTTPBadRequest(text="Missing activation token") + if not username: + raise web.HTTPServiceUnavailable(text="Bot is still starting; try again shortly.") + raise web.HTTPFound(f"https://t.me/{username}?start={token}") + + @routes.get("/status", allow_head=True) async def status_endpoint(request): uptime = time.time() - StartTime total_load = sum(work_loads.values()) - workload_distribution = {str(k): v for k, v in sorted(work_loads.items())} + dc_id = getattr(getattr(StreamBot, "session", None), "dc_id", None) + return web.json_response( { "server": { "status": "operational", "version": __version__, - "uptime": get_readable_time(uptime) + "uptime": get_readable_time(uptime), }, "telegram_bot": { "username": f"@{StreamBot.username}", - "active_clients": len(multi_clients) + "active_clients": len(multi_clients), + "dc_id": dc_id, }, "resources": { "total_workload": total_load, - "workload_distribution": workload_distribution - } + "inflight": total_load, + "workload_distribution": workload_distribution, + "touch_buffer": touch_buffer_stats(), + }, + }, + headers={ + "Access-Control-Allow-Origin": "*", + # L3: status is dynamic -- never serve it from cache + "Cache-Control": "no-store", }, - headers={"Access-Control-Allow-Origin": "*"} ) @routes.options("/status") async def status_options(request: web.Request): - return web.Response(headers={ - **CORS_HEADERS, - "Access-Control-Max-Age": "86400" - }) + return web.Response(headers={**CORS_HEADERS, "Access-Control-Max-Age": "86400"}) @routes.options(r"/{path:.+}") async def media_options(request: web.Request): - return web.Response(headers={ - **CORS_HEADERS, - "Access-Control-Max-Age": "86400" - }) + return web.Response(headers={**CORS_HEADERS, "Access-Control-Max-Age": "86400"}) @routes.get(r"/watch/f/{secure_hash}/{name:.+}", allow_head=True) @@ -321,16 +348,23 @@ async def canonical_media_preview(request: web.Request): file_name = file_record.get("file_name") or f"file_{secure_hash}" src = f"{Var.URL.rstrip('/')}/f/{secure_hash}/{quote_media_name(file_name)}" - rendered_page = await render_media_page(file_name, src, requested_action='stream') + rendered_page = await render_media_page( + file_name, + src, + requested_action="stream", + mime_type=file_record.get("mime_type"), + ) response = web.Response( text=rendered_page, - content_type='text/html', + content_type="text/html", headers={ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Range, Content-Type, *", "X-Content-Type-Options": "nosniff", - } + # M2: player pages are per-file dynamic; keep them unindexed + "X-Robots-Tag": "noindex, nofollow", + }, ) response.enable_compression() return response @@ -340,41 +374,43 @@ async def canonical_media_preview(request: web.Request): except Exception as e: error_id = secrets.token_hex(6) logger.error(f"Canonical preview error {error_id}: {e}", exc_info=True) - raise web.HTTPInternalServerError( - text=f"Server error occurred: {error_id}") from e + raise web.HTTPInternalServerError(text=f"Server error occurred: {error_id}") from e @routes.get(r"/watch/{path:.+}", allow_head=True) async def media_preview(request: web.Request): + # L1: the legacy URL family can be switched off explicitly. + if not Var.ENABLE_LEGACY_LINKS: + raise web.HTTPGone( + text="Legacy links are disabled on this server. " + "Please re-send the file to the bot to get a fresh link." + ) try: path = request.match_info["path"] message_id, secure_hash = parse_media_request(path, request.query) - rendered_page = await render_page( - message_id, secure_hash, requested_action='stream') + rendered_page = await render_page(message_id, secure_hash, requested_action="stream") response = web.Response( text=rendered_page, - content_type='text/html', + content_type="text/html", headers={ "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "Range, Content-Type, *", "X-Content-Type-Options": "nosniff", - } + "X-Robots-Tag": "noindex, nofollow", + }, ) response.enable_compression() return response except (InvalidHash, FileNotFound) as e: - logger.debug( - f"Client error in preview: {type(e).__name__} - {e}", - exc_info=True) + logger.debug(f"Client error in preview: {type(e).__name__} - {e}", exc_info=True) raise web.HTTPNotFound(text="Resource not found") from e except Exception as e: error_id = secrets.token_hex(6) logger.error(f"Preview error {error_id}: {e}", exc_info=True) - raise web.HTTPInternalServerError( - text=f"Server error occurred: {error_id}") from e + raise web.HTTPInternalServerError(text=f"Server error occurred: {error_id}") from e @routes.get(r"/f/{secure_hash}/{name:.+}", allow_head=True) @@ -391,30 +427,54 @@ async def canonical_media_delivery(request: web.Request): try: _resolve_unique_id(file_record) media_ref = int(file_record["canonical_message_id"]) - fallback_message_id = int(file_record["canonical_message_id"]) - - async def persist_refreshed_file_id(message): - if client_id != 0: - return - media = get_media(message) - new_file_id = getattr(media, "file_id", None) if media else None - if new_file_id and new_file_id != file_record.get("file_id"): - try: - await update_cached_file_id(file_record, new_file_id) - except Exception as e: - logger.warning( - f"Failed to refresh cached file_id for canonical file {secure_hash}: {e}", - exc_info=True - ) + + # M10: resolve the vault message up-front. One fetch serves + # both the self-heal check and the Content-Length verification; + # the Message object is passed on so stream_file does not + # re-fetch it. + try: + vault_message = await streamer.get_message(media_ref) + except FileNotFound: + await forget_stale_record(file_record) + raise FileNotFound( + "Vault message missing; record self-healed, re-upload to regenerate the link" + ) from None + + media = get_media(vault_message) + if not media: + await forget_stale_record(file_record) + raise FileNotFound("Vault message has no media; record self-healed") + + serve_info = dict(file_record) + actual_size = int(getattr(media, "file_size", 0) or 0) + if actual_size and actual_size != int(serve_info.get("file_size", 0) or 0): + logger.warning( + f"Record size {serve_info.get('file_size')} != vault size {actual_size} " + f"for {secure_hash}; serving verified length" + ) + if actual_size: + # never tell clients a Content-Length the upstream cannot deliver + serve_info["file_size"] = actual_size + serve_info.setdefault("mime_type", None) + if not serve_info.get("mime_type"): + serve_info["mime_type"] = getattr(media, "mime_type", None) + + new_file_id = getattr(media, "file_id", None) + if new_file_id and new_file_id != file_record.get("file_id") and client_id == 0: + try: + await update_cached_file_id(file_record, new_file_id) + except Exception as e: + logger.warning( + f"Failed to refresh cached file_id for canonical file {secure_hash}: {e}", + exc_info=True, + ) return await _serve_media_response( request, - file_info=file_record, + file_info=serve_info, streamer=streamer, client_id=client_id, - media_ref=media_ref, - fallback_message_id=fallback_message_id, - on_fallback_message=persist_refreshed_file_id + media_ref=vault_message, ) except (FileNotFound, InvalidHash): work_loads[client_id] -= 1 @@ -428,7 +488,8 @@ async def persist_refreshed_file_id(message): error_id = secrets.token_hex(6) logger.error(f"Canonical stream error {error_id}: {e}", exc_info=True) raise web.HTTPInternalServerError( - text=f"Server error during streaming: {error_id}") from e + text=f"Server error during streaming: {error_id}" + ) from e except (InvalidHash, FileNotFound) as e: logger.debug(f"Canonical client error: {type(e).__name__} - {e}", exc_info=True) raise web.HTTPNotFound(text="Resource not found") from e @@ -439,11 +500,18 @@ async def persist_refreshed_file_id(message): error_id = secrets.token_hex(6) logger.error(f"Canonical server error {error_id}: {e}", exc_info=True) raise web.HTTPInternalServerError( - text=f"An unexpected server error occurred: {error_id}") from e + text=f"An unexpected server error occurred: {error_id}" + ) from e @routes.get(r"/{path:.+}", allow_head=True) async def media_delivery(request: web.Request): + # L1: legacy delivery route honors the same switch + if not Var.ENABLE_LEGACY_LINKS: + raise web.HTTPGone( + text="Legacy links are disabled on this server. " + "Please re-send the file to the bot to get a fresh link." + ) try: path = request.match_info["path"] message_id, secure_hash = parse_media_request(path, request.query) @@ -457,14 +525,13 @@ async def media_delivery(request: web.Request): unique_id = _resolve_unique_id(file_info) if unique_id[:SECURE_HASH_LENGTH] != secure_hash: - raise InvalidHash( - "Provided hash does not match file's unique ID.") + raise InvalidHash("Provided hash does not match file's unique ID.") return await _serve_media_response( request, file_info=file_info, streamer=streamer, client_id=client_id, - media_ref=message_id + media_ref=message_id, ) except (FileNotFound, InvalidHash): @@ -477,11 +544,10 @@ async def media_delivery(request: web.Request): except Exception as e: work_loads[client_id] -= 1 error_id = secrets.token_hex(6) - logger.error( - f"Stream error {error_id}: {e}", - exc_info=True) + logger.error(f"Stream error {error_id}: {e}", exc_info=True) raise web.HTTPInternalServerError( - text=f"Server error during streaming: {error_id}") from e + text=f"Server error during streaming: {error_id}" + ) from e except (InvalidHash, FileNotFound) as e: logger.debug(f"Client error: {type(e).__name__} - {e}", exc_info=True) @@ -493,4 +559,5 @@ async def media_delivery(request: web.Request): error_id = secrets.token_hex(6) logger.error(f"Server error {error_id}: {e}", exc_info=True) raise web.HTTPInternalServerError( - text=f"An unexpected server error occurred: {error_id}") from e + text=f"An unexpected server error occurred: {error_id}" + ) from e diff --git a/Thunder/template/req.html b/Thunder/template/req.html index d18cd64..34ad684 100755 --- a/Thunder/template/req.html +++ b/Thunder/template/req.html @@ -8,6 +8,9 @@ + + + @@ -27,10 +30,20 @@ - + + + + + + {% if kind == 'video' %} + {% elif kind == 'audio' %} + + + + {% endif %} @@ -47,9 +60,34 @@ + + + + + {% if kind == 'video' %} + {% elif kind == 'audio' %} + + + + + {% elif kind == 'image' %} +
+ {{ file_name }} +
+ {% else %} + +
+ πŸ“¦ + Preview not available + {{ mime_type }} β€” use the download button below. +
+ {% endif %} @@ -349,11 +406,24 @@

{{ file_name }}

+ {% if kind in ('video', 'audio') %} + {% endif %} + + diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py index 4403f03..a18956f 100755 --- a/Thunder/utils/bot_utils.py +++ b/Thunder/utils/bot_utils.py @@ -1,21 +1,27 @@ # Thunder/utils/bot_utils.py import asyncio -from typing import Any, Dict, Optional +from typing import Any 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 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.messages import ( + MSG_BUTTON_GET_HELP, + MSG_DC_UNKNOWN, + MSG_DC_USER_INFO, + MSG_FILE_EXPIRY_NOTE, + MSG_FILE_TTL_DAYS_LABEL, + MSG_LINKS, + MSG_NEW_USER, +) +from Thunder.utils.safe_call import reply_safe, send_safe, tg_call from Thunder.utils.shortener import shorten from Thunder.vars import Var @@ -24,14 +30,29 @@ def quote_media_name(file_name: str) -> str: return quote(str(file_name).replace("/", "_"), safe="") +def format_link_message(links: dict[str, str]) -> str: + """Render the MSG_LINKS template, appending the TTL expiry note (L2).""" + text = MSG_LINKS.format( + file_name=links["media_name"], + file_size=links["media_size"], + download_link=links["online_link"], + stream_link=links["stream_link"], + ) + if getattr(Var, "FILE_TTL_DAYS", 0) > 0: + text += "\n\n" + MSG_FILE_EXPIRY_NOTE.format( + days=MSG_FILE_TTL_DAYS_LABEL.format(days=Var.FILE_TTL_DAYS) + ) + return text + + async def _build_links( *, download_path: str, stream_path: str, media_name: str, media_size: str, - shortener: bool = True -) -> Dict[str, str]: + shortener: bool = True, +) -> dict[str, str]: base_url = Var.URL.rstrip("/") slink = f"{base_url}{stream_path}" olink = f"{base_url}{download_path}" @@ -50,16 +71,17 @@ async def _build_links( except Exception as e: logger.error(f"Error during link shortening: {e}") - return {"stream_link": slink, "online_link": olink, "media_name": media_name, "media_size": media_size} + return { + "stream_link": slink, + "online_link": olink, + "media_name": media_name, + "media_size": media_size, + } async def gen_canonical_links( - *, - file_name: str, - file_size: int, - public_hash: str, - shortener: bool = True -) -> Dict[str, str]: + *, file_name: str, file_size: int, public_hash: str, shortener: bool = True +) -> dict[str, str]: media_name = str(file_name) media_size = humanbytes(file_size) encoded_name = quote_media_name(media_name) @@ -68,51 +90,37 @@ async def gen_canonical_links( stream_path=f"/watch/f/{public_hash}/{encoded_name}", media_name=media_name, media_size=media_size, - shortener=shortener + shortener=shortener, ) - -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) - + await send_safe(cli, chat_id, text=txt) + except Exception as e: + logger.warning(f"Could not notify chat {chat_id}: {e}") + 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: + if 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 + await reply_safe( + msg, + err_txt, + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]] + ), + disable_web_page_preview=True, ) + except Exception as e: + logger.error(f"Error sending user error reply: {e}", exc_info=True) async def log_newusr(cli: Client, uid: int, fname: str): @@ -120,20 +128,25 @@ async def log_newusr(cli: Client, uid: int, fname: str): 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: + if 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)) + await send_safe( + cli, Var.BIN_CHANNEL, text=MSG_NEW_USER.format(first_name=fname, user_id=uid) + ) + except Exception as e: + logger.warning(f"Could not log new user {uid}: {e}") 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]: +async def gen_links(fwd_msg: Message, shortener: bool = True) -> dict[str, str]: fid = fwd_msg.id m_name_raw = get_fname(fwd_msg) - m_name = m_name_raw.decode('utf-8', errors='replace') if isinstance(m_name_raw, bytes) else str(m_name_raw) + m_name = ( + m_name_raw.decode("utf-8", errors="replace") + if isinstance(m_name_raw, bytes) + else str(m_name_raw) + ) m_size_hr = humanbytes(get_fsize(fwd_msg)) enc_fname = quote_media_name(m_name) f_hash = get_hash(fwd_msg) @@ -142,47 +155,38 @@ async def gen_links(fwd_msg: Message, shortener: bool = True) -> Dict[str, str]: stream_path=f"/watch/{f_hash}{fid}/{enc_fname}", media_name=m_name, media_size=m_size_hr, - shortener=shortener + 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) + 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): +async def get_user(cli: Client, qry: Any) -> User | None: + if isinstance(qry, str) and qry.startswith("@"): try: - return await cli.get_users(qry) - except FloodWait as e: - await asyncio.sleep(e.value) - return await cli.get_users(qry) + return await tg_call(cli.get_users, qry) + except Exception as e: + logger.debug(f"get_users failed for {qry}: {e}") + return None + if isinstance(qry, str) and qry.isdigit(): + qry = int(qry) + if isinstance(qry, int): + try: + return await tg_call(cli.get_users, qry) + except Exception as e: + logger.debug(f"get_users failed for {qry}: {e}") + return None 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 + member = await tg_call(cli.get_chat_member, chat_id_val, cli.me.id, retries=1) except Exception: return False if member is None: @@ -191,8 +195,21 @@ async def is_admin(cli: Client, chat_id_val: int) -> bool: 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) + kwargs.setdefault("disable_web_page_preview", True) + return await reply_safe(msg, kwargs.pop("text", ""), **kwargs) + + +__all__ = [ + "quote_media_name", + "format_link_message", + "gen_canonical_links", + "notify_own", + "reply_user_err", + "log_newusr", + "gen_links", + "gen_dc_txt", + "get_user", + "is_admin", + "reply", + "MSG_BUTTON_GET_HELP", +] diff --git a/Thunder/utils/broadcast.py b/Thunder/utils/broadcast.py index c15cfc8..505cc53 100755 --- a/Thunder/utils/broadcast.py +++ b/Thunder/utils/broadcast.py @@ -6,31 +6,50 @@ 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 pyrogram.errors import ( + ChannelInvalid, + ChatWriteForbidden, + FloodWait, + InputUserDeactivated, + PeerIdInvalid, + UserDeactivated, + UserIsBlocked, +) +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_COMPLETE, MSG_BROADCAST_START, MSG_BUTTON_CANCEL_BROADCAST, - MSG_BROADCAST_COMPLETE + MSG_INVALID_BROADCAST_CMD, ) +from Thunder.utils.safe_call import reply_safe, tg_call from Thunder.utils.time_format import get_readable_time - +from Thunder.vars import Var broadcast_ids = {} +# Errors that mean the recipient will never be reachable again. +_PERMANENT_ERRORS = ( + UserDeactivated, + UserIsBlocked, + PeerIdInvalid, + ChatWriteForbidden, + ChannelInvalid, + InputUserDeactivated, +) + +# pacing between sends per worker + progress-edit cadence (M4a) +_BROADCAST_PACE_SECONDS = 0.2 +_PROGRESS_EVERY = 25 + + 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) + await reply_safe(message, MSG_INVALID_BROADCAST_CMD) except Exception as e: logger.error(f"Error sending invalid broadcast message: {e}", exc_info=True) return @@ -40,19 +59,18 @@ async def broadcast_message(client: Client, message: Message, mode: str = "all") broadcast_ids[broadcast_id] = stats try: - status_msg = await message.reply_text( + status_msg = await tg_call( + 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}") - ]]) + 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) @@ -74,12 +92,14 @@ async def broadcast_message(client: Client, message: Message, mode: str = "all") 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}'.") + 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}`") @@ -89,75 +109,54 @@ async def broadcast_message(client: Client, message: Message, mode: str = "all") 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 + # M4a: worker pool pulling from a bounded queue; the Mongo cursor is + # streamed (never materialized), sends are paced, progress edits are + # throttled, and the cancel callback keeps working. + queue: asyncio.Queue = asyncio.Queue(maxsize=200) + async def producer(): try: - success = False - for attempt in range(3): - try: - await message.reply_to_message.copy(user_id) - stats["success"] += 1 - success = True + async for user in cursor: + if stats["cancelled"]: 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 - + await queue.put(user) except Exception as e: - logger.error(f"Error copying message to user {user_id}: {e}", exc_info=True) - stats["failed"] += 1 + logger.error(f"Broadcast cursor error: {e}", exc_info=True) + finally: + for _ in range(worker_count): + await queue.put(None) # poison pills + + async def worker(): + while True: + user = await queue.get() + try: + if user is None: + return + if stats["cancelled"]: + continue + user_id = user.get("id") or user.get("user_id") + if not user_id: + logger.warning(f"Skipping user with no ID: {user}") + continue + await _send_one(client, message, user_id, stats) + if stats["success"] and stats["success"] % _PROGRESS_EVERY == 0: + await _edit_progress(status_msg, stats) + finally: + queue.task_done() + if user is not None: + await asyncio.sleep(_BROADCAST_PACE_SECONDS) + + worker_count = max(1, int(getattr(Var, "BROADCAST_WORKERS", 4))) + workers = [ + asyncio.create_task(worker(), name=f"broadcast_worker_{i}") for i in range(worker_count) + ] + producer_task = asyncio.create_task(producer(), name="broadcast_producer") + + await producer_task + await asyncio.gather(*workers) 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}") @@ -166,20 +165,14 @@ async def do_broadcast(): total_users=stats["total"], successes=stats["success"], failures=stats["failed"], - deleted_accounts=stats["deleted"] + 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) + await reply_safe(message, completion_msg, parse_mode=ParseMode.MARKDOWN) except Exception as e: logger.error(f"Failed to send broadcast completion message: {e}", exc_info=True) @@ -187,3 +180,51 @@ async def do_broadcast(): del broadcast_ids[broadcast_id] asyncio.create_task(do_broadcast()) + + +async def _send_one(client: Client, message: Message, user_id: int, stats: dict) -> None: + try: + await tg_call(message.reply_to_message.copy, user_id, retries=2) + stats["success"] += 1 + except _PERMANENT_ERRORS as e: + if isinstance(e, ChannelInvalid): + recipient_type, reason = "Channel", "invalid channel" + elif isinstance(e, InputUserDeactivated): + recipient_type, reason = "User", "deactivated account" + elif isinstance(e, UserIsBlocked): + recipient_type, reason = "User", "blocked the bot" + elif isinstance(e, UserDeactivated): + recipient_type, reason = "User", "deactivated account" + elif isinstance(e, PeerIdInvalid): + recipient_type, reason = "Recipient", "invalid ID" + elif isinstance(e, ChatWriteForbidden): + recipient_type, reason = "Chat", "write forbidden" + else: + recipient_type, reason = "Recipient", f"error: {type(e).__name__}" + + logger.warning(f"{recipient_type} {user_id} removed due to {reason}") + try: + 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 db_err: + logger.error(f"Prune lookup failed for {user_id}: {db_err}", exc_info=True) + stats["failed"] += 1 + except FloodWait as e: + logger.warning(f"FloodWait persisted for user {user_id}, last wait: {e.value}s") + stats["failed"] += 1 + except Exception as e: + logger.error(f"Error copying message to user {user_id}: {e}", exc_info=True) + stats["failed"] += 1 + + +async def _edit_progress(status_msg: Message, stats: dict) -> None: + try: + await status_msg.edit_text( + f"πŸ“£ **Broadcasting...** βœ… {stats['success']} / {stats['total']} delivered" + ) + except Exception: + pass diff --git a/Thunder/utils/canonical_files.py b/Thunder/utils/canonical_files.py index 7ef66f5..299893d 100755 --- a/Thunder/utils/canonical_files.py +++ b/Thunder/utils/canonical_files.py @@ -2,20 +2,24 @@ import datetime import hashlib from collections import OrderedDict +from collections.abc import Awaitable, Callable from contextlib import asynccontextmanager -from typing import Any, Awaitable, Callable, Dict, Optional, Tuple +from typing import Any +from pymongo.errors import DuplicateKeyError 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.utils.safe_call import tg_call from Thunder.vars import Var -PUBLIC_HASH_LENGTH = 20 +# L4: new ingestions hash to 32 hex chars; the historical 20-char family +# stays valid forever so every existing link keeps working. +PUBLIC_HASH_LENGTH = 32 +LEGACY_PUBLIC_HASH_LENGTH = 20 _CACHE_TTL_SECONDS = 600 _CACHE_MAX_ITEMS = 4096 _INGEST_CLAIM_TTL_SECONDS = 60 @@ -24,17 +28,22 @@ _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() +# M14: bounded touch buffer -- overflow drops increments (counted) instead +# of growing memory; flushes batch into a single BulkWrite. +_FLUSH_DELAY_SECONDS = max(1, min(60, int(getattr(Var, "TOUCH_FLUSH_SECONDS", 3)))) +_TOUCH_BUFFER_MAX = max(100, int(getattr(Var, "TOUCH_BUFFER_MAX", 1000))) +_dropped_touches = 0 + +_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 +_pending_touches: dict[str, tuple[dict[str, Any], bool]] = {} +_flush_task: asyncio.Task | None = None def build_public_hash(file_unique_id: str) -> str: @@ -57,15 +66,15 @@ def _infer_mime_type(media: Any) -> str: def build_file_record( stored_message: Message, *, - source_chat_id: Optional[int] = None, - source_message_id: Optional[int] = None -) -> Optional[Dict[str, Any]]: + source_chat_id: int | None = None, + source_message_id: int | None = None, +) -> dict[str, Any] | None: 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) + now = datetime.datetime.now(datetime.UTC) return { "file_unique_id": file_unique_id, "public_hash": build_public_hash(file_unique_id), @@ -80,11 +89,11 @@ def build_file_record( "created_at": now, "last_seen_at": now, "seen_count": 1, - "reuse_count": 0 + "reuse_count": 0, } -def _prune_cache(cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]") -> None: +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: @@ -94,9 +103,8 @@ def _prune_cache(cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]") -> Non def _cache_get( - cache: "OrderedDict[Any, Tuple[float, Dict[str, Any]]]", - key: Any -) -> Optional[Dict[str, Any]]: + cache: "OrderedDict[Any, tuple[float, dict[str, Any]]]", key: Any +) -> dict[str, Any] | None: if key not in cache: return None ts, value = cache[key] @@ -108,7 +116,7 @@ def _cache_get( return value -def _remember(record: Dict[str, Any]) -> Dict[str, Any]: +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") @@ -116,7 +124,7 @@ def _remember(record: Dict[str, Any]) -> Dict[str, Any]: canonical_message_id = record.get("canonical_message_id") _insert_counter += 1 - should_prune = (_insert_counter % _CACHE_PRUNE_INTERVAL == 0) + should_prune = _insert_counter % _CACHE_PRUNE_INTERVAL == 0 if file_unique_id: _cache_by_unique_id[file_unique_id] = (now, record) @@ -136,7 +144,7 @@ def _remember(record: Dict[str, Any]) -> Dict[str, Any]: return record -def _forget(record: Dict[str, Any]) -> None: +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") @@ -149,7 +157,7 @@ def _forget(record: Dict[str, Any]) -> 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]]: +async def get_file_by_unique_id(file_unique_id: str) -> dict[str, Any] | None: cached = _cache_get(_cache_by_unique_id, file_unique_id) if cached: return cached @@ -158,10 +166,8 @@ async def get_file_by_unique_id(file_unique_id: str) -> Optional[Dict[str, Any]] async def get_file_by_hash( - public_hash: str, - *, - raise_on_error: bool = True -) -> Optional[Dict[str, Any]]: + public_hash: str, *, raise_on_error: bool = True +) -> dict[str, Any] | None: cached = _cache_get(_cache_by_hash, public_hash) if cached: return cached @@ -169,61 +175,60 @@ async def get_file_by_hash( return _remember(record) if record else None -async def get_file_by_message_id(canonical_message_id: int) -> Optional[Dict[str, Any]]: +async def get_file_by_message_id(canonical_message_id: int) -> dict[str, Any] | None: 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 + return 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 forget_stale_record(record: dict[str, Any]) -> bool: + """Self-healing (M10): drop a corrupted/stale record from cache + DB so + the next upload re-ingests cleanly instead of erroring forever.""" + public_hash = record.get("public_hash") + if not public_hash: + return False + _forget(record) + deleted = await db.delete_file_record(public_hash) + logger.warning(f"Self-healed stale file record {public_hash} (deleted={deleted})") + return deleted async def _flush_pending_touches() -> None: - global _flush_task + global _flush_task, _dropped_touches 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) + await _bulk_flush() 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) + try: + await _bulk_flush() + except Exception as e: + logger.error(f"Touch flush failed on cancel path: {e}", exc_info=True) _flush_task = None -def schedule_touch_file_record(record: Dict[str, Any], *, reused: bool = False) -> None: - global _flush_task +async def _bulk_flush() -> None: + items = list(_pending_touches.items()) + _pending_touches.clear() + if not items: + return + try: + await db.bulk_touch_file_records([(h, reused) for h, (_, reused) in items]) + except Exception as e: + logger.error(f"Failed to bulk-flush {len(items)} touches: {e}", exc_info=True) + + +def schedule_touch_file_record(record: dict[str, Any], *, reused: bool = False) -> None: + global _flush_task, _dropped_touches if not record.get("public_hash"): return - record["last_seen_at"] = datetime.datetime.now(datetime.timezone.utc) + record["last_seen_at"] = datetime.datetime.now(datetime.UTC) record["seen_count"] = int(record.get("seen_count", 0)) + 1 if reused: record["reuse_count"] = int(record.get("reuse_count", 0)) + 1 @@ -233,6 +238,14 @@ def schedule_touch_file_record(record: Dict[str, Any], *, reused: bool = False) if public_hash in _pending_touches: _, existing_reused = _pending_touches[public_hash] _pending_touches[public_hash] = (record, existing_reused or reused) + elif len(_pending_touches) >= _TOUCH_BUFFER_MAX: + # M14: drop-on-overflow with a counter -- memory stays capped + _dropped_touches += 1 + if _dropped_touches % 100 == 1: + logger.warning( + f"Touch buffer full ({_TOUCH_BUFFER_MAX}); " + f"dropped {_dropped_touches} increments so far" + ) else: _pending_touches[public_hash] = (record, reused) @@ -240,6 +253,14 @@ def schedule_touch_file_record(record: Dict[str, Any], *, reused: bool = False) _flush_task = asyncio.create_task(_flush_pending_touches()) +def touch_buffer_stats() -> dict[str, int]: + return { + "pending": len(_pending_touches), + "dropped": _dropped_touches, + "cap": _TOUCH_BUFFER_MAX, + } + + async def drain_background_touch_tasks() -> None: if _flush_task and not _flush_task.done(): _flush_task.cancel() @@ -247,18 +268,14 @@ async def drain_background_touch_tasks() -> None: 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) + try: + await _bulk_flush() + except Exception as e: + logger.error(f"Failed to flush touch buffer at shutdown: {e}", exc_info=True) -async def update_cached_file_id(record: Dict[str, Any], file_id: str) -> None: + +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 @@ -266,27 +283,29 @@ async def update_cached_file_id(record: Dict[str, Any], file_id: str) -> None: 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]: +async def _fetch_canonical_message(record: dict[str, Any], client=None) -> Message | None: canonical_message_id = record.get("canonical_message_id") if canonical_message_id is None: return None + # M12 layering break: the client is passed in by callers; the lazy + # fallback keeps backward compatibility for existing call sites. + if client is None: + from Thunder.bot import StreamBot + + client = StreamBot + 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) - ) + message = await tg_call( + client.get_messages, + chat_id=int(Var.BIN_CHANNEL), + message_ids=int(canonical_message_id), + retries=1, + timeout=60, + ) except Exception as e: logger.warning( - f"Error fetching canonical message {canonical_message_id}: {e}", - exc_info=True + f"Error fetching canonical message {canonical_message_id}: {e}", exc_info=True ) raise @@ -295,24 +314,27 @@ async def _fetch_canonical_message(record: Dict[str, Any]) -> Optional[Message]: return message -async def _is_canonical_record_valid(record: Dict[str, Any], file_unique_id: str) -> bool: - message = await _fetch_canonical_message(record) +async def _is_canonical_record_valid( + record: dict[str, Any], file_unique_id: str, client=None +) -> bool: + message = await _fetch_canonical_message(record, client) 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]]]: + file_unique_id: str, + client=None, +) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: 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) + is_valid = await _is_canonical_record_valid(existing, file_unique_id, client) 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 + exc_info=True, ) is_valid = False @@ -323,12 +345,14 @@ async def _get_reusable_canonical_record( return None, existing -async def _wait_for_other_worker_canonical_record(file_unique_id: str) -> Optional[Dict[str, Any]]: +async def _wait_for_other_worker_canonical_record( + file_unique_id: str, client=None +) -> dict[str, Any] | None: 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) + reusable_record, _ = await _get_reusable_canonical_record(file_unique_id, client) if reusable_record: return reusable_record @@ -341,19 +365,16 @@ async def _wait_for_other_worker_canonical_record(file_unique_id: str) -> Option def _merge_replacement_record( - existing: Dict[str, Any], - refreshed: Dict[str, Any] -) -> Dict[str, Any]: + 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") + "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") + "first_source_message_id", refreshed.get("first_source_message_id") ) return refreshed @@ -387,8 +408,9 @@ async def file_ingest_lock(file_unique_id: str): 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]: + copy_media: Callable[[Message], Awaitable[Message | None]], + client=None, +) -> tuple[dict[str, Any] | None, Message | None, bool]: file_unique_id = get_uniqid(source_message) if not file_unique_id: return None, None, False @@ -397,25 +419,30 @@ async def get_or_create_canonical_file( 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) + + reusable_record, stale_record = await _get_reusable_canonical_record( + file_unique_id, client + ) 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 + 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) + reusable_record = await _wait_for_other_worker_canonical_record( + file_unique_id, client + ) 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) + reusable_record, stale_record = await _get_reusable_canonical_record( + file_unique_id, client + ) if reusable_record: schedule_touch_file_record(reusable_record, reused=True) return reusable_record, None, True @@ -427,7 +454,7 @@ async def get_or_create_canonical_file( 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 + source_message_id=source_message.id, ) if not record: return None, stored_message, False @@ -441,7 +468,9 @@ async def get_or_create_canonical_file( _remember(record) return record, stored_message, False except DuplicateKeyError: - reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id) + reusable_record = await _wait_for_other_worker_canonical_record( + file_unique_id, client + ) if reusable_record: schedule_touch_file_record(reusable_record, reused=True) return reusable_record, stored_message, True @@ -449,12 +478,17 @@ async def get_or_create_canonical_file( 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) + 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) + 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) diff --git a/Thunder/utils/commands.py b/Thunder/utils/commands.py index b8e5742..e99c70f 100755 --- a/Thunder/utils/commands.py +++ b/Thunder/utils/commands.py @@ -1,37 +1,26 @@ -from pyrogram.types import BotCommand - from Thunder.bot import StreamBot +from Thunder.bot.registry import bot_commands, help_command_rows from Thunder.utils.logger import logger +from Thunder.utils.messages import MSG_HELP_COMMANDS_HEADER, MSG_HELP_TIPS 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()] + +def build_help_text(max_files: int) -> str: + """Assemble /help from its three parts (M1: commands come from the registry).""" + from Thunder.utils.messages import MSG_HELP_INTRO + + return ( + MSG_HELP_INTRO.format(max_files=max_files) + + MSG_HELP_COMMANDS_HEADER + + help_command_rows() + + MSG_HELP_TIPS + ) + async def set_commands(): if Var.SET_COMMANDS: try: - commands = get_commands() + commands = bot_commands() if commands: await StreamBot.set_bot_commands(commands) except Exception as e: diff --git a/Thunder/utils/config_parser.py b/Thunder/utils/config_parser.py index e307b12..3ad4a90 100755 --- a/Thunder/utils/config_parser.py +++ b/Thunder/utils/config_parser.py @@ -1,35 +1,33 @@ # 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] = {} + def __init__(self, config_file: str | None = None): + self.tokens: dict[int, str] = {} self.config_file = config_file - def parse_from_env(self) -> Dict[int, str]: + 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) + key=lambda item: int("".join(filter(str.isdigit, item[0])) or 0), ) - - self.tokens = { - index + 1: token - for index, (_, token) in enumerate(sorted_tokens) - } - + + 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) diff --git a/Thunder/utils/custom_dl.py b/Thunder/utils/custom_dl.py index 22b0de4..aae6a0e 100755 --- a/Thunder/utils/custom_dl.py +++ b/Thunder/utils/custom_dl.py @@ -1,7 +1,8 @@ # Thunder/utils/custom_dl.py import asyncio -from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, Optional +from collections.abc import AsyncGenerator +from typing import Any from pyrogram import Client from pyrogram.errors import FloodWait @@ -10,11 +11,12 @@ from Thunder.server.exceptions import FileNotFound from Thunder.utils.file_properties import get_media from Thunder.utils.logger import logger +from Thunder.utils.media_types import ext_and_mime_for_class from Thunder.vars import Var class ByteStreamer: - __slots__ = ('client', 'chat_id') + __slots__ = ("client", "chat_id") def __init__(self, client: Client) -> None: self.client = client @@ -41,92 +43,58 @@ async def stream_file( media_ref: int | Message, offset: int = 0, limit: int = 0, - fallback_message_id: int | None = None, - on_fallback_message: Optional[Callable[[Message], Awaitable[None]]] = None - ) -> AsyncGenerator[bytes, None]: + ) -> AsyncGenerator[bytes]: chunk_offset = offset // (1024 * 1024) chunk_limit = 0 if limit > 0: chunk_limit = ((limit + (1024 * 1024) - 1) // (1024 * 1024)) + 1 - refs: list[int | Message] = [media_ref] - media_id = media_ref if isinstance(media_ref, int) else None - if isinstance(media_ref, Message): - media_id = getattr(media_ref, "id", getattr(media_ref, "message_id", None)) - if fallback_message_id is not None and (media_id is None or fallback_message_id != media_id): - refs.append(fallback_message_id) - - last_error: Exception | None = None - for ref in refs: - started_stream = False - while True: - try: - target = await self.get_message(ref) if isinstance(ref, int) else ref - if ( - on_fallback_message is not None and - fallback_message_id is not None and - ref == fallback_message_id and - isinstance(target, Message) - ): - await on_fallback_message(target) - async for chunk in self.client.stream_media( - target, offset=chunk_offset, limit=chunk_limit - ): - started_stream = True - yield chunk - return - except FloodWait as e: - logger.debug(f"FloodWait: stream_file, sleep {e.value}s") - await asyncio.sleep(e.value) - except Exception as e: - last_error = e - logger.debug(f"Error streaming media ref {ref}: {e}", exc_info=True) - if started_stream: - raise - break - - raise FileNotFound(f"Unable to stream file: {last_error}") - - def get_file_info_sync(self, message: Message) -> Dict[str, Any]: + # H4b: the historical fallback-message plumbing was dead (the + # fallback id always equalled the primary ref, so the fallback ref + # was never appended) -- removed. + while True: + try: + target = ( + await self.get_message(media_ref) if isinstance(media_ref, int) else media_ref + ) + async for chunk in self.client.stream_media( + target, offset=chunk_offset, limit=chunk_limit + ): + yield chunk + return + except FloodWait as e: + logger.debug(f"FloodWait: stream_file, sleep {e.value}s") + await asyncio.sleep(e.value) + except Exception as e: + logger.debug(f"Error streaming media ref {media_ref}: {e}", exc_info=True) + raise FileNotFound(f"Unable to stream file: {e}") from e + + def get_file_info_sync(self, message: Message) -> dict[str, Any]: media = get_media(message) if not media: return {"message_id": message.id, "error": "No media"} media_type = type(media).__name__.lower() - file_name = getattr(media, 'file_name', None) - mime_type = getattr(media, 'mime_type', None) + file_name = getattr(media, "file_name", None) + mime_type = getattr(media, "mime_type", None) if not file_name: - ext_map = { - "photo": "jpg", - "audio": "mp3", - "voice": "ogg", - "video": "mp4", - "animation": "mp4", - "videonote": "mp4", - "sticker": "webp", - } - ext = ext_map.get(media_type, "bin") + ext, _ = ext_and_mime_for_class(media_type) file_name = f"Thunder_{message.id}.{ext}" if not mime_type: - mime_map = { - "photo": "image/jpeg", - "voice": "audio/ogg", - "videonote": "video/mp4", - } - mime_type = mime_map.get(media_type) + _, mime_type = ext_and_mime_for_class(media_type) return { "message_id": message.id, - "file_size": getattr(media, 'file_size', 0) or 0, + "file_size": getattr(media, "file_size", 0) or 0, "file_name": file_name, "mime_type": mime_type, - "unique_id": getattr(media, 'file_unique_id', None), - "media_type": media_type + "unique_id": getattr(media, "file_unique_id", None), + "media_type": media_type, } - async def get_file_info(self, message_id: int) -> Dict[str, Any]: + async def get_file_info(self, message_id: int) -> dict[str, Any]: try: message = await self.get_message(message_id) return self.get_file_info_sync(message) diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py index d24277e..d07506a 100755 --- a/Thunder/utils/database.py +++ b/Thunder/utils/database.py @@ -1,16 +1,23 @@ # Thunder/utils/database.py import datetime -from typing import Any, Dict, Optional -from pymongo import AsyncMongoClient +from typing import Any + +from pymongo import AsyncMongoClient, UpdateOne from pymongo.asynchronous.collection import AsyncCollection from pymongo.errors import DuplicateKeyError -from Thunder.vars import Var + from Thunder.utils.logger import logger +from Thunder.vars import Var + +# H8: every Mongo operation gets a server-side budget so a brownout cannot +# pin handlers forever. Per-op overrides remain possible at call sites. +MONGO_TIMEOUT_MS = 5000 + class Database: def __init__(self, uri: str, database_name: str, *args, **kwargs): - self._client = AsyncMongoClient(uri, *args, **kwargs) + self._client = AsyncMongoClient(uri, *args, timeoutMS=MONGO_TIMEOUT_MS, **kwargs) self.db = self._client[database_name] self.col: AsyncCollection = self.db.users self.banned_users_col: AsyncCollection = self.db.banned_users @@ -25,7 +32,7 @@ async def _deduplicate_users(self) -> None: pipeline = [ {"$sort": {"join_date": 1}}, {"$group": {"_id": "$id", "doc_id": {"$first": "$_id"}}}, - {"$project": {"_id": "$doc_id"}} + {"$project": {"_id": "$doc_id"}}, ] keep_ids = [] async for doc in self.col.aggregate(pipeline): @@ -37,6 +44,23 @@ async def _deduplicate_users(self) -> None: async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool: try: + # L2: optional file TTL -- backfill first so pre-existing rows do + # not vanish the moment the index is created (default off). + if Var.FILE_TTL_DAYS > 0: + await self.files_col.update_many( + {"last_seen_at": {"$exists": False}}, + {"$set": {"last_seen_at": datetime.datetime.now(datetime.UTC)}}, + ) + await self.files_col.create_index( + "last_seen_at", expireAfterSeconds=Var.FILE_TTL_DAYS * 86400 + ) + logger.info(f"File TTL index active: {Var.FILE_TTL_DAYS} days") + else: + try: + await self.files_col.drop_index("last_seen_at_1") + except Exception: + pass + 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) @@ -55,7 +79,6 @@ async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool: 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.") @@ -68,10 +91,7 @@ async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool: def new_user(self, user_id: int) -> dict: try: - return { - 'id': user_id, - 'join_date': datetime.datetime.now(datetime.timezone.utc) - } + return {"id": user_id, "join_date": datetime.datetime.now(datetime.UTC)} except Exception as e: logger.error(f"Error in new_user for user {user_id}: {e}", exc_info=True) raise @@ -79,9 +99,7 @@ def new_user(self, user_id: int) -> dict: 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 + {"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.") @@ -91,11 +109,10 @@ async def add_user(self, user_id: int) -> bool: 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}) + 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) @@ -147,28 +164,24 @@ async def get_regular_users_cursor(self): async def delete_user(self, user_id: int): try: - await self.col.delete_one({'id': user_id}) + 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 + self, user_id: int, banned_by: int | None = None, reason: str | None = None ): try: ban_data = { "user_id": user_id, - "banned_at": datetime.datetime.now(datetime.timezone.utc), + "banned_at": datetime.datetime.now(datetime.UTC), "banned_by": banned_by, - "reason": reason + "reason": reason, } await self.banned_users_col.update_one( - {"user_id": user_id}, - {"$set": ban_data}, - upsert=True + {"user_id": user_id}, {"$set": ban_data}, upsert=True ) logger.debug(f"Added/Updated banned user {user_id}. Reason: {reason}") except Exception as e: @@ -186,7 +199,7 @@ async def remove_banned_user(self, user_id: int) -> bool: 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]]: + async def is_user_banned(self, user_id: int) -> dict[str, Any] | None: try: return await self.banned_users_col.find_one({"user_id": user_id}) except Exception as e: @@ -194,24 +207,23 @@ async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]: return None async def add_banned_channel( - self, channel_id: int, banned_by: Optional[int] = None, - reason: Optional[str] = None + self, channel_id: int, banned_by: int | None = None, reason: str | None = None ): try: ban_data = { "channel_id": channel_id, - "banned_at": datetime.datetime.now(datetime.timezone.utc), + "banned_at": datetime.datetime.now(datetime.UTC), "banned_by": banned_by, - "reason": reason + "reason": reason, } await self.banned_channels_col.update_one( - {"channel_id": channel_id}, - {"$set": ban_data}, - upsert=True + {"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) + 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: @@ -222,46 +234,59 @@ async def remove_banned_channel(self, channel_id: int) -> bool: return True return False except Exception as e: - logger.error(f"Error in remove_banned_channel for channel {channel_id}: {e}", exc_info=True) + 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]]: + async def is_channel_banned(self, channel_id: int) -> dict[str, Any] | None: 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: + 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 + { + "$set": { + "expires_at": expires_at, + "created_at": created_at, + "activated": activated, } }, - upsert=True + upsert=True, + ) + logger.debug( + f"Saved main token {token_value} for user {user_id} with activated status {activated}." ) - 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) - }) + await self.restart_message_col.insert_one( + { + "message_id": message_id, + "chat_id": chat_id, + "timestamp": datetime.datetime.now(datetime.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]]: + async def get_restart_message(self) -> dict[str, Any] | None: try: return await self.restart_message_col.find_one(sort=[("timestamp", -1)]) except Exception as e: @@ -277,13 +302,13 @@ async def delete_restart_message(self, message_id: int) -> None: 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}) + 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]]: + async def get_file_by_unique_id(self, file_unique_id: str) -> dict[str, Any] | None: try: return await self.files_col.find_one({"file_unique_id": file_unique_id}) except Exception as e: @@ -291,11 +316,8 @@ async def get_file_by_unique_id(self, file_unique_id: str) -> Optional[Dict[str, return None async def get_file_by_hash( - self, - public_hash: str, - *, - raise_on_error: bool = True - ) -> Optional[Dict[str, Any]]: + self, public_hash: str, *, raise_on_error: bool = True + ) -> dict[str, Any] | None: try: return await self.files_col.find_one({"public_hash": public_hash}) except Exception as e: @@ -304,51 +326,35 @@ async def get_file_by_hash( 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: + 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 + exc_info=True, ) raise - async def replace_file_record(self, file_record: Dict[str, Any]) -> None: + 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 + {"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 + exc_info=True, ) raise async def touch_file_record( - self, - public_hash: str, - *, - reused: bool = False, - raise_on_error: bool = False + 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} + update_doc: dict[str, Any] = { + "$set": {"last_seen_at": datetime.datetime.now(datetime.UTC)}, + "$inc": {"seen_count": 1}, } if reused: update_doc["$inc"]["reuse_count"] = 1 @@ -360,22 +366,53 @@ async def touch_file_record( raise return False + async def bulk_touch_file_records( + self, items: list[tuple[str, bool]], *, raise_on_error: bool = False + ) -> bool: + """Batched touch (M14): one BulkWrite for the whole flush cycle. + + ``items`` is a list of ``(public_hash, reused)`` pairs; increments are + merged per hash by the caller before reaching this method. + """ + if not items: + return True + now = datetime.datetime.now(datetime.UTC) + ops: list[UpdateOne] = [] + for public_hash, reused in items: + inc: dict[str, int] = {"seen_count": 1} + if reused: + inc["reuse_count"] = 1 + ops.append( + UpdateOne( + {"public_hash": public_hash}, + {"$set": {"last_seen_at": now}, "$inc": inc}, + ) + ) + try: + await self.files_col.bulk_write(ops, ordered=False) + return True + except Exception as e: + logger.error(f"Error bulk-touching {len(ops)} file records: {e}", exc_info=True) + if raise_on_error: + raise + return False + + async def delete_file_record(self, public_hash: str) -> bool: + """Remove a stale canonical record (M10 self-healing).""" + try: + result = await self.files_col.delete_one({"public_hash": public_hash}) + return result.deleted_count > 0 + except Exception as e: + logger.error(f"Error deleting stale file record {public_hash}: {e}", exc_info=True) + return False + async def update_file_id( - self, - public_hash: str, - file_id: str, - *, - raise_on_error: bool = False + 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) - } - } + {"$set": {"file_id": file_id, "last_seen_at": datetime.datetime.now(datetime.UTC)}}, ) return True except Exception as e: @@ -385,40 +422,31 @@ async def update_file_id( return False async def acquire_file_ingest_claim( - self, - file_unique_id: str, - *, - ttl_seconds: int = 60 + self, file_unique_id: str, *, ttl_seconds: int = 60 ) -> bool: - now = datetime.datetime.now(datetime.timezone.utc) + now = datetime.datetime.now(datetime.UTC) claim_fields = { "created_at": now, - "expires_at": now + datetime.timedelta(seconds=ttl_seconds) + "expires_at": now + datetime.timedelta(seconds=ttl_seconds), } try: - await self.file_ingest_locks_col.insert_one({ - "_id": file_unique_id, - **claim_fields - }) + 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 + "$or": [{"expires_at": {"$lte": now}}, {"expires_at": {"$exists": False}}], }, - return_document=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) + 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) @@ -435,11 +463,8 @@ async def release_file_ingest_claim(self, file_unique_id: str) -> bool: 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} + {"_id": file_unique_id, "expires_at": {"$gt": datetime.datetime.now(datetime.UTC)}}, + {"_id": 1}, ) return bool(claim) except Exception as e: @@ -450,4 +475,5 @@ 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 index 2775225..5392b58 100755 --- a/Thunder/utils/decorators.py +++ b/Thunder/utils/decorators.py @@ -1,19 +1,41 @@ # Thunder/utils/decorators.py -import asyncio -from pyrogram.errors import FloodWait +"""Access gates (plan H7 + M12). + +One preflight chain replaces the three ad-hoc per-plugin gate orders. +Documented ordering (see AGENTS.md): + + banned -> private-mode -> token-activation -> force-sub -> shortener-status + +* owner bypasses everything; authorized users bypass everything but the + ban check; +* /start runs only ``banned + private-mode`` so the activation flow stays + reachable; +* every DB-backed gate is cached (``flag_cache``) and **fail-closed**: + a Mongo outage denies access with a temporary-error message instead of + silently letting everyone through. +""" + from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from Thunder.utils.database import db +from Thunder.utils.flag_cache import flags from Thunder.utils.logger import logger -from Thunder.utils.messages import (MSG_DECORATOR_BANNED, - MSG_ERROR_UNAUTHORIZED, MSG_TOKEN_INVALID) +from Thunder.utils.messages import ( + MSG_DECORATOR_BANNED, + MSG_ERROR_TEMP, + MSG_ERROR_UNAUTHORIZED, + MSG_PRIVATE_MODE_DENIED, + MSG_TOKEN_INVALID, +) +from Thunder.utils.safe_call import answer_safe, reply_safe, tg_call 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): +async def check_banned(client, message: Message) -> bool: + """Ban gate -- cached, fail-closed (H7).""" try: if not message.from_user: return True @@ -21,39 +43,75 @@ async def check_banned(client, message: Message): if user_id == Var.OWNER_ID: return True - ban_details = await db.is_user_banned(user_id) + try: + ban_details = await flags.get_or_load( + ("banned_user", user_id), + lambda: db.is_user_banned(user_id), + ) + except Exception as e: + # fail-closed: a Mongo outage must not un-ban everybody + logger.error(f"Ban check degraded for user {user_id}: {e}", exc_info=True) + try: + await reply_safe(message, MSG_ERROR_TEMP) + except Exception: + pass + return False + if ban_details: - banned_at = ban_details.get('banned_at') + 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' + 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( + await reply_safe( + message, MSG_DECORATOR_BANNED.format( - reason=ban_details.get('reason', 'Not specified'), - ban_time=ban_time + 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 ) + except Exception: + pass 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 False + + +async def check_private_mode(client, message: Message) -> bool: + """PRIVATE_MODE allowlist gate (M12): owner + authorized users only.""" + if not getattr(Var, "PRIVATE_MODE", False): + return True + if not message.from_user: return True + user_id = message.from_user.id + if user_id == Var.OWNER_ID: + return True + try: + if await allowed(user_id): + return True + except Exception as e: + logger.error(f"Private-mode auth check failed for {user_id}: {e}", exc_info=True) + try: + await reply_safe(message, MSG_ERROR_TEMP) + except Exception: + pass + return False + try: + await reply_safe(message, MSG_PRIVATE_MODE_DENIED) + except Exception: + pass + logger.debug(f"Rejected non-allowlisted user {user_id} (PRIVATE_MODE).") + return False + -async def require_token(client, message: Message): +async def require_token(client, message: Message) -> bool: + """Token-activation gate (H7: cached checks, fail-closed).""" try: if not message.from_user: return True @@ -62,42 +120,67 @@ async def require_token(client, message: Message): return True user_id = message.from_user.id - if user_id == Var.OWNER_ID or await allowed(user_id) or await check(user_id): + if user_id == Var.OWNER_ID: return True - temp_token_string = None + try: + if await allowed(user_id) or await check(user_id): + return True + except Exception as e: + logger.error(f"Token gate degraded for user {user_id}: {e}", exc_info=True) + try: + await reply_safe(message, MSG_ERROR_TEMP) + except Exception: + pass + return False + 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) + logger.error( + f"Failed to generate temporary token for user {user_id}: {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) + await reply_safe( + message, + "Sorry, could not generate an access token link. Please try again later.", + ) + except Exception: + pass 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) + logger.error( + f"Temporary token generation returned empty for user {user_id}.", 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) + await reply_safe( + message, + "Sorry, could not generate an access token link. Please try again later.", + ) + except Exception: + pass return False try: - me = await client.get_me() - except FloodWait as e: - await asyncio.sleep(e.value) - me = await client.get_me() + me = await tg_call(client.get_me) + except Exception as e: + logger.error(f"Failed to get bot info for user {user_id}: {e}", exc_info=True) + try: + await reply_safe( + message, "Sorry, an unexpected error occurred. Please try again later." + ) + except Exception: + pass + return False if not me: - logger.error(f"Failed to get bot info for user {user_id} in require_token.", exc_info=True) + logger.error(f"get_me returned nothing for user {user_id}.", 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) + await reply_safe( + message, "Sorry, an unexpected error occurred. Please try again later." + ) + except Exception: + pass return False deep_link = f"https://t.me/{me.username}?start={temp_token_string}" short_url = deep_link @@ -107,40 +190,37 @@ async def require_token(client, message: Message): 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) + logger.warning( + f"Failed to shorten token link for user {user_id}: {e}. Using full link.", + exc_info=True, + ) try: - await message.reply_text( + await reply_safe( + message, 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 + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton("Activate Access", url=short_url)]] + ), ) + except Exception: + pass 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) + await reply_safe( + message, "An error occurred while checking your authorization. Please try again." + ) except Exception as inner_e: - logger.error(f"Failed to send error message to user in require_token: {inner_e}", exc_info=True) + 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): + +async def get_shortener_status(client, message: Message) -> bool: try: user_id = message.from_user.id if message.from_user else None use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False) @@ -149,33 +229,86 @@ async def get_shortener_status(client, message: Message): 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) + logger.warning( + f"Error checking allowed status for user {user_id}: {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): + +# -------------------------------------------------------------------------- +# M12: unified preflight chain +# -------------------------------------------------------------------------- + +#: gate registry -- order is the documented contract; adding a new gate is a +#: one-place change here (asserted by tests/test_preflight.py). +PREFLIGHT_GATES = { + "banned": check_banned, + "private_mode": check_private_mode, + "token": require_token, +} + + +async def preflight( + client, + message: Message, + *, + gates: tuple = ("banned", "private_mode", "token"), + skip: tuple = (), +) -> bool | None: + """Run the standard gate chain in order. + + Returns the final shortener status (last gate's value convention) or + ``None`` when any gate rejects the request. + """ + for name in gates: + if name in skip: + continue + gate = PREFLIGHT_GATES.get(name) + if gate is None: + continue + if not await gate(client, message): + return None + return await get_shortener_status(client, message) + + +async def force_sub_gate(client, message: Message) -> bool: + """Force-subscribe gate, kept separate so the rate-limit chain can slot + it after the token gate (called explicitly by stream entries).""" + from Thunder.utils.force_channel import force_channel_check + + return await force_channel_check(client, message) + + +async def owner_only(client, update) -> bool: try: user = None - if hasattr(update, 'from_user'): + 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) + 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.") + if hasattr(update, "answer"): + await answer_safe(update, 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) + if hasattr(update, "answer"): + await answer_safe(update, "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 index fdc6297..26b1d16 100755 --- a/Thunder/utils/file_properties.py +++ b/Thunder/utils/file_properties.py @@ -1,44 +1,49 @@ # Thunder/utils/file_properties.py -import asyncio from datetime import datetime as dt -from typing import Any, Optional +from typing import Any -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 +from Thunder.utils.media_types import canonical_media_type, ext_for -def get_media(message: Message) -> Optional[Any]: - for attr in ("audio", "document", "photo", "sticker", "animation", "video", "voice", "video_note"): +def get_media(message: Message) -> Any | None: + 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]: +def get_uniqid(message: Message) -> str | None: media = get_media(message) - return getattr(media, 'file_unique_id', None) + 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 '' + 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 + return getattr(media, "file_size", 0) if media else 0 -def parse_fid(message: Message) -> Optional[FileId]: +def parse_fid(message: Message) -> FileId | None: media = get_media(message) - if media and hasattr(media, 'file_id'): + if media and hasattr(media, "file_id"): try: return FileId.decode(media.file_id) except Exception: @@ -48,52 +53,18 @@ def parse_fid(message: Message) -> Optional[FileId]: def get_fname(msg: Message) -> str: media = get_media(msg) - fname = getattr(media, 'file_name', None) if media else None + 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(): + # single media-type map (H4c): attribute -> canonical key -> ext + for attr in ("photo", "audio", "voice", "video", "animation", "video_note", "sticker"): if getattr(msg, attr, None) is not None: - ext = extension + ext = ext_for(canonical_media_type(attr=attr)) 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/flag_cache.py b/Thunder/utils/flag_cache.py new file mode 100644 index 0000000..67704ea --- /dev/null +++ b/Thunder/utils/flag_cache.py @@ -0,0 +1,108 @@ +# Thunder/utils/flag_cache.py + +"""Tiny lazy TTL+LRU cache for per-user/per-channel flags. + +Mirrors ThunderGo's ``internal/store/cache.go``: values are loaded on first +access, kept for ``ttl_seconds`` and evicted least-recently-used beyond +``max_items``. A periodic :meth:`sweep` drops expired entries so memory +stays bounded even for bots with large user bases. + +Loader exceptions deliberately propagate -- callers implement their own +fail-closed policy (see ``utils/decorators.py``). +""" + +import asyncio +import time +from collections import OrderedDict +from collections.abc import Awaitable, Callable, Hashable +from typing import Any + +from Thunder.utils.logger import logger + +DEFAULT_TTL_SECONDS = 300 +DEFAULT_MAX_ITEMS = 4096 +_SWEEP_INTERVAL_SECONDS = 300 + + +class FlagCache: + def __init__( + self, + *, + ttl_seconds: int = DEFAULT_TTL_SECONDS, + max_items: int = DEFAULT_MAX_ITEMS, + name: str = "flags", + ): + self.ttl_seconds = ttl_seconds + self.max_items = max_items + self.name = name + self._data: OrderedDict[Hashable, tuple[Any, float]] = OrderedDict() + + def _prune_expired(self, now: float) -> None: + expired = [key for key, (_, ts) in self._data.items() if now - ts > self.ttl_seconds] + for key in expired: + self._data.pop(key, None) + + async def get_or_load( + self, + key: Hashable, + loader: Callable[[], Awaitable[Any]], + ) -> Any: + now = time.monotonic() + if key in self._data: + value, ts = self._data[key] + if now - ts <= self.ttl_seconds: + self._data.move_to_end(key) + return value + self._data.pop(key, None) + + value = await loader() + self._data[key] = (value, now) + self._data.move_to_end(key) + while len(self._data) > self.max_items: + self._data.popitem(last=False) + return value + + def peek(self, key: Hashable) -> tuple[bool, Any]: + """Non-loading read: ``(hit, value)``.""" + if key not in self._data: + return False, None + value, ts = self._data[key] + if time.monotonic() - ts > self.ttl_seconds: + self._data.pop(key, None) + return False, None + return True, value + + def invalidate(self, *keys: Hashable) -> None: + for key in keys: + self._data.pop(key, None) + + def clear(self) -> None: + self._data.clear() + + def occupancy(self) -> int: + return len(self._data) + + async def sweep(self) -> int: + now = time.monotonic() + before = len(self._data) + self._prune_expired(now) + dropped = before - len(self._data) + if dropped: + logger.debug(f"flag_cache[{self.name}]: swept {dropped} expired entries") + return dropped + + async def run_sweeper(self) -> None: + """Background loop; cancel to stop. Registered at startup.""" + while True: + await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) + try: + await self.sweep() + except asyncio.CancelledError: + raise + except Exception as e: + logger.error(f"flag_cache[{self.name}] sweeper error: {e}", exc_info=True) + + +flags = FlagCache(name="user_flags") + +__all__ = ["FlagCache", "flags", "DEFAULT_TTL_SECONDS"] diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py index 9a32535..13b12ee 100755 --- a/Thunder/utils/force_channel.py +++ b/Thunder/utils/force_channel.py @@ -13,15 +13,16 @@ _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) @@ -29,17 +30,20 @@ async def get_force_info(bot: Client): 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_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 @@ -48,7 +52,9 @@ async def force_channel_check(client: Client, message: Message): 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.") + 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: @@ -60,17 +66,13 @@ async def force_channel_check(client: Client, message: Message): try: await message.reply_text( MSG_COMMUNITY_CHANNEL.format(channel_title=title), - reply_markup=InlineKeyboardMarkup([[ - InlineKeyboardButton("Join", url=link) - ]]) + 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) - ]]) + reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Join", url=link)]]), ) else: try: @@ -82,8 +84,12 @@ async def force_channel_check(client: Client, message: Message): 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.") + 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.") + 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 index 4a76fbe..b42e11d 100755 --- a/Thunder/utils/human_readable.py +++ b/Thunder/utils/human_readable.py @@ -2,7 +2,8 @@ from Thunder.utils.logger import logger -_UNITS = ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y') +_UNITS = ("", "K", "M", "G", "T", "P", "E", "Z", "Y") + def humanbytes(size: int, decimal_places: int = 2) -> str: try: diff --git a/Thunder/utils/keepalive.py b/Thunder/utils/keepalive.py index 46bf180..25bd5fc 100755 --- a/Thunder/utils/keepalive.py +++ b/Thunder/utils/keepalive.py @@ -1,22 +1,49 @@ # Thunder/utils/keepalive.py import asyncio +import os + import aiohttp -from Thunder.vars import Var + from Thunder.utils.logger import logger +from Thunder.vars import Var + + +def _health_url() -> str: + """Ping /health on ourselves (M3). + + The historical implementation GET ``Var.URL``, whose root handler is a + 302 to GitHub -- so the keepalive had been validating GitHub, not this + bot. We now bind to the configured address explicitly and check the + status code. + """ + fqdn = os.getenv("KEEPALIVE_HOST") or Var.BIND_ADDRESS + if fqdn in ("0.0.0.0", "::"): # nosec B104 -- string check mapping bind-all to loopback + fqdn = "127.0.0.1" + # self-check always targets loopback over plain HTTP unless overridden + return f"http://{fqdn}:{Var.PORT}/health" + async def ping_server(): try: - async with aiohttp.ClientSession( - timeout=aiohttp.ClientTimeout(total=10) - ) as session: + url = _health_url() + 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: + async with session.get(url) as resp: + body = await resp.text() if resp.status != 200: - logger.warning(f"Ping to {Var.URL} returned status {resp.status}.") + logger.warning( + f"Health check to {url} returned status {resp.status}: {body[:120]}" + ) + else: + logger.debug("Health check OK") except asyncio.CancelledError: break + except Exception as e: + logger.warning(f"Health check failed: {e}") + except asyncio.CancelledError: + pass 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 index b063baa..ac61ca3 100755 --- a/Thunder/utils/logger.py +++ b/Thunder/utils/logger.py @@ -1,39 +1,107 @@ # Thunder/utils/logger.py +import atexit +import json import logging -from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener import os import queue -import atexit +import re import sys +from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler -LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs') +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') +LOG_FILE = os.path.join(LOG_DIR, "bot.txt") logging._srcfile = None logging.logThreads = 0 -logging.logProcesses = 0 +logging.logProcesses = 0 + +# -------------------------------------------------------------------------- +# H10: shared secret redaction -- used by the access-log middleware and by +# /log before upload so no token / Mongo URI can leave the machine. +# -------------------------------------------------------------------------- + +BOT_TOKEN_PATTERN = re.compile(r"\d{8,10}:[A-Za-z0-9_-]{35,}") +MONGO_URI_PATTERN = re.compile(r"mongodb(\+srv)?://[^:]+:[^@]+@") +SESSION_TOKEN_PATTERN = re.compile(r"(?i)(authorization:\s*)(Bearer\s+)?[A-Za-z0-9._\-]{20,}") + +REDACTED = "***REDACTED***" + + +def redact_secrets(text: str) -> str: + """Strip bot tokens and Mongo credentials from a log payload.""" + if not text: + return text + text = BOT_TOKEN_PATTERN.sub(REDACTED, text) + text = MONGO_URI_PATTERN.sub("mongodb://***:***@", text) + text = SESSION_TOKEN_PATTERN.sub(r"\1\2" + REDACTED, text) + return text + + +def hash_path_token(token: str) -> str: + """Stable short pseudonym for a file token in access logs.""" + import hashlib + + return hashlib.sha256(token.encode("utf-8", "ignore")).hexdigest()[:8] + + +class RedactingFormatter(logging.Formatter): + def __init__(self, fmt: str, redact: bool = True): + super().__init__(fmt) + self._redact = redact + + def format(self, record: logging.LogRecord) -> str: + message = super().format(record) + if self._redact: + message = redact_secrets(message) + return message + + +class JsonFormatter(logging.Formatter): + def format(self, record: logging.LogRecord) -> str: + payload = { + "ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S%z"), + "level": record.levelname, + "name": record.name, + "msg": redact_secrets(record.getMessage()), + } + if record.exc_info: + payload["exc"] = redact_secrets(self.formatException(record.exc_info)) + return json.dumps(payload, ensure_ascii=False) + + +_log_level_name = os.getenv("LOG_LEVEL", "INFO").upper() +_log_level = getattr(logging, _log_level_name, logging.INFO) +_log_format = os.getenv("LOG_FORMAT", "plain").lower() log_queue = queue.Queue(maxsize=10000) -formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') +if _log_format == "json": + file_formatter: logging.Formatter = JsonFormatter() + console_formatter: logging.Formatter = JsonFormatter() +else: + plain = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + file_formatter = RedactingFormatter(plain) + console_formatter = RedactingFormatter(plain) -file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8') -file_handler.setFormatter(formatter) +file_handler = RotatingFileHandler( + LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=5, encoding="utf-8" +) +file_handler.setFormatter(file_formatter) console_handler = logging.StreamHandler(stream=sys.__stdout__) -console_handler.setFormatter(formatter) -console_handler.stream.reconfigure(encoding='utf-8', errors='replace') +console_handler.setFormatter(console_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 = logging.getLogger("ThunderBot") +logger.setLevel(_log_level) logger.propagate = False logger.addHandler(QueueHandler(log_queue)) atexit.register(listener.stop) -__all__ = ['logger', 'LOG_FILE'] +__all__ = ["logger", "LOG_FILE", "redact_secrets", "hash_path_token"] diff --git a/Thunder/utils/media_types.py b/Thunder/utils/media_types.py new file mode 100644 index 0000000..f41a0b5 --- /dev/null +++ b/Thunder/utils/media_types.py @@ -0,0 +1,85 @@ +# Thunder/utils/media_types.py + +"""Single source of truth for media-type -> extension / mime / display maps. + +Historically three drifted copies existed (``custom_dl.get_file_info_sync``, +``file_properties.get_fname``, ``common.send_file_dc``); this module replaces +all of them (plan H4c). Both naming families are keyed: pyrogram class +names come out lower-cased with no underscore (``videonote``) while message +attribute names use ``video_note`` -- both are accepted everywhere. +""" + +# message attribute name -> stable canonical key +_ATTR_TO_MEDIA_TYPE: dict[str, str] = { + "audio": "audio", + "document": "document", + "photo": "photo", + "sticker": "sticker", + "animation": "animation", + "video": "video", + "voice": "voice", + "video_note": "video_note", +} + +# pyrogram class-name (lower) -> canonical key +_CLASS_TO_MEDIA_TYPE: dict[str, str] = { + "audio": "audio", + "document": "document", + "photo": "photo", + "sticker": "sticker", + "animation": "animation", + "video": "video", + "voice": "voice", + "videonote": "video_note", + "video_note": "video_note", # both naming families accepted +} + +# canonical key -> (extension, mime type) +_MEDIA_EXT_MIME: dict[str, tuple[str, str]] = { + "photo": ("jpg", "image/jpeg"), + "audio": ("mp3", "audio/mpeg"), + "voice": ("ogg", "audio/ogg"), + "video": ("mp4", "video/mp4"), + "animation": ("mp4", "video/mp4"), + "video_note": ("mp4", "video/mp4"), + "sticker": ("webp", "image/webp"), + "document": ("bin", "application/octet-stream"), +} + +DEFAULT_EXT = "bin" +DEFAULT_MIME = "application/octet-stream" + + +def canonical_media_type(*, attr: str | None = None, media: object | None = None) -> str: + """Resolve a canonical media key from a message attribute or media object.""" + if attr and attr in _ATTR_TO_MEDIA_TYPE: + return _ATTR_TO_MEDIA_TYPE[attr] + if media is not None: + return _CLASS_TO_MEDIA_TYPE.get(type(media).__name__.lower(), "document") + return "document" + + +def ext_for(media_key: str) -> str: + return _MEDIA_EXT_MIME.get(media_key, (DEFAULT_EXT, DEFAULT_MIME))[0] + + +def mime_for(media_key: str) -> str: + return _MEDIA_EXT_MIME.get(media_key, (DEFAULT_EXT, DEFAULT_MIME))[1] + + +def ext_and_mime_for_class(class_name_lower: str) -> tuple[str, str]: + """Direct lookup by pyrogram class name (``videonote``, ``photo``, ...).""" + key = _CLASS_TO_MEDIA_TYPE.get(class_name_lower) + if key is None: + return DEFAULT_EXT, DEFAULT_MIME + return _MEDIA_EXT_MIME.get(key, (DEFAULT_EXT, DEFAULT_MIME)) + + +__all__ = [ + "canonical_media_type", + "ext_for", + "mime_for", + "ext_and_mime_for_class", + "DEFAULT_EXT", + "DEFAULT_MIME", +] diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py index c98ad3b..9866532 100755 --- a/Thunder/utils/messages.py +++ b/Thunder/utils/messages.py @@ -5,7 +5,9 @@ # ===================================================================================== # ------ General Errors ------ -MSG_ERROR_GENERIC = "⚠️ **Oops!** Something went wrong. Please try again. If the issue persists, contact support." +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 ------ @@ -17,17 +19,23 @@ 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." +# H7/M12: fail-closed + private-mode surfaces +MSG_ERROR_TEMP = ( + "⚠️ **Temporary service error.** Access checks are unavailable right now, " + "so your request was rejected. Please try again in a few minutes." +) +MSG_PRIVATE_MODE_DENIED = ( + "πŸ”’ **Private bot.** This instance is restricted to authorized users. " + "If you believe you should have access, contact the owner." +) + # ------ 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_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." + "❌ **Deauthorization Failed:** User `{user_id}` was not authorized or an error occurred." ) MSG_TOKEN_FAILED = ( "⚠️ **Token Activation Failed!**\n\n" @@ -39,10 +47,13 @@ # ------ 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_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_DC_FILE_ERROR = ( + "βš™οΈ **Error Getting File DC Info:** Could not fetch details. File might be inaccessible." +) MSG_STATUS_ERROR = "❌ **Status Error:** Could not retrieve system status." MSG_DB_ERROR = "❌ **Database Error:** Could not retrieve user count." MSG_CRITICAL_ERROR = ( @@ -55,20 +66,22 @@ # ====== ADMIN MESSAGES ====== # ===================================================================================== -# ------ Ban/Unban ------ -MSG_DECORATOR_BANNED = "You are currently banned and cannot use this bot.\nReason: {reason}\nBanned on: {ban_time}" +# ------ Ban/Unban (HTML for reason surfaces: reason is user-controlled, M7) ------ +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_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_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_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.**" @@ -76,30 +89,30 @@ MSG_AUTHORIZE_USAGE = "πŸ”‘ **Usage:** `/authorize `" MSG_DEAUTHORIZE_USAGE = "πŸ”’ **Usage:** `/deauthorize `" MSG_AUTHORIZE_SUCCESS = ( - "βœ… **User Authorized!**\n\n" - "> πŸ‘€ User ID: `{user_id}`\n" - "> πŸ”‘ Access: Permanent" + "βœ… **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" + "βœ… **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_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" + β€’ 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 (guarded by ENABLE_SHELL, L10) ------ +MSG_SHELL_USAGE = "Usage:\n/shell \n\nExample:\n/shell ls -l" +MSG_SHELL_DISABLED = ( + "β›” Shell is disabled.\n\n" + "Set ENABLE_SHELL=True in the environment to enable this " + "owner-only command." ) MSG_SHELL_EXECUTING = "Executing Command... βš™οΈ\n
{command}
" MSG_SHELL_OUTPUT = """**Shell Command Output:** @@ -138,62 +151,65 @@ # ====== COMMAND RESPONSES (User-facing) ====== # ===================================================================================== +# M7: welcome/help/about are HTML and interpolate html.escape()d values. MSG_WELCOME = ( - "🌟 **Welcome, {user_name}!** 🌟\n\n" - "I'm **Thunder File to Link Bot** ⚑\n" + "🌟 Welcome, {user_name}! 🌟\n\n" + "I'm Thunder File to Link Bot ⚑\n" "I generate direct download and streaming links for your files.\n\n" - "**How to use:**\n" + "How to use:\n" "1. Send any file to me for private links.\n" - "2. In groups, reply to a file with `/link`.\n\n" - "Β» Use `/help` for all commands and detailed information.\n\n" + "2. In groups, reply to a file with /link.\n\n" + "Β» Use /help for all commands and detailed information.\n\n" "πŸš€ Send a file to begin!" ) -MSG_HELP = ( - "πŸ“˜ **Thunder Bot - Help Guide** πŸ“–\n\n" - "How to get direct download & streaming links:\n\n" - "**πŸš€ Private Chat (with me):**\n" - "> 1. Send me **any file** (document, video, audio, photo, etc.).\n" +MSG_HELP_INTRO = ( + "πŸ“˜ Thunder Bot - Help Guide πŸ“–\n\n" + "How to get direct download & streaming links:\n\n" + "πŸš€ Private Chat (with me):\n" + "> 1. Send me any file (document, video, audio, photo, etc.).\n" "> 2. I'll instantly reply with your links! ⚑\n\n" - "**πŸ‘₯ Using in Groups:**\n" - "> β€’ Reply to any file with `/link`.\n" - "> β€’ **Batch Mode:** Reply to the **first** file with `/link ` (e.g., `/link 5` for 5 files, up to {max_files}).\n" + "πŸ‘₯ Using in Groups:\n" + "> β€’ Reply to any file with /link.\n" + "> β€’ Batch Mode: Reply to the first file with /link <number> " + "(e.g., /link 5 for 5 files, up to {max_files}).\n" "> β€’ Bot needs administrator rights in the group to function.\n" - "> β€’ Links are posted in the group & sent to you privately.\n\n" - "**πŸ“’ Using in Channels:**\n" + "> β€’ Links are posted in the group & sent to you privately.\n\n" + "πŸ“’ Using in Channels:\n" "> β€’ Add me as an administrator with necessary permissions.\n" "> β€’ I can be configured to auto-detect new media files.\n" "> β€’ Inline stream/download buttons can be added to files automatically.\n" "> β€’ Files from banned channels (owner configuration) are rejected.\n" - "> β€’ Auto-posting links if the bot has admin privileges with delete rights.\n\n" - "**βš™οΈ Available Commands:**\n" - "> `/start` πŸ‘‹ - Welcome message & quick start information.\n" - "> `/help` πŸ“– - Shows this help message.\n" - "> `/link ` πŸ”— - (Groups) Generate links. \n" - "> `/about` ℹ️ - Learn more about me and my features.\n" - "> `/ping` πŸ“‘ - Check my responsiveness and online status.\n" - "> `/dc` 🌍 - View DC information (for yourself, another user, or a file).\n\n" - "**πŸ’‘ Pro Tips:**\n" + "> β€’ Auto-posting links if the bot has admin privileges with delete rights.\n" +) + +# M1: the commands section is generated from bot/registry.py. +MSG_HELP_COMMANDS_HEADER = "\nβš™οΈ Available Commands:\n" +MSG_HELP_COMMAND_ROW = "> /{name} - {description}\n" + +MSG_HELP_TIPS = ( + "\nπŸ’‘ Pro Tips:\n" "> β€’ You can forward files from other chats directly to me.\n" "> β€’ If you encounter a rate limit message, please wait the specified time. ⏳\n" - "> β€’ For `/link` in groups to work reliably (and for private link delivery), ensure you've started a private chat with me first.\n" + "> β€’ For /link in groups to work reliably (and for private link delivery), " + "ensure you've started a private chat with me first.\n" "> β€’ Processing batch files might take a bit longer. Please be patient. 🐌\n\n" "❓ Questions? Please ask in our support group!" ) MSG_ABOUT = ( - "🌟 **About Thunder File to Link Bot** ℹ️\n\n" - "I'm your go-to bot for **instant download & streaming!** ⚑\n\n" - "**πŸš€ Key Features:**\n" - "> **Instant Links:** Get your links within seconds.\n" - "> **Online Streaming:** Watch videos or listen to audio directly (for supported formats).\n" - "> **Universal File Support:** Handles documents, videos, audio, photos, and more.\n" - "> **High-Speed Access:** Optimized for fast link generation and file access.\n" - "> **Secure & Reliable:** Your files are handled with care during processing.\n" - "> **User-Friendly Interface:** Designed for ease of use on any device.\n" - "> **Efficient Processing:** Built for speed and reliability.\n" - "> **Batch Mode:** Process multiple files at once in groups using `/link `.\n" - "> **Versatile Usage:** Works in private chats, groups, and channels (with admin setup).\n\n" + "🌟 About Thunder File to Link Bot ℹ️\n\n" + "I'm your go-to bot for instant download & streaming! ⚑\n\n" + "πŸš€ Key Features:\n" + "> Instant Links: Get your links within seconds.\n" + "> Online Streaming: Watch videos or listen to audio directly (for supported formats).\n" + "> Universal File Support: Handles documents, videos, audio, photos, and more.\n" + "> High-Speed Access: Optimized for fast link generation and file access.\n" + "> Secure & Reliable: Your files are handled with care during processing.\n" + "> User-Friendly Interface: Designed for ease of use on any device.\n" + "> Efficient Processing: Built for speed and reliability.\n" + "> Batch Mode: Process multiple files at once in groups using /link <number>.\n" + "> Versatile Usage: Works in private chats, groups, and channels (with admin setup).\n\n" "πŸ’– If you find me useful, please consider sharing me with your friends!" ) @@ -223,27 +239,31 @@ MSG_DC_UNKNOWN = "Unknown" -# ------ File Link Generation ------ -MSG_DM_SINGLE_PREFIX = "πŸ“¬ **From {chat_title}**\n" +# ------ File Link Generation (HTML: file names are user-controlled, M7) ------ +MSG_DM_SINGLE_PREFIX = "πŸ“¬ From {chat_title}\n" MSG_LINKS = ( - "✨ **Your Links are Ready!** ✨\n\n" - "> `{file_name}`\n\n" - "πŸ“‚ **File Size:** `{file_size}`\n\n" - "πŸš€ **Download Link:**\n`{download_link}`\n\n" - "πŸ–₯️ **Stream Link:**\n`{stream_link}`\n\n" - "βŒ›οΈ **Note: Links remain active while the bot is running and the file is accessible.**" + "✨ Your Links are Ready! ✨\n\n" + "> {file_name}\n\n" + "πŸ“‚ File Size: {file_size}\n\n" + "πŸš€ Download Link:\n{download_link}\n\n" + "πŸ–₯️ Stream Link:\n{stream_link}\n\n" + "βŒ›οΈ Note: Links remain active while the bot is running and the file is accessible." ) +# L2: appended to link messages only when FILE_TTL_DAYS > 0 +MSG_FILE_EXPIRY_NOTE = "⏳ Files expire after {days} of inactivity." +MSG_FILE_TTL_DAYS_LABEL = "{days} day(s)" + # ===================================================================================== # ====== USER NOTIFICATIONS ====== # ===================================================================================== MSG_NEW_USER = ( - "✨ **New User Alert!** ✨\n" - "> πŸ‘€ **Name:** [{first_name}](tg://user?id={user_id})\n" - "> πŸ†” **User ID:** `{user_id}`\n\n" + "✨ New User Alert! ✨\n" + '> πŸ‘€ Name: {first_name}\n' + "> πŸ†” User ID: {user_id}\n\n" ) -MSG_COMMUNITY_CHANNEL = "πŸ“’ **{channel_title}:** πŸ”’ Join this channel to use the bot." +MSG_COMMUNITY_CHANNEL = "πŸ“’ {channel_title}: πŸ”’ Join this channel to use the bot." # ===================================================================================== # ====== PROCESSING MESSAGES ====== @@ -253,18 +273,20 @@ MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**" MSG_PROCESSING_FILE = "⏳ **Processing your file...**" MSG_NEW_FILE_REQUEST = ( - "> πŸ‘€ **Source:** [{source_info}](tg://user?id={id_})\n" - "> πŸ†” **ID:** `{id_}`\n\n" - "πŸš€ **Download:** `{online_link}`\n\n" - "πŸ–₯️ **Stream:** `{stream_link}`" + '> πŸ‘€ Source: {source_info}\n' + "> πŸ†” ID: {id_}\n\n" + "πŸš€ Download: {online_link}\n\n" + "πŸ–₯️ Stream: {stream_link}" ) -# ------ Batch Processing ------ +# ------ Batch Processing (M4b: skipped counts non-media files) ------ MSG_PROCESSING_BATCH = "♻️ **Processing Batch {batch_number}/{total_batches}** ({file_count} files)" MSG_PROCESSING_STATUS = "πŸ“Š **Processing Files:** {processed}/{total} complete, {failed} failed" MSG_BATCH_LINKS_READY = "πŸ”— Here are your {count} download links:" -MSG_DM_BATCH_PREFIX = "πŸ“¬ **Batch Links from {chat_title}**\n" -MSG_PROCESSING_RESULT = "βœ… **Process Complete:** {processed}/{total} files processed successfully, {failed} failed" +MSG_DM_BATCH_PREFIX = "πŸ“¬ Batch Links from {chat_title}\n" +MSG_PROCESSING_RESULT = ( + "βœ… **Process Complete:** {processed}/{total} files processed successfully, {failed} failed" +) # ===================================================================================== # ====== BROADCAST MESSAGES ====== @@ -279,7 +301,9 @@ "❌ **Failed Deliveries:** `{failures}`\n" "πŸ—‘οΈ **Accounts Removed (Blocked/Deactivated):** `{deleted_accounts}`\n" ) -MSG_BROADCAST_CANCEL = "πŸ›‘ **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations..." +MSG_BROADCAST_CANCEL = ( + "πŸ›‘ **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations..." +) MSG_INVALID_BROADCAST_CMD = "Please reply to the message you want to broadcast." MSG_BROADCAST_USAGE = ( "πŸ“£ **Broadcast Command Usage:**\n\n" @@ -297,6 +321,9 @@ MSG_ERROR_BROADCAST_RESTART = "Please use the /broadcast command to start a new broadcast." MSG_ERROR_BROADCAST_INSTRUCTION = "To start a new broadcast, use the /broadcast command and reply to the message you want to broadcast." MSG_ERROR_CALLBACK_UNSUPPORTED = "This button is not active or no longer supported." +MSG_ERROR_CLOSE_NOT_ALLOWED = ( + "⚠️ Only the person who triggered this panel (or the owner) can close it." +) # ===================================================================================== # ====== RATE LIMITING MESSAGES ====== @@ -349,31 +376,6 @@ "> ♻️ **Version:** `{version}`" ) -# ------ Speedtest Messages ------ -MSG_SPEEDTEST_INIT = "πŸš€ **Running Speed Test...**" -MSG_SPEEDTEST_ERROR = "❌ **Speed Test Failed!**\n\n> Unable to complete the speed test. Please try again later." -MSG_SPEEDTEST_RESULT = ( - "⚑ **Speed Test Results**\n\n" - "**SPEEDTEST INFO:**\n" - "> **Download:** `{download_mbps} Mbps` (`{download_bps}/s`)\n" - "> **Upload:** `{upload_mbps} Mbps` (`{upload_bps}/s`)\n" - "> **Ping:** `{ping} ms`\n" - "> **Timestamp:** `{timestamp}`\n" - "> **Data Sent:** `{bytes_sent}`\n" - "> **Data Received:** `{bytes_received}`\n\n" - "**SERVER INFO:**\n" - "> **Name:** `{server_name}`\n" - "> **Country:** `{server_country}`\n" - "> **Sponsor:** `{server_sponsor}`\n" - "> **Latency:** `{server_latency} ms`\n" - "> **Coordinates:** `{server_lat}, {server_lon}`\n\n" - "**CLIENT DETAILS:**\n" - "> **IP:** `{client_ip}`\n" - "> **Coordinates:** `{client_lat}, {client_lon}`\n" - "> **ISP:** `{client_isp}`\n" - "> **ISP Rating:** `{client_isprating}`\n" - "> **Country:** `{client_country}`" -) MSG_SYSTEM_STATS = ( "πŸ“Š **System Statistics**\n\n" "> System Uptime: {sys_uptime}\n" @@ -393,7 +395,8 @@ "> Free: `{free}`\n\n" "πŸ“Ά **Network:**\n" "> πŸ”Ί Upload: `{upload}`\n" - "> πŸ”» Download: `{download}`\n" + "> πŸ”» Download: `{download}`\n\n" + "🚦 **Limiter:** {limiter}" ) MSG_DB_STATS = "πŸ“Š **Database Statistics**\n\n> πŸ‘₯ **Total Users:** `{total_users}`" diff --git a/Thunder/utils/rate_limiter.py b/Thunder/utils/rate_limiter.py index 6ced23f..020c1fd 100755 --- a/Thunder/utils/rate_limiter.py +++ b/Thunder/utils/rate_limiter.py @@ -1,48 +1,111 @@ # Thunder/utils/rate_limiter.py -import time -import math +"""Queue + rate limiting (plan H6). + +Protected UX (report Β§4.2 keep-list): users still get the queue / +wait-estimate messages; only the internals changed. + +H6a: bounded structures + periodic sweep (no unbounded deques/dicts). +H6b: a small worker pool replaces the single executor, so one user's long +FloodWait no longer stalls every other queued request; the sliding window +is charged at *execution* time (the old code charged at enqueue AND again +inside the executor, double-charging every queued request); FloodWait +inside a worker requeues the request with an attempt counter instead of +sleeping the worker. +H6c: a global RPS token-bucket breaker (Β§5.1b) sheds bursts before they all +hit Telegram-side FLOOD_WAIT at once. +""" + import asyncio +import math +import time from collections import deque -from typing import Callable, Dict, Optional, Tuple +from collections.abc import Callable + from pyrogram import Client -from pyrogram.types import Message from pyrogram.errors import FloodWait, RPCError +from pyrogram.types import Message + from Thunder.utils.logger import logger -from Thunder.utils.database import db from Thunder.utils.messages import ( + MSG_RATE_LIMIT_QUEUE_FULL, MSG_RATE_LIMIT_QUEUE_PRIORITY, MSG_RATE_LIMIT_QUEUE_REGULAR, - MSG_RATE_LIMIT_QUEUE_FULL ) +from Thunder.utils.safe_call import edit_safe, send_safe +from Thunder.utils.tokens import allowed from Thunder.vars import Var +# A queued request is requeued at most this many times (FloodWait storms, +# breaker shedding) before it is dropped and the user notified. +MAX_REQUEST_ATTEMPTS = 5 +# Bounded bookkeeping +MAX_TRACKED_USERS = 4096 +MAX_TRACKED_FILES = 1024 + class QueueFullError(Exception): pass +class TokenBucket: + """Non-blocking RPS token bucket (global circuit breaker, H6c).""" + + def __init__(self, rate_per_second: float, burst_multiplier: float = 2.0): + self.rate = max(rate_per_second, 0.0) + self.burst = max(self.rate * burst_multiplier, 1.0) + self._tokens = self.burst + self._updated = time.monotonic() + + def _refill(self) -> None: + now = time.monotonic() + self._tokens = min(self.burst, self._tokens + (now - self._updated) * self.rate) + self._updated = now + + def allow(self) -> bool: + if self.rate <= 0: + return True + self._refill() + if self._tokens >= 1.0: + self._tokens -= 1.0 + return True + return False + + def retry_after(self) -> float: + if self.rate <= 0: + return 0.0 + self._refill() + if self._tokens >= 1.0: + return 0.0 + return (1.0 - self._tokens) / self.rate + + class RateLimiter: def __init__(self): - self.request_queue: deque = deque() - self.priority_queue: deque = deque() - self.user_queue_counts: Dict[int, int] = {} + self.request_queue: deque[dict] = deque() + self.priority_queue: deque[dict] = deque() + self.user_queue_counts: dict[int, int] = {} self.request_event: asyncio.Event = asyncio.Event() self.request_lock: asyncio.Lock = asyncio.Lock() - self.user_requests: Dict[int, deque] = {} - self.global_requests: deque = deque() + self.user_requests: dict[int, deque[float]] = {} + self.global_requests: deque[float] = deque() - self.processing_times: deque = deque(maxlen=100) - self.file_processing_times: Dict[str, deque] = {} + self.processing_times: deque[float] = deque(maxlen=100) + self.file_processing_times: dict[str, deque[float]] = {} self.average_processing_time: float = 1.0 - self.auth_cache: Dict[int, Tuple[bool, float]] = {} - self.auth_cache_ttl_seconds: int = 300 - self._initialization_error = False self._load_configuration() + self.breaker = TokenBucket(self._breaker_rate()) + + def _breaker_rate(self) -> float: + if Var.GLOBAL_RPS_LIMIT and Var.GLOBAL_RPS_LIMIT > 0: + return Var.GLOBAL_RPS_LIMIT + if self.global_rate_limit_enabled and self.max_global_requests_per_minute > 0: + return self.max_global_requests_per_minute / 60.0 + return 0.0 def _load_configuration(self): try: @@ -57,14 +120,18 @@ def _load_configuration(self): logger.warning("Rate limiter disabled due to invalid configuration.") self.enabled = False else: - logger.debug(f"Rate limiter initialized: enabled={self.enabled}, " - f"max_requests={self.max_requests_per_period}, " - f"period={self.rate_limit_period_seconds}s, " - f"queue_size={self.max_queue_size}, " - f"global_enabled={self.global_rate_limit_enabled}, " - f"max_global_requests={self.max_global_requests_per_minute}") + logger.debug( + f"Rate limiter initialized: enabled={self.enabled}, " + f"max_requests={self.max_requests_per_period}, " + f"period={self.rate_limit_period_seconds}s, " + f"queue_size={self.max_queue_size}, " + f"global_enabled={self.global_rate_limit_enabled}, " + f"max_global_requests={self.max_global_requests_per_minute}" + ) except Exception as e: - logger.critical(f"Critical error initializing rate limiter, using safe defaults: {e}", exc_info=True) + logger.critical( + f"Critical error initializing rate limiter, using safe defaults: {e}", exc_info=True + ) self.max_requests_per_period = 5 self.rate_limit_period_seconds = 60 self.max_queue_size = 100 @@ -85,7 +152,9 @@ def _validate_configuration(self) -> bool: logger.error("Invalid MAX_QUEUE_SIZE: must be > 0.") is_valid = False if self.global_rate_limit_enabled and self.max_global_requests_per_minute <= 0: - logger.error("Invalid MAX_GLOBAL_REQUESTS_PER_MINUTE: must be > 0 when global rate limit is enabled.") + logger.error( + "Invalid MAX_GLOBAL_REQUESTS_PER_MINUTE: must be > 0 when global rate limit is enabled." + ) is_valid = False return is_valid @@ -93,27 +162,18 @@ def is_owner(self, user_id: int) -> bool: return user_id == Var.OWNER_ID async def is_authorized_user(self, user_id: int) -> bool: - current_time = time.time() - if user_id in self.auth_cache: - is_auth, timestamp = self.auth_cache[user_id] - if current_time - timestamp < self.auth_cache_ttl_seconds: - return is_auth - try: - authorized_user = await db.authorized_users_col.find_one({"user_id": user_id}) - is_auth = bool(authorized_user) - self.auth_cache[user_id] = (is_auth, current_time) - return is_auth + return await allowed(user_id) except Exception as e: logger.error(f"Database error checking authorized user {user_id}: {e}") return False async def get_user_priority(self, user_id: int) -> str: if self.is_owner(user_id): - return 'owner' + return "owner" if await self.is_authorized_user(user_id): - return 'authorized' - return 'regular' + return "authorized" + return "regular" async def check_limits(self, user_id: int, record: bool = True) -> bool: if not self.enabled or self._initialization_error or self.is_owner(user_id): @@ -128,7 +188,9 @@ async def check_limits(self, user_id: int, record: bool = True) -> bool: return False user_timestamps = self.user_requests.setdefault(user_id, deque()) - while user_timestamps and user_timestamps[0] <= current_time - self.rate_limit_period_seconds: + while ( + user_timestamps and user_timestamps[0] <= current_time - self.rate_limit_period_seconds + ): user_timestamps.popleft() if len(user_timestamps) >= self.max_requests_per_period: return False @@ -139,24 +201,96 @@ async def check_limits(self, user_id: int, record: bool = True) -> bool: user_timestamps.append(current_time) return True - async def _requeue_request(self, request_data: dict, queue_type: str): + # ---------------- sweep (H6a) ---------------- + + async def sweep(self) -> dict[str, int]: + """Prune stale bookkeeping; called every 5 min from the sweeper task.""" + now = time.time() + dropped_users = 0 + for user_id in list(self.user_requests.keys()): + stamps = self.user_requests[user_id] + while stamps and stamps[0] <= now - self.rate_limit_period_seconds: + stamps.popleft() + if not stamps: + self.user_requests.pop(user_id, None) + dropped_users += 1 + if len(self.user_requests) > MAX_TRACKED_USERS: + for user_id, stamps in list(self.user_requests.items()): + if len(self.user_requests) <= MAX_TRACKED_USERS: + break + if not stamps: + continue + self.user_requests.pop(user_id, None) + + dropped_global = 0 + while self.global_requests and self.global_requests[0] <= now - 60: + self.global_requests.popleft() + dropped_global += 1 + + dropped_files = 0 + if len(self.file_processing_times) > MAX_TRACKED_FILES: + for key in list(self.file_processing_times.keys()): + if len(self.file_processing_times) <= MAX_TRACKED_FILES: + break + self.file_processing_times.pop(key, None) + dropped_files += 1 + + dropped_counts = 0 + for user_id, count in list(self.user_queue_counts.items()): + if count <= 0: + self.user_queue_counts.pop(user_id, None) + dropped_counts += 1 + + return { + "user_windows": dropped_users, + "global_entries": dropped_global, + "file_entries": dropped_files, + "stale_counts": dropped_counts, + } + + def occupancy(self) -> dict[str, int]: + """Limiter occupancy for /stats (plan PR-13).""" + return { + "queued": len(self.request_queue) + len(self.priority_queue), + "tracked_users": len(self.user_requests), + "global_window": len(self.global_requests), + "breaker_tokens": round(max(self.breaker._tokens, 0.0), 2), + } + + # ---------------- queueing ---------------- + + async def _requeue_request(self, request_data: dict, queue_type: str, delay: float = 0.0): + if delay > 0: + request_data["not_before"] = time.time() + delay async with self.request_lock: if queue_type == "priority": self.priority_queue.appendleft(request_data) else: self.request_queue.appendleft(request_data) self.request_event.set() - logger.debug(f"Re-queued request for user {request_data['user_id']} to {queue_type} queue.") - - async def add_to_queue(self, func: Callable, user_id: int, file_identifier: Optional[str] = None, *args, **kwargs): + logger.debug( + f"Re-queued request for user {request_data['user_id']} to {queue_type} queue (delay={delay:.2f}s)." + ) + + async def add_to_queue( + self, func: Callable, user_id: int, file_identifier: str | None = None, *args, **kwargs + ): + """Queue a request. The sliding window is NOT charged here -- it is + charged at execution time (H6b charge-at-exec).""" if not self.enabled: await func(*args, **kwargs) return request_data = { - 'func': func, 'user_id': user_id, 'args': args, 'kwargs': kwargs, - 'timestamp': time.time(), 'user_priority': await self.get_user_priority(user_id), - 'file_identifier': file_identifier + "func": func, + "user_id": user_id, + "args": args, + "kwargs": kwargs, + "timestamp": time.time(), + "user_priority": await self.get_user_priority(user_id), + "file_identifier": file_identifier, + "attempts": 0, + "not_before": 0.0, } async with self.request_lock: @@ -164,7 +298,7 @@ async def add_to_queue(self, func: Callable, user_id: int, file_identifier: Opti if total_queued >= self.max_queue_size: raise QueueFullError("Queue is full") - if request_data['user_priority'] == 'authorized': + if request_data["user_priority"] == "authorized": self.priority_queue.append(request_data) queue_name = "priority" else: @@ -172,62 +306,119 @@ async def add_to_queue(self, func: Callable, user_id: int, file_identifier: Opti queue_name = "regular" self.user_queue_counts[user_id] = self.user_queue_counts.get(user_id, 0) + 1 - logger.debug(f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}") + logger.debug( + f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}" + ) self.request_event.set() + # ---------------- executor (H6b) ---------------- + + async def _process_one(self) -> bool: + """Pop and process a single request. Returns True when something was + handled (so the worker loop does not spin on an empty event).""" + async with self.request_lock: + if self.priority_queue: + queue, queue_type = self.priority_queue, "priority" + elif self.request_queue: + queue, queue_type = self.request_queue, "regular" + else: + self.request_event.clear() + return False + request_data = queue.popleft() + + now = time.time() + if request_data.get("not_before", 0.0) > now: + # deferred: rotate to the right so other requests can proceed + async with self.request_lock: + queue.append(request_data) + return True + + user_id = request_data["user_id"] + + # charge-at-exec: the sliding window is charged exactly once, here. + if not self.is_owner(user_id): + if not await self.check_limits(user_id, record=True): + wait = self._calculate_user_rate_limit_wait(user_id, now) + if self.global_rate_limit_enabled: + wait = max(wait, self._calculate_global_rate_limit_wait(now)) + wait = min(max(wait, 1.0), self.rate_limit_period_seconds) + await self._requeue_request(request_data, queue_type, delay=wait) + return True + if self.global_rate_limit_enabled and not self.breaker.allow(): + retry = max(self.breaker.retry_after(), 0.5) + await self._requeue_request(request_data, queue_type, delay=retry) + return True + + logger.debug(f"Processing request for user {user_id} from {queue_type} queue.") + start_time = time.time() + processed = False + try: + await request_data["func"](*request_data["args"], **request_data["kwargs"]) + processing_time = time.time() - start_time + self.processing_times.append(processing_time) + if self.processing_times: + self.average_processing_time = sum(self.processing_times) / len( + self.processing_times + ) + + file_identifier = request_data.get("file_identifier") + if file_identifier: + file_times = self.file_processing_times.setdefault( + file_identifier, deque(maxlen=100) + ) + file_times.append(processing_time) + processed = True + + except FloodWait as e: + # H6b: requeue with an attempt counter instead of stalling the + # whole worker pool with a sleep. + attempts = request_data.get("attempts", 0) + 1 + request_data["attempts"] = attempts + if attempts > MAX_REQUEST_ATTEMPTS: + logger.warning( + f"Dropping request for user {user_id} after {attempts} " + f"FloodWait requeues (last wait {e.value}s)." + ) + processed = True # leave the queue + await self._notify_drop(request_data) + else: + logger.warning(f"FloodWait for user {user_id}, requeueing (attempt {attempts}).") + await self._requeue_request(request_data, queue_type, delay=min(e.value, 300.0)) + except Exception as e: + logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) + processed = True + finally: + if processed: + async with self.request_lock: + if user_id in self.user_queue_counts: + self.user_queue_counts[user_id] -= 1 + if self.user_queue_counts[user_id] <= 0: + self.user_queue_counts.pop(user_id, None) + return True + + async def _notify_drop(self, request_data: dict) -> None: + notification_msg = request_data["kwargs"].get("notification_msg") + if notification_msg is None: + return + try: + await edit_safe( + notification_msg, + "⚠️ Service is busy and your request could not be completed. Please try again in a few minutes.", + ) + except Exception: + logger.debug("Could not notify user about dropped request", exc_info=True) + async def request_executor(self): - logger.debug("Request executor started.") + """One consumer; start :data:`Var.EXUTOR_WORKERS` -- see start_executors().""" + logger.debug("Request executor worker started.") while True: try: await self.request_event.wait() - - async with self.request_lock: - queue, queue_type = (self.priority_queue, "priority") if self.priority_queue else (self.request_queue, "regular") - if not queue: - self.request_event.clear() - continue - request_data = queue.popleft() - - user_id = request_data['user_id'] - processed = False - if not self.is_owner(user_id): - if not await self.check_limits(user_id, record=True): - await self._requeue_request(request_data, queue_type) - await asyncio.sleep(0.5) - continue - - logger.debug(f"Processing request for user {user_id} from {queue_type} queue.") - start_time = time.time() - try: - await request_data['func'](*request_data['args'], **request_data['kwargs']) - processing_time = time.time() - start_time - self.processing_times.append(processing_time) - if self.processing_times: - self.average_processing_time = sum(self.processing_times) / len(self.processing_times) - - file_identifier = request_data.get('file_identifier') - if file_identifier: - file_times = self.file_processing_times.setdefault(file_identifier, deque(maxlen=100)) - file_times.append(processing_time) - - processed = True - - except FloodWait as e: - logger.warning(f"FloodWait for user {user_id}, waiting {e.value}s before re-queuing.") - await asyncio.sleep(e.value) - await self._requeue_request(request_data, queue_type) - except Exception as e: - logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) - processed = True - finally: - async with self.request_lock: - if processed and user_id in self.user_queue_counts: - self.user_queue_counts[user_id] -= 1 - if self.user_queue_counts[user_id] <= 0: - self.user_queue_counts.pop(user_id, None) - + handled = await self._process_one() + if not handled: + await asyncio.sleep(0.05) except asyncio.CancelledError: - logger.debug("Request executor cancelled, shutting down.") + logger.debug("Request executor worker cancelled, shutting down.") break except Exception as e: logger.critical(f"Critical error in request executor: {e}", exc_info=True) @@ -242,40 +433,34 @@ async def shutdown(self): self.request_event.clear() logger.debug("Rate limiter queues cleared.") - def get_queue_status(self) -> dict: - return { - 'regular_queue_size': len(self.request_queue), - 'priority_queue_size': len(self.priority_queue), - 'total_queued': len(self.request_queue) + len(self.priority_queue), - 'max_queue_size': self.max_queue_size, - 'active_users_in_queue': len(self.user_queue_counts), - 'enabled': self.enabled, - } + # ---------------- estimates (protected UX) ---------------- async def get_user_queue_position(self, user_id: int) -> dict: user_priority = await self.get_user_priority(user_id) position = -1 - queue_to_search = self.priority_queue if user_priority == 'authorized' else self.request_queue - + queue_to_search = ( + self.priority_queue if user_priority == "authorized" else self.request_queue + ) + for idx, req in enumerate(queue_to_search): - if req.get('user_id') == user_id: + if req.get("user_id") == user_id: position = idx + 1 break effective_position = position - if user_priority == 'regular' and position > -1: + if user_priority == "regular" and position > -1: effective_position += len(self.priority_queue) return { - 'user_priority': user_priority, - 'position_in_own_queue': position if position > -1 else None, - 'effective_position': effective_position if effective_position > -1 else None, - 'priority_queue_size': len(self.priority_queue), - 'regular_queue_size': len(self.request_queue), - 'bypasses_rate_limit': user_priority == 'owner' + "user_priority": user_priority, + "position_in_own_queue": position if position > -1 else None, + "effective_position": effective_position if effective_position > -1 else None, + "priority_queue_size": len(self.priority_queue), + "regular_queue_size": len(self.request_queue), + "bypasses_rate_limit": user_priority == "owner", } - def _get_base_processing_time(self, file_identifier: Optional[str]) -> float: + def _get_base_processing_time(self, file_identifier: str | None) -> float: if file_identifier and file_identifier in self.file_processing_times: file_times = self.file_processing_times[file_identifier] if file_times: @@ -284,12 +469,14 @@ def _get_base_processing_time(self, file_identifier: Optional[str]) -> float: async def _calculate_queue_wait(self, user_id: int, effective_processing_time: float) -> float: pos_info = await self.get_user_queue_position(user_id) - items_ahead = (pos_info['effective_position'] - 1) if pos_info['effective_position'] else 0 + items_ahead = (pos_info["effective_position"] - 1) if pos_info["effective_position"] else 0 return items_ahead * effective_processing_time def _calculate_user_rate_limit_wait(self, user_id: int, future_time: float) -> float: user_timestamps = self.user_requests.get(user_id, deque()) - future_user_timestamps = deque(ts for ts in user_timestamps if ts > future_time - self.rate_limit_period_seconds) + future_user_timestamps = deque( + ts for ts in user_timestamps if ts > future_time - self.rate_limit_period_seconds + ) if len(future_user_timestamps) >= self.max_requests_per_period: reset_time = future_user_timestamps[0] + self.rate_limit_period_seconds @@ -301,21 +488,25 @@ def _calculate_global_rate_limit_wait(self, future_time: float) -> float: return 0.0 future_global_requests = deque(ts for ts in self.global_requests if ts > future_time - 60) - + if len(future_global_requests) >= self.max_global_requests_per_minute: oldest_request_time = future_global_requests[0] reset_time = oldest_request_time + 60 return max(0.0, reset_time - future_time) return 0.0 - async def estimate_wait_time(self, user_id: int, file_identifier: Optional[str] = None) -> float: + async def estimate_wait_time(self, user_id: int, file_identifier: str | None = None) -> float: if self.is_owner(user_id): return 0.0 base_processing_time = self._get_base_processing_time(file_identifier) - min_time_per_request = self.rate_limit_period_seconds / self.max_requests_per_period if self.max_requests_per_period > 0 else 0 + min_time_per_request = ( + self.rate_limit_period_seconds / self.max_requests_per_period + if self.max_requests_per_period > 0 + else 0 + ) effective_processing_time = max(base_processing_time, min_time_per_request) - + if self.global_rate_limit_enabled and self.max_global_requests_per_minute > 0: min_time_per_global = 60 / self.max_global_requests_per_minute effective_processing_time = max(effective_processing_time, min_time_per_global) @@ -336,9 +527,27 @@ async def request_executor(): await rate_limiter.request_executor() -async def handle_rate_limited_request(bot: Client, message: Message, handler: Callable, *args, **kwargs): - rl_user_id = kwargs.pop('rl_user_id', None) - user_id = rl_user_id if rl_user_id is not None else (message.from_user.id if message and message.from_user else None) +def start_executors() -> list[asyncio.Task]: + """Start the worker pool (H6b) -- callers keep the tasks for shutdown.""" + workers: list[asyncio.Task] = [] + for i in range(max(1, int(getattr(Var, "EXECUTOR_WORKERS", 5)))): + workers.append( + asyncio.create_task( + rate_limiter.request_executor(), name=f"request_executor_worker_{i}" + ) + ) + return workers + + +async def handle_rate_limited_request( + bot: Client, message: Message, handler: Callable, *args, **kwargs +): + rl_user_id = kwargs.pop("rl_user_id", None) + user_id = ( + rl_user_id + if rl_user_id is not None + else (message.from_user.id if message and message.from_user else None) + ) if not isinstance(user_id, int): logger.error(f"Invalid user_id provided for rate limiting: {user_id}") return @@ -350,6 +559,14 @@ async def handle_rate_limited_request(bot: Client, message: Message, handler: Ca await handler(bot, message, *args, **kwargs) return + # H6c: global RPS breaker sheds bursts before they hit Telegram FLOOD_WAIT. + if rate_limiter.global_rate_limit_enabled and not rate_limiter.breaker.allow(): + logger.warning(f"Global RPS breaker engaged; shedding request for user {user_id}.") + if not (rl_user_id is not None and rl_user_id < 0): + await send_queue_full_message(bot, message, file_identifier) + return + + # Immediate path: executes right now, so charging here == charging at exec. if await rate_limiter.check_limits(user_id, record=True): logger.debug(f"User {user_id} within rate limits, executing immediately.") await handler(bot, message, *args, **kwargs) @@ -361,14 +578,19 @@ async def handle_rate_limited_request(bot: Client, message: Message, handler: Ca try: user_priority = await rate_limiter.get_user_priority(user_id) notification_msg = await send_queue_notification( - bot, message, is_priority=(user_priority == 'authorized'), file_identifier=file_identifier + bot, + message, + is_priority=(user_priority == "authorized"), + file_identifier=file_identifier, ) - kwargs['notification_msg'] = notification_msg + kwargs["notification_msg"] = notification_msg except Exception as e: logger.error(f"Error sending queue notification for user {user_id}: {e}", exc_info=True) try: - await rate_limiter.add_to_queue(handler, user_id, file_identifier, bot, message, *args, **kwargs) + await rate_limiter.add_to_queue( + handler, user_id, file_identifier, bot, message, *args, **kwargs + ) logger.debug(f"Request for user {user_id} queued.") except QueueFullError: logger.warning(f"Queue full, request for user {user_id} rejected.") @@ -380,28 +602,20 @@ async def handle_rate_limited_request(bot: Client, message: Message, handler: Ca await send_queue_full_message(bot, message, file_identifier) -async def _send_notification(bot: Client, message: Message, template: str, file_identifier: Optional[str], **format_kwargs): +async def _send_notification( + bot: Client, message: Message, template: str, file_identifier: str | None, **format_kwargs +): try: if message.from_user: user_id = message.from_user.id wait_seconds = await rate_limiter.estimate_wait_time(user_id, file_identifier) wait_estimate = max(1, math.ceil(wait_seconds / 60)) - text = template.format(wait_estimate=wait_estimate, s="s" if wait_estimate > 1 else "", **format_kwargs) + text = template.format( + wait_estimate=wait_estimate, s="s" if wait_estimate > 1 else "", **format_kwargs + ) - try: - return await bot.send_message( - chat_id=message.chat.id, - text=text, - reply_to_message_id=message.id - ) - except FloodWait as e: - await asyncio.sleep(e.value) - return await bot.send_message( - chat_id=message.chat.id, - text=text, - reply_to_message_id=message.id - ) + return await send_safe(bot, message.chat.id, text=text, reply_to_message_id=message.id) else: logger.debug("Skipping notification for channel message (no from_user)") return None @@ -413,7 +627,9 @@ async def _send_notification(bot: Client, message: Message, template: str, file_ return None -async def send_queue_notification(bot: Client, message: Message, is_priority: bool, file_identifier: Optional[str]): +async def send_queue_notification( + bot: Client, message: Message, is_priority: bool, file_identifier: str | None +): if is_priority: template = MSG_RATE_LIMIT_QUEUE_PRIORITY params = {} @@ -424,14 +640,16 @@ async def send_queue_notification(bot: Client, message: Message, is_priority: bo "max_requests": rate_limiter.max_requests_per_period, "time_window": time_window, "s1": "s" if rate_limiter.max_requests_per_period > 1 else "", - "s2": "s" if time_window > 1 else "" + "s2": "s" if time_window > 1 else "", } user_id = message.from_user.id if message.from_user else "channel" - logger.debug(f"Sending {'priority' if is_priority else 'regular'} queue notification to user {user_id}") + logger.debug( + f"Sending {'priority' if is_priority else 'regular'} queue notification to user {user_id}" + ) return await _send_notification(bot, message, template, file_identifier, **params) -async def send_queue_full_message(bot: Client, message: Message, file_identifier: Optional[str]): +async def send_queue_full_message(bot: Client, message: Message, file_identifier: str | None): user_id = message.from_user.id if message.from_user else "channel" logger.debug(f"Sending queue full message to user {user_id}") await _send_notification(bot, message, MSG_RATE_LIMIT_QUEUE_FULL, file_identifier) diff --git a/Thunder/utils/render_template.py b/Thunder/utils/render_template.py index 5c6ef1a..66de038 100755 --- a/Thunder/utils/render_template.py +++ b/Thunder/utils/render_template.py @@ -1,51 +1,126 @@ # Thunder/utils/render_template.py -import asyncio +import time import urllib.parse +from collections import OrderedDict +from pathlib import Path from jinja2 import Environment, FileSystemLoader, select_autoescape -from pyrogram.errors import FloodWait -from Thunder.bot import StreamBot -from Thunder.server.exceptions import InvalidHash from Thunder.utils.file_properties import get_fname, get_uniqid from Thunder.utils.logger import logger +from Thunder.utils.safe_call import tg_call from Thunder.vars import Var +# NOTE: ``Thunder.server.exceptions`` is imported lazily inside render_page() +# to avoid a circular import (server/__init__ -> stream_routes -> here). + +# M2: resolve templates relative to the package, not the process CWD -- +# the old ``'Thunder/template'`` loader only worked when CWD == repo root. +_TEMPLATE_DIR = Path(__file__).resolve().parent.parent / "template" + template_env = Environment( - loader=FileSystemLoader('Thunder/template'), + loader=FileSystemLoader(str(_TEMPLATE_DIR)), autoescape=select_autoescape(enabled_extensions=("html",), default_for_string=True), enable_async=True, cache_size=200, auto_reload=False, - optimized=True + optimized=True, ) -async def render_media_page(file_name: str, src: str, requested_action: str | None = None) -> str: + +def _page_kind(mime_type: str | None, file_name: str) -> str: + """M2: typed player page -- derive the layout from the mime type. + + A specific mime type is authoritative; extension sniffing only applies + when the mime type is missing or generic (application/octet-stream). + """ + mime = (mime_type or "").lower() + if mime.startswith("audio/"): + return "audio" + if mime.startswith("image/"): + return "image" + if mime.startswith("video/"): + return "video" + if mime and mime != "application/octet-stream": + return "other" + # fall back to extension sniffing when the mime type is missing/generic + ext = file_name.rsplit(".", 1)[-1].lower() if "." in file_name else "" + if ext in {"mp3", "m4a", "ogg", "opus", "wav", "flac", "aac"}: + return "audio" + if ext in {"jpg", "jpeg", "png", "gif", "webp", "bmp"}: + return "image" + if ext in {"mp4", "mkv", "webm", "mov", "avi", "m4v"}: + return "video" + return "other" + + +async def render_media_page( + file_name: str, + src: str, + requested_action: str | None = None, + mime_type: str | None = None, +) -> str: # NOTE: src must be a pre-encoded URL. Templates use |safe to avoid double-encoding. - if requested_action == 'stream': - template = template_env.get_template('req.html') - context = { - 'heading': f"View {file_name}", - 'file_name': file_name, - 'src': f"{src}?disposition=inline" - } - else: - template = template_env.get_template('dl.html') - context = { - 'file_name': file_name, - 'src': src - } + template = template_env.get_template("req.html") + context = { + "heading": f"View {file_name}", + "file_name": file_name, + "src": f"{src}?disposition=inline", + "kind": _page_kind(mime_type, file_name), + "mime_type": mime_type or "application/octet-stream", + } return await template.render_async(**context) -async def render_page(message_id: int, secure_hash: str, requested_action: str | None = None) -> str: +# L1: the legacy /watch route re-fetched the vault message from Telegram on +# every view; a small TTL+LRU cache keeps repeat views off the API. +_legacy_cache: "OrderedDict[tuple[int, str], tuple[float, str, str]]" = OrderedDict() +_LEGACY_CACHE_TTL_SECONDS = 600 +_LEGACY_CACHE_MAX_ITEMS = 1024 + + +def _legacy_cache_get(key) -> tuple[str, str] | None: + cached = _legacy_cache.get(key) + if not cached: + return None + ts, file_name, unique_id = cached + if time.monotonic() - ts > _LEGACY_CACHE_TTL_SECONDS: + _legacy_cache.pop(key, None) + return None + _legacy_cache.move_to_end(key) + return file_name, unique_id + + +def _legacy_cache_put(key, file_name: str, unique_id: str) -> None: + _legacy_cache[key] = (time.monotonic(), file_name, unique_id) + _legacy_cache.move_to_end(key) + while len(_legacy_cache) > _LEGACY_CACHE_MAX_ITEMS: + _legacy_cache.popitem(last=False) + + +async def render_page( + message_id: int, secure_hash: str, requested_action: str | None = None +) -> str: + key = (int(message_id), str(secure_hash)) + cached = _legacy_cache_get(key) + if cached is not None: + file_name, _ = cached + quoted_filename = urllib.parse.quote(file_name.replace("/", "_"), safe="") + src = urllib.parse.urljoin(Var.URL, f"{secure_hash}{message_id}/{quoted_filename}") + return await render_media_page(file_name, src, requested_action) + try: - try: - message = await StreamBot.get_messages(chat_id=int(Var.BIN_CHANNEL), message_ids=message_id) - except FloodWait as e: - await asyncio.sleep(e.value) - message = await StreamBot.get_messages(chat_id=int(Var.BIN_CHANNEL), message_ids=message_id) + from Thunder.bot import StreamBot # M12 layering break: lazy import + from Thunder.server.exceptions import InvalidHash + + message = await tg_call( + StreamBot.get_messages, + chat_id=int(Var.BIN_CHANNEL), + message_ids=int(message_id), + retries=1, + timeout=60, + ) if not message: raise InvalidHash("Message not found") @@ -56,12 +131,14 @@ async def render_page(message_id: int, secure_hash: str, requested_action: str | if not file_unique_id or file_unique_id[:6] != secure_hash: raise InvalidHash("File unique ID or secure hash mismatch during rendering.") - quoted_filename = urllib.parse.quote(file_name.replace('/', '_'), safe="") - src = urllib.parse.urljoin(Var.URL, f'{secure_hash}{message_id}/{quoted_filename}') + _legacy_cache_put(key, file_name, file_unique_id) + + quoted_filename = urllib.parse.quote(file_name.replace("/", "_"), safe="") + src = urllib.parse.urljoin(Var.URL, f"{secure_hash}{message_id}/{quoted_filename}") return await render_media_page(file_name, src, requested_action) except Exception as e: logger.error( f"Error in render_page for message_id {message_id} and hash {secure_hash}: {e}", - exc_info=True + exc_info=True, ) raise diff --git a/Thunder/utils/safe_call.py b/Thunder/utils/safe_call.py new file mode 100644 index 0000000..6f475cb --- /dev/null +++ b/Thunder/utils/safe_call.py @@ -0,0 +1,139 @@ +# Thunder/utils/safe_call.py + +"""Central FloodWait-safe call helpers. + +Every Telegram RPC in the codebase goes through :func:`tg_call` or one of the +thin wrappers below instead of the historical copy-pasted +``try/except FloodWait`` pairs. Semantics preserved from the old pattern: + +* on ``FloodWait`` the coroutine sleeps for ``e.value`` seconds and retries, + at most ``retries`` times (default 1 -- i.e. two attempts total, matching + the previous inline behaviour); +* after the retries are exhausted the exception propagates unchanged. + +Wall-clock budgets (H8): lightweight RPCs get a default timeout so a hung +call can never pin a handler forever. File-transfer paths (copy / upload / +download) default to *no* timeout because large media legitimately takes +minutes; pass ``timeout=`` explicitly where a budget is known. +""" + +import asyncio +from collections.abc import Awaitable, Callable +from typing import Any, TypeVar + +from pyrogram.errors import FloodWait + +from Thunder.utils.logger import logger + +T = TypeVar("T") + +# Default wall-clock budget for lightweight RPCs (get_me, get_messages, +# edit_text, answer, ...). Env-overridable via TG_RPC_TIMEOUT_SECONDS. +DEFAULT_RPC_TIMEOUT_SECONDS = 30.0 + +# Call shapes that are allowed to run unbounded by default (large media +# transfers). Matched by attribute name of the callable. +_UNBOUNDED_SHAPES = { + "copy", + "copy_message", + "send_document", + "send_video", + "send_audio", + "send_photo", + "send_animation", + "send_voice", + "send_video_note", + "reply_document", + "reply_video", + "reply_photo", + "reply_audio", + "stream_media", + "download_media", + "send_cached_media", +} + + +def _env_timeout() -> float | None: + try: + from Thunder.vars import Var # lazy: avoids any import-order coupling + + return float(getattr(Var, "TG_RPC_TIMEOUT_SECONDS", DEFAULT_RPC_TIMEOUT_SECONDS)) + except Exception: + return DEFAULT_RPC_TIMEOUT_SECONDS + + +def _default_timeout(fn: Callable[..., Awaitable[T]]) -> float | None: + if getattr(fn, "__name__", "") in _UNBOUNDED_SHAPES: + return None + return _env_timeout() + + +async def tg_call( + fn: Callable[..., Awaitable[T]], + *args: Any, + retries: int = 1, + timeout: float | None = None, + on_error: Callable[[Exception], None] | None = None, + **kwargs: Any, +) -> T: + """Call ``fn(*args, **kwargs)`` sleeping through ``FloodWait``. + + ``timeout`` forces a wall-clock budget (``None`` = auto: unbounded for + file-transfer shapes, :data:`DEFAULT_RPC_TIMEOUT_SECONDS` otherwise; + ``0`` or negative disables the budget entirely). + """ + attempt = 0 + while True: + try: + budget = timeout if timeout is not None else _default_timeout(fn) + coro = fn(*args, **kwargs) + if budget and budget > 0: + return await asyncio.wait_for(coro, timeout=budget) + return await coro + except FloodWait as e: + attempt += 1 + if attempt > retries: + raise + logger.debug( + f"FloodWait in {getattr(fn, '__name__', fn)}, " + f"sleeping {e.value}s (attempt {attempt}/{retries})" + ) + await asyncio.sleep(e.value) + except Exception as e: + if on_error is not None: + try: + on_error(e) + except Exception: + logger.debug("on_error hook raised", exc_info=True) + raise + + +async def reply_safe(msg: Any, text: str, retries: int = 1, **kwargs: Any): + return await tg_call(msg.reply_text, text, quote=True, retries=retries, **kwargs) + + +async def send_safe(cli: Any, chat_id: Any, retries: int = 1, **kwargs: Any): + return await tg_call(cli.send_message, chat_id=chat_id, retries=retries, **kwargs) + + +async def edit_safe(msg: Any, text: str, retries: int = 1, **kwargs: Any): + return await tg_call(msg.edit_text, text, retries=retries, **kwargs) + + +async def delete_safe(msg: Any, retries: int = 1): + return await tg_call(msg.delete, retries=retries) + + +async def answer_safe(query: Any, text: str = "", retries: int = 1, **kwargs: Any): + return await tg_call(query.answer, text, retries=retries, **kwargs) + + +__all__ = [ + "tg_call", + "reply_safe", + "send_safe", + "edit_safe", + "delete_safe", + "answer_safe", + "DEFAULT_RPC_TIMEOUT_SECONDS", +] diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py index 8406133..497c5df 100755 --- a/Thunder/utils/shortener.py +++ b/Thunder/utils/shortener.py @@ -1,13 +1,36 @@ # Thunder/utils/shortener.py +"""URL shortener (plan H5b + M5). + +* HTTP layer is asyncio-native ``aiohttp`` (cloudscraper removed; the + requests/urllib3 transitive tree is gone). ``curl_cffi`` remains an + optional escape hatch for Cloudflare-protected providers -- declared as + the ``shortener-cf`` extra, never a hard dependency. +* M5 hardening: LRU cache + per-URL singleflight, API key moved to an + ``Authorization: Bearer`` header (never the query string), https-only + endpoints, redirects never followed, and the returned short URL's host + must match the configured site's host (anti redirect-to-attacker). +* The plugin registry and the offline Linkvertise builder are preserved. +""" + import asyncio -import cloudscraper from abc import ABC, abstractmethod from base64 import b64encode -from random import random, choice -from urllib.parse import quote -from Thunder.vars import Var +from collections import OrderedDict +from random import choice, random +from urllib.parse import quote, urlparse + +import aiohttp + from Thunder.utils.logger import logger +from Thunder.vars import Var + +SHORTEN_TIMEOUT_SECONDS = 10 +CACHE_MAX_ITEMS = 10_000 + + +class ShortenerError(Exception): + pass class ShortenerPlugin(ABC): @@ -17,39 +40,59 @@ def matches(cls, domain: str) -> bool: pass @abstractmethod - async def shorten(self, url: str, api_key: str) -> str: + async def shorten( + self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str + ) -> str: pass + @staticmethod + def _validate_short_url(short_url: str, domain: str) -> bool: + """The response host must match the configured site (M5).""" + try: + result_host = urlparse(short_url).hostname or "" + site_host = urlparse(f"https://{domain}").hostname or "" + return result_host == site_host + except ValueError: + return False + class LinkvertisePlugin(ShortenerPlugin): + """Offline constructor: no HTTP call involved, host check not needed.""" + @classmethod def matches(cls, domain: str) -> bool: return "linkvertise" in domain - async def shorten(self, url: str, api_key: str) -> str: + async def shorten( + self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str + ) -> str: encoded_url = quote(b64encode(url.encode("utf-8"))) - return choice([ - f"https://link-to.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}", - f"https://up-to-down.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}", - f"https://direct-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}", - f"https://file-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}", - ]) + return choice( + [ + f"https://link-to.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}", + f"https://up-to-down.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}", + f"https://direct-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}", + f"https://file-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}", + ] + ) class BitlyPlugin(ShortenerPlugin): @classmethod def matches(cls, domain: str) -> bool: - return "bitly.com" in domain + return "bitly.com" in domain or "bit.ly" in domain - async def shorten(self, url: str, api_key: str) -> str: - response = await asyncio.to_thread( - self.session.post, + async def shorten( + self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str + ) -> str: + async with session.post( "https://api-ssl.bit.ly/v4/shorten", json={"long_url": url}, headers={"Authorization": f"Bearer {api_key}"}, - ) - if response.status_code == 200: - return response.json()["link"] + ) as resp: + if resp.status == 200: + data = await resp.json() + return data.get("link", url) return url @@ -58,12 +101,14 @@ class OuoIoPlugin(ShortenerPlugin): def matches(cls, domain: str) -> bool: return "ouo.io" in domain - async def shorten(self, url: str, api_key: str) -> str: - response = await asyncio.to_thread( - self.session.get, f"http://ouo.io/api/{api_key}?s={url}" - ) - if response.status_code == 200 and response.text: - return response.text + async def shorten( + self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str + ) -> str: + async with session.get(f"https://ouo.io/api/{api_key}", params={"s": url}) as resp: + if resp.status == 200: + text = (await resp.text()).strip() + if text and self._validate_short_url(text, domain): + return text return url @@ -72,12 +117,17 @@ class CuttLyPlugin(ShortenerPlugin): def matches(cls, domain: str) -> bool: return "cutt.ly" in domain - async def shorten(self, url: str, api_key: str) -> str: - response = await asyncio.to_thread( - self.session.get, f"http://cutt.ly/api/api.php?key={api_key}&short={url}" - ) - if response.status_code == 200: - return response.json()["url"]["shortLink"] + async def shorten( + self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str + ) -> str: + async with session.get( + "https://cutt.ly/api/api.php", params={"key": api_key, "short": url} + ) as resp: + if resp.status == 200: + data = await resp.json() + short = (data.get("url") or {}).get("shortLink") + if short and self._validate_short_url(short, domain): + return short return url @@ -86,35 +136,46 @@ class GenericShortenerPlugin(ShortenerPlugin): def matches(cls, domain: str) -> bool: return True - async def shorten(self, url: str, api_key: str) -> str: - response = await asyncio.to_thread( - self.session.get, f"https://{self.domain}/api?api={api_key}&url={quote(url)}" - ) - if response.status_code == 200: - return response.json().get("shortenedUrl", url) + async def shorten( + self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str + ) -> str: + async with session.get( + f"https://{domain}/api", + params={"api": api_key, "url": url}, + headers={"Authorization": f"Bearer {api_key}"} if api_key else {}, + ) as resp: + if resp.status == 200: + data = await resp.json() + short = data.get("shortenedUrl", url) + if short != url and not self._validate_short_url(short, domain): + logger.warning(f"Shortener returned foreign host {short!r}; rejecting.") + return url + return short return url class ShortenerSystem: def __init__(self): - self.session = None - self.plugin = None + self.session: aiohttp.ClientSession | None = None + self.plugin: ShortenerPlugin | None = None + self.domain: str = "" self.ready = False - self._lock = asyncio.Lock() + self._cache: OrderedDict[str, str] = OrderedDict() + self._inflight: dict[str, asyncio.Future] = {} def _get_plugin_class(self, domain: str): for plugin_class in ShortenerPlugin.__subclasses__(): - if plugin_class.matches(domain): + if plugin_class is not GenericShortenerPlugin and plugin_class.matches(domain): return plugin_class - return GenericShortenerPlugin async def initialize(self) -> bool: if self.ready: return True - if not (getattr(Var, "SHORTEN_ENABLED", False) or - getattr(Var, "SHORTEN_MEDIA_LINKS", False)): + if not ( + getattr(Var, "SHORTEN_ENABLED", False) or getattr(Var, "SHORTEN_MEDIA_LINKS", False) + ): return False site = getattr(Var, "URL_SHORTENER_SITE", "") @@ -124,37 +185,72 @@ async def initialize(self) -> bool: return False try: - self.session = await asyncio.to_thread( - cloudscraper.create_scraper, - browser={ - 'browser': 'chrome', - 'platform': 'windows', - 'desktop': True, - 'mobile': False - }, - delay=1 + timeout = aiohttp.ClientTimeout(total=SHORTEN_TIMEOUT_SECONDS) + self.session = aiohttp.ClientSession( + timeout=timeout, + # never follow redirects (M5): a 30x can never be mistaken + # for a successful shortening + allow_redirects=False, + headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) FileToLink/shortener"}, ) - + self.domain = site plugin_class = self._get_plugin_class(site) self.plugin = plugin_class() - self.plugin.session = self.session - self.plugin.domain = site self.ready = True + logger.info(f"Shortener ready (plugin={type(plugin_class).__name__}, site={site})") return True except Exception as e: logger.error(f"Failed to initialize ShortenerSystem: {e}", exc_info=True) return False + async def _shorten_uncached(self, url: str) -> str: + if self.session is None or self.plugin is None: + return url + try: + short = await self.plugin.shorten( + self.session, url, Var.URL_SHORTENER_API_KEY, self.domain + ) + if short and short != url: + self._cache[url] = short + self._cache.move_to_end(url) + while len(self._cache) > CACHE_MAX_ITEMS: + self._cache.popitem(last=False) + return short or url + except Exception as e: + logger.error(f"Error shortening URL {url}: {e}", exc_info=True) + return url + async def short_url(self, url: str) -> str: if not self.ready: return url - async with self._lock: - try: - return await self.plugin.shorten(url, Var.URL_SHORTENER_API_KEY) - except Exception as e: - logger.error(f"Error shortening URL {url}: {e}", exc_info=True) - return url + cached = self._cache.get(url) + if cached is not None: + self._cache.move_to_end(url) + return cached + + future = self._inflight.get(url) + if future is not None: + return await asyncio.shield(future) + + loop = asyncio.get_running_loop() + future = loop.create_future() + self._inflight[url] = future + try: + result = await self._shorten_uncached(url) + if not future.done(): + future.set_result(result) + return result + except Exception as e: + if not future.done(): + future.set_exception(e) + raise + finally: + self._inflight.pop(url, None) + + async def close(self) -> None: + if self.session and not self.session.closed: + await self.session.close() _system = ShortenerSystem() diff --git a/Thunder/utils/time_format.py b/Thunder/utils/time_format.py index 172712a..39a1ba7 100755 --- a/Thunder/utils/time_format.py +++ b/Thunder/utils/time_format.py @@ -2,7 +2,8 @@ from Thunder.utils.logger import logger -_TIME_PERIODS = (('d', 86400), ('h', 3600), ('m', 60), ('s', 1)) +_TIME_PERIODS = (("d", 86400), ("h", 3600), ("m", 60), ("s", 1)) + def get_readable_time(seconds: int) -> str: try: @@ -11,7 +12,7 @@ def get_readable_time(seconds: int) -> str: if seconds >= period: value, seconds = divmod(int(seconds), period) result.append(f"{int(value)}{suffix}") - return ' '.join(result) if result else '0s' + return " ".join(result) if result else "0s" except Exception as e: logger.error(f"Error in get_readable_time: {e}", exc_info=True) return "N/A" diff --git a/Thunder/utils/tokens.py b/Thunder/utils/tokens.py index f9eeb42..e496a73 100755 --- a/Thunder/utils/tokens.py +++ b/Thunder/utils/tokens.py @@ -2,54 +2,60 @@ import secrets from datetime import datetime, timedelta -from typing import Optional, Dict, Any, List -import asyncio -import random +from typing import Any + import pyrogram.errors + from Thunder.utils.database import db -from Thunder.vars import Var +from Thunder.utils.flag_cache import flags from Thunder.utils.logger import logger +from Thunder.vars import Var + + +def _invalidate_user_flags(user_id: int) -> None: + flags.invalidate(("allowed", user_id), ("token_ok", user_id)) + async def check(user_id: int) -> bool: + """Token/authorization gate (H7: cached, fail-closed).""" try: - logger.debug(f"Token validation started for user: {user_id}") if not getattr(Var, "TOKEN_ENABLED", False): - logger.debug("Token system disabled - access granted") return True if user_id == Var.OWNER_ID: - logger.debug("Owner access granted") return True - current_time = datetime.utcnow() - auth_result = await db.authorized_users_col.find_one( - {"user_id": user_id}, - {"_id": 1} - ) - if auth_result: + # cached authorized-user lookup (5 min TTL) + if await allowed(user_id): return True - token_result = await db.token_col.find_one( - {"user_id": user_id, "expires_at": {"$gt": current_time}, "activated": True}, - {"_id": 1} + return await flags.get_or_load( + ("token_ok", user_id), + lambda: _load_token_ok(user_id), ) - access_granted = bool(token_result) - logger.debug(f"Token validation {'SUCCESS' if access_granted else 'FAILURE'} for user: {user_id}") - return access_granted except Exception as e: logger.error(f"Error in check for user {user_id}: {e}", exc_info=True) raise + +async def _load_token_ok(user_id: int) -> bool: + """Loader for the activated-token flag. Raises on DB failure so the + caller can apply its fail-closed policy.""" + token_result = await db.token_col.find_one( + {"user_id": user_id, "expires_at": {"$gt": datetime.utcnow()}, "activated": True}, + {"_id": 1}, + ) + return bool(token_result) + + async def generate(user_id: int) -> str: try: logger.debug(f"Token generation started for user: {user_id}") existing_token_doc = await db.token_col.find_one( {"user_id": user_id, "activated": False, "expires_at": {"$gt": datetime.utcnow()}}, - {"token": 1} + {"token": 1}, ) if existing_token_doc: logger.debug(f"Returning existing unactivated token for user: {user_id}") return existing_token_doc["token"] token_str = secrets.token_urlsafe(32) - masked_token = f"{token_str[:4]}...{token_str[-4:]}" - logger.debug(f"Generated new token: {masked_token}") max_retries = 3 base_delay = 0.5 for attempt in range(max_retries): @@ -62,91 +68,134 @@ async def generate(user_id: int) -> str: token_value=token_str, expires_at=expires_at, created_at=created_at, - activated=False + activated=False, ) logger.debug(f"New token generated and saved successfully for user: {user_id}") return token_str except pyrogram.errors.RPCError as e: - logger.error(f"Telegram API error while generating new token for user {user_id}: {e}", exc_info=True) + logger.error( + f"Telegram API error while generating new token for user {user_id}: {e}", + exc_info=True, + ) raise except Exception as e: if attempt < max_retries - 1: - delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1) - logger.warning(f"Database error (attempt {attempt+1}/{max_retries}) while saving new token: {e}. Retrying in {delay:.2f} seconds.", exc_info=True) + import asyncio + import random + + delay = base_delay * (2**attempt) + random.uniform(0, 0.1) + logger.warning( + f"Database error (attempt {attempt + 1}/{max_retries}) while saving new token: {e}. Retrying in {delay:.2f} seconds.", + exc_info=True, + ) await asyncio.sleep(delay) else: - logger.error(f"Failed to generate and save new token for user {user_id} after {max_retries} attempts: {e}", exc_info=True) + logger.error( + f"Failed to generate and save new token for user {user_id} after {max_retries} attempts: {e}", + exc_info=True, + ) raise return "" except Exception as e: logger.error(f"Error in generate for user {user_id}: {e}", exc_info=True) raise -async def allowed(user_id: int) -> bool: + +async def consume(token: str, user_id: int) -> tuple[str, float]: + """Atomically activate a token (plan M8). + + Replaces the historical ``find_one`` -> ``update_one`` pair that allowed + a double-activation race. Uses ``find_one_and_update`` conditioned on + ``activated != True`` so exactly one concurrent activation can win. + + Returns ``(status, hours_valid)`` with status one of + ``"ok" | "already" | "wrong_user" | "invalid"``. + """ + now = datetime.utcnow() try: - result = await db.authorized_users_col.find_one( - {"user_id": user_id}, - {"_id": 1} + doc = await db.token_col.find_one({"token": token}) + if not doc: + return "invalid", 0.0 + if doc.get("user_id") != user_id: + return "wrong_user", 0.0 + if doc.get("activated"): + return "already", 0.0 + + expires_at = now + timedelta(hours=Var.TOKEN_TTL_HOURS) + activated_doc = await db.token_col.find_one_and_update( + {"token": token, "user_id": user_id, "activated": {"$ne": True}}, + { + "$set": { + "activated": True, + "activated_at": now, + "created_at": now, + "expires_at": expires_at, + } + }, + return_document=True, ) - return bool(result) + if activated_doc is None: + # lost the race to a concurrent activation + return "already", 0.0 + _invalidate_user_flags(user_id) + hours = round((expires_at - now).total_seconds() / 3600, 1) + logger.debug(f"Token atomically activated for user {user_id} ({hours}h)") + return "ok", hours except Exception as e: - logger.error(f"Error in allowed for user {user_id}: {e}", exc_info=True) + logger.error(f"Error in consume for user {user_id}: {e}", exc_info=True) raise + +async def allowed(user_id: int) -> bool: + """Cached authorized-user check (H7). Raises on DB failure.""" + return await flags.get_or_load( + ("allowed", user_id), + lambda: _load_allowed(user_id), + ) + + +async def _load_allowed(user_id: int) -> bool: + result = await db.authorized_users_col.find_one({"user_id": user_id}, {"_id": 1}) + return bool(result) + + async def authorize(user_id: int, authorized_by: int) -> bool: try: auth_data = { "user_id": user_id, "authorized_by": authorized_by, - "authorized_at": datetime.utcnow() + "authorized_at": datetime.utcnow(), } await db.authorized_users_col.update_one( - {"user_id": user_id}, - {"$set": auth_data}, - upsert=True + {"user_id": user_id}, {"$set": auth_data}, upsert=True ) + _invalidate_user_flags(user_id) return True except Exception as e: logger.error(f"Error in authorize for user {user_id}: {e}", exc_info=True) raise + async def deauthorize(user_id: int) -> bool: try: result = await db.authorized_users_col.delete_one({"user_id": user_id}) + _invalidate_user_flags(user_id) return result.deleted_count > 0 except Exception as e: logger.error(f"Error in deauthorize for user {user_id}: {e}", exc_info=True) raise -async def get_user(user_id: int) -> Optional[Dict[str, Any]]: - try: - return await db.token_col.find_one({"user_id": user_id}) - except Exception as e: - logger.error(f"Error in get_user for user {user_id}: {e}", exc_info=True) - return None -async def list_allowed() -> List[Dict[str, Any]]: +async def list_allowed() -> list[dict[str, Any]]: try: cursor = db.authorized_users_col.find( - {}, - {"user_id": 1, "authorized_by": 1, "authorized_at": 1} + {}, {"user_id": 1, "authorized_by": 1, "authorized_at": 1} ) return await cursor.to_list(length=None) except Exception as e: logger.error(f"Error in list_allowed: {e}", exc_info=True) return [] -async def list_tokens() -> List[Dict[str, Any]]: - try: - current_time = datetime.utcnow() - cursor = db.token_col.find( - {"expires_at": {"$gt": current_time}}, - {"user_id": 1, "expires_at": 1, "created_at": 1, "activated": 1} - ) - return await cursor.to_list(length=None) - except Exception as e: - logger.error(f"Error in list_tokens: {e}", exc_info=True) - return [] async def cleanup_expired_tokens() -> int: try: diff --git a/Thunder/vars.py b/Thunder/vars.py index 5a988c2..fd80e6f 100755 --- a/Thunder/vars.py +++ b/Thunder/vars.py @@ -15,7 +15,6 @@ """ import os -from typing import List, Optional, Set from dotenv import load_dotenv @@ -29,10 +28,10 @@ def str_to_bool(val: str) -> bool: return val.lower() in ("true", "1", "t", "y", "yes") -def str_to_int_set(val: str) -> Set[int]: +def str_to_int_set(val: str) -> set[int]: if not val: return set() - result: Set[int] = set() + result: set[int] = set() for x in val.split(): try: result.add(int(x)) @@ -41,12 +40,13 @@ def str_to_int_set(val: str) -> Set[int]: return result -_config_errors: List[str] = [] -_config_warnings: List[str] = [] +_config_errors: list[str] = [] +_config_warnings: list[str] = [] -def _get_int(name: str, default: str, *, min_val: Optional[int] = None, - max_val: Optional[int] = None) -> int: +def _get_int( + name: str, default: str, *, min_val: int | None = None, max_val: int | None = None +) -> int: raw = os.getenv(name, default) try: value = int(str(raw).strip()) @@ -60,7 +60,7 @@ def _get_int(name: str, default: str, *, min_val: Optional[int] = None, return value -def _get_float(name: str, default: str, *, min_val: Optional[float] = None) -> float: +def _get_float(name: str, default: str, *, min_val: float | None = None) -> float: raw = os.getenv(name, default) try: value = float(str(raw).strip()) @@ -94,7 +94,7 @@ class Var: _require(BIN_CHANNEL, "BIN_CHANNEL", "storage channel id, e.g. -1001234567890") PORT: int = _get_int("PORT", "8080", min_val=1, max_val=65535) - BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0") + BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0") # nosec B104 -- user-configured listen address PING_INTERVAL: int = _get_int("PING_INTERVAL", "840", min_val=30) NO_PORT: bool = str_to_bool(os.getenv("NO_PORT", "True")) @@ -121,20 +121,15 @@ class Var: MAX_BATCH_FILES: int = _get_int("MAX_BATCH_FILES", "50", min_val=1, max_val=100) CHANNEL: bool = str_to_bool(os.getenv("CHANNEL", "False")) - BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", "")) + BANNED_CHANNELS: set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", "")) - # Kept for backward compatibility of env parsing; no longer read at runtime. - MULTI_CLIENT: bool = False - - FORCE_CHANNEL_ID: Optional[int] = None + FORCE_CHANNEL_ID: int | None = None force_channel_env = os.getenv("FORCE_CHANNEL_ID", "").strip() if force_channel_env: try: FORCE_CHANNEL_ID = int(force_channel_env) except ValueError: - _config_errors.append( - f"FORCE_CHANNEL_ID={force_channel_env!r} must be an integer" - ) + _config_errors.append(f"FORCE_CHANNEL_ID={force_channel_env!r} must be an integer") TOKEN_ENABLED: bool = str_to_bool(os.getenv("TOKEN_ENABLED", "False")) TOKEN_TTL_HOURS: int = _get_int("TOKEN_TTL_HOURS", "24", min_val=1) @@ -143,17 +138,19 @@ class Var: SHORTEN_MEDIA_LINKS: bool = str_to_bool(os.getenv("SHORTEN_MEDIA_LINKS", "False")) URL_SHORTENER_API_KEY: str = os.getenv("URL_SHORTENER_API_KEY", "") URL_SHORTENER_SITE: str = os.getenv("URL_SHORTENER_SITE", "") - if (SHORTEN_ENABLED or SHORTEN_MEDIA_LINKS) and not (URL_SHORTENER_SITE and URL_SHORTENER_API_KEY): + if (SHORTEN_ENABLED or SHORTEN_MEDIA_LINKS) and not ( + URL_SHORTENER_SITE and URL_SHORTENER_API_KEY + ): _config_warnings.append( "Shortener enabled but URL_SHORTENER_SITE/URL_SHORTENER_API_KEY " "missing; links will not be shortened." ) GLOBAL_RATE_LIMIT: bool = str_to_bool(os.getenv("GLOBAL_RATE_LIMIT", "False")) - MAX_GLOBAL_REQUESTS_PER_MINUTE: int = _get_int( - "MAX_GLOBAL_REQUESTS_PER_MINUTE", "4", min_val=1) + MAX_GLOBAL_REQUESTS_PER_MINUTE: int = _get_int("MAX_GLOBAL_REQUESTS_PER_MINUTE", "4", min_val=1) GLOBAL_RPS_LIMIT: float = _get_float( - "GLOBAL_RPS_LIMIT", "0", min_val=0) # 0 = derive from per-minute value + "GLOBAL_RPS_LIMIT", "0", min_val=0 + ) # 0 = derive from per-minute value RATE_LIMIT_ENABLED: bool = str_to_bool(os.getenv("RATE_LIMIT_ENABLED", "False")) MAX_FILES_PER_PERIOD: int = _get_int("MAX_FILES_PER_PERIOD", "2", min_val=1) @@ -197,7 +194,7 @@ class Var: if _config_errors: - logger.critical("Invalid configuration -- %d problem(s) found:" % len(_config_errors)) + logger.critical(f"Invalid configuration -- {len(_config_errors)} problem(s) found:") for err in _config_errors: logger.critical(f" βœ– {err}") raise SystemExit( From 0c4a2c629b2d4497b9be5efa250578007f2f6cae Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 10:25:32 +0000 Subject: [PATCH 05/49] test(H2, L9): pytest scaffolding + unit tier (103 tests) + integration tier - conftest.py: hermetic env bootstrap (Thunder.vars validates at import) - unit tier (pytest -m unit, asyncio_mode=auto, no network/Mongo): human_readable, time_format, media_types unification, safe_call FloodWait/timeout semantics, flag_cache TTL/LRU/fail paths, rate limiter window math + breaker + sweep, canonical hash dual-length + merge precedence, stream_routes parsing/range/disposition, shortener registry + host validation, secret redaction, config validation (subprocess: all problems reported together, OWNER_ID boot-fail), registry menu/help/AGENTS.md drift checks - coverage gate --cov-fail-under=35 as the starting bar (raise to 80 on core modules next) - tests/integration: testcontainers MongoDB (ingest-claim + atomic token activation), TEST_INTEGRATION=1 gated, skipped cleanly without Docker (mirrors ThunderGo's build-tag-gated tier, but opt-in runnable in CI) --- tests/__init__.py | 0 tests/conftest.py | 31 ++++++ tests/integration/__init__.py | 0 tests/integration/test_mongo.py | 62 ++++++++++++ tests/test_unit/__init__.py | 0 tests/test_unit/test_canonical_files.py | 70 ++++++++++++++ tests/test_unit/test_config.py | 119 ++++++++++++++++++++++++ tests/test_unit/test_flag_cache.py | 74 +++++++++++++++ tests/test_unit/test_human_readable.py | 36 +++++++ tests/test_unit/test_media_types.py | 46 +++++++++ tests/test_unit/test_rate_limiter.py | 94 +++++++++++++++++++ tests/test_unit/test_redaction.py | 35 +++++++ tests/test_unit/test_registry.py | 47 ++++++++++ tests/test_unit/test_safe_call.py | 68 ++++++++++++++ tests/test_unit/test_shortener.py | 66 +++++++++++++ tests/test_unit/test_stream_routes.py | 111 ++++++++++++++++++++++ tests/test_unit/test_time_format.py | 29 ++++++ 17 files changed, 888 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_mongo.py create mode 100644 tests/test_unit/__init__.py create mode 100644 tests/test_unit/test_canonical_files.py create mode 100644 tests/test_unit/test_config.py create mode 100644 tests/test_unit/test_flag_cache.py create mode 100644 tests/test_unit/test_human_readable.py create mode 100644 tests/test_unit/test_media_types.py create mode 100644 tests/test_unit/test_rate_limiter.py create mode 100644 tests/test_unit/test_redaction.py create mode 100644 tests/test_unit/test_registry.py create mode 100644 tests/test_unit/test_safe_call.py create mode 100644 tests/test_unit/test_shortener.py create mode 100644 tests/test_unit/test_stream_routes.py create mode 100644 tests/test_unit/test_time_format.py diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d0d6023 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +# tests/conftest.py +"""Bootstrap a valid fake environment BEFORE any Thunder import. + +Thunder.vars validates at import time and hard-fails on missing required +values (plan H7/M6), so the unit tier must always run with a complete, +hermetic environment -- no network, no Mongo (AsyncMongoClient constructs +lazily and never connects at import). +""" + +import os +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +# Hard overrides (not setdefault): the unit tier must be hermetic even when +# the CI/host environment leaks unrelated DATABASE_URL-style variables. +_required = { + "API_ID": "1234567", + "API_HASH": "test-hash", + "BOT_TOKEN": "123456789:ABCdefGHIjklMNOpqrsTUVwxyz", + "BIN_CHANNEL": "-1001234567890", + "OWNER_ID": "42", + "DATABASE_URL": "mongodb://localhost:27017/thunder_test", + "FQDN": "example.com", + "NO_PORT": "True", + "LOG_LEVEL": "WARNING", +} +for _key, _value in _required.items(): + os.environ[_key] = _value diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_mongo.py b/tests/integration/test_mongo.py new file mode 100644 index 0000000..0761e90 --- /dev/null +++ b/tests/integration/test_mongo.py @@ -0,0 +1,62 @@ +# tests/integration/test_mongo.py +"""L9: integration tier against a real MongoDB via testcontainers. + +Run explicitly: pytest -m integration +Requires Docker; skipped cleanly (as designed, mirroring ThunderGo's +build-tag-gated tier) when Docker is unavailable. +""" + +import os + +import pytest + +pytestmark = pytest.mark.integration + +docker_unavailable = True +mongo_uri = None + +try: # pragma: no cover - environment-dependent + from testcontainers.mongo import MongoContainer + + docker_unavailable = False +except ImportError: + MongoContainer = None + + +@pytest.fixture(scope="module") +def db(): + if docker_unavailable or os.getenv("TEST_INTEGRATION") != "1": + pytest.skip("integration tier disabled (set TEST_INTEGRATION=1 with Docker)") + with MongoContainer("mongo:7") as mongo: + os.environ["DATABASE_URL"] = mongo.get_connection_url() + # re-import a fresh Database bound to the container URI + import importlib + + import Thunder.utils.database as database_module + + importlib.reload(database_module) + yield database_module.db + + +async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover + assert await db.ensure_indexes(raise_on_error=True) is True + + # M8: atomic activation -- two concurrent consume() calls, one winner + import asyncio + from datetime import datetime, timedelta + + from Thunder.utils.tokens import consume + + token = "integration-token-1" + await db.token_col.insert_one( + { + "token": token, + "user_id": 424242, + "activated": False, + "created_at": datetime.utcnow(), + "expires_at": datetime.utcnow() + timedelta(hours=1), + } + ) + results = await asyncio.gather(consume(token, 424242), consume(token, 424242)) + statuses = sorted(status for status, _ in results) + assert statuses == ["already", "ok"] diff --git a/tests/test_unit/__init__.py b/tests/test_unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_unit/test_canonical_files.py b/tests/test_unit/test_canonical_files.py new file mode 100644 index 0000000..a96dd7a --- /dev/null +++ b/tests/test_unit/test_canonical_files.py @@ -0,0 +1,70 @@ +# tests/test_unit/test_canonical_files.py +"""Hash building (L4 dual lengths) + merge precedence (H2 target).""" + +import pytest + +from Thunder.utils.canonical_files import ( + LEGACY_PUBLIC_HASH_LENGTH, + PUBLIC_HASH_LENGTH, + _merge_replacement_record, + build_public_hash, +) + + +@pytest.mark.unit +def test_new_hashes_are_32_hex(): + h = build_public_hash("unique-id-1") + assert len(h) == PUBLIC_HASH_LENGTH == 32 + int(h, 16) # hex-parseable + + +@pytest.mark.unit +def test_hash_is_deterministic(): + assert build_public_hash("abc") == build_public_hash("abc") + assert build_public_hash("abc") != build_public_hash("abd") + + +@pytest.mark.unit +def test_legacy_length_constant_still_20(): + # L4: the old family must remain representable for dual validation + assert LEGACY_PUBLIC_HASH_LENGTH == 20 + + +@pytest.mark.unit +def test_merge_keeps_created_at_and_increments_seen(): + existing = { + "created_at": "2026-01-01", + "seen_count": 7, + "reuse_count": 3, + "first_source_chat_id": -100111, + "first_source_message_id": 222, + } + refreshed = { + "created_at": "2026-09-01", + "seen_count": 0, + "reuse_count": 0, + "first_source_chat_id": None, + "first_source_message_id": None, + "file_unique_id": "x", + } + merged = _merge_replacement_record(existing, refreshed) + assert merged["created_at"] == "2026-01-01" + assert merged["seen_count"] == 8 + assert merged["reuse_count"] == 3 + assert merged["first_source_chat_id"] == -100111 + assert merged["first_source_message_id"] == 222 + + +@pytest.mark.unit +def test_merge_falls_back_to_refreshed_sources(): + existing = {"seen_count": 0, "reuse_count": 0} + refreshed = { + "created_at": "c", + "seen_count": 0, + "reuse_count": 0, + "first_source_chat_id": -1, + "first_source_message_id": 1, + } + merged = _merge_replacement_record(existing, refreshed) + assert merged["first_source_chat_id"] == -1 + assert merged["first_source_message_id"] == 1 diff --git a/tests/test_unit/test_config.py b/tests/test_unit/test_config.py new file mode 100644 index 0000000..3cee7fe --- /dev/null +++ b/tests/test_unit/test_config.py @@ -0,0 +1,119 @@ +# tests/test_unit/test_config.py +"""M6: config validation surfaces all problems; booleans/sets parse.""" + +import subprocess +import sys +from pathlib import Path + +import pytest + +from Thunder.vars import Var, str_to_bool, str_to_int_set + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + + +@pytest.mark.unit +@pytest.mark.parametrize( + "raw,expected", + [ + ("true", True), + ("True", True), + ("1", True), + ("yes", True), + ("Y", True), + ("false", False), + ("0", False), + ("no", False), + ("", False), + ("junk", False), + ], +) +def test_str_to_bool(raw, expected): + assert str_to_bool(raw) is expected + + +@pytest.mark.unit +def test_str_to_int_set(): + assert str_to_int_set("") == set() + assert str_to_int_set("-100111 -100222") == {-100111, -100222} + assert str_to_int_set("1 junk 2") == {1, 2} # junk skipped + + +@pytest.mark.unit +def test_var_facade_has_new_knobs(): + # every new env knob from the plan exists with a safe default + assert Var.PRIVATE_MODE is False + assert Var.ENABLE_LEGACY_LINKS is True + assert Var.ENABLE_SHELL is False + assert Var.FILE_TTL_DAYS == 0 + assert Var.EXECUTOR_WORKERS >= 1 + assert Var.BATCH_WORKERS >= 1 + assert Var.BROADCAST_WORKERS >= 1 + assert Var.MAX_CONCURRENT_STREAMS >= 1 + assert Var.TOUCH_FLUSH_SECONDS >= 1 + assert Var.TOUCH_BUFFER_MAX >= 100 + + +@pytest.mark.unit +def test_owner_id_required_boot_fails(): + """H7: missing OWNER_ID must refuse to boot (nobody had owner access).""" + env = { + k: v + for k, v in __import__("os").environ.items() + if not k.startswith(("API_", "BOT_TOKEN", "BIN_", "OWNER_", "DATABASE_")) + } + env.update( + { + "API_ID": "1", + "API_HASH": "h", + "BOT_TOKEN": "1:x", + "BIN_CHANNEL": "-1", + "DATABASE_URL": "mongodb://localhost/x", + "OWNER_ID": "", + "PYTHONPATH": str(REPO_ROOT), + } + ) + proc = subprocess.run( + [sys.executable, "-c", "import Thunder.vars"], + capture_output=True, + text=True, + env=env, + cwd=str(REPO_ROOT), + timeout=30, + ) + assert proc.returncode != 0 + assert "OWNER_ID" in (proc.stderr + proc.stdout) + + +@pytest.mark.unit +def test_all_problems_reported_together(): + """M6: three bad vars -> all three named before exit (not first-fail).""" + env = { + k: v + for k, v in __import__("os").environ.items() + if not k.startswith(("API_", "BOT_TOKEN", "BIN_", "OWNER_", "DATABASE_", "MAX_BATCH")) + } + env.update( + { + "API_ID": "not-a-number", + "API_HASH": "h", + "BOT_TOKEN": "1:x", + "BIN_CHANNEL": "also-bad", + "DATABASE_URL": "mongodb://localhost/x", + "OWNER_ID": "42", + "MAX_BATCH_FILES": "not-int", + "PYTHONPATH": str(REPO_ROOT), + } + ) + proc = subprocess.run( + [sys.executable, "-c", "import Thunder.vars"], + capture_output=True, + text=True, + env=env, + cwd=str(REPO_ROOT), + timeout=30, + ) + combined = proc.stderr + proc.stdout + assert proc.returncode != 0 + for var in ("API_ID", "BIN_CHANNEL", "MAX_BATCH_FILES"): + assert var in combined, f"{var} problem not reported together with the others" diff --git a/tests/test_unit/test_flag_cache.py b/tests/test_unit/test_flag_cache.py new file mode 100644 index 0000000..e020461 --- /dev/null +++ b/tests/test_unit/test_flag_cache.py @@ -0,0 +1,74 @@ +# tests/test_unit/test_flag_cache.py +"""H7: TTL-LRU flag cache semantics.""" + +import pytest + +from Thunder.utils.flag_cache import FlagCache + + +@pytest.mark.unit +async def test_loader_called_once_within_ttl(): + calls = {"n": 0} + + async def loader(): + calls["n"] += 1 + return "value" + + cache = FlagCache(ttl_seconds=60) + assert await cache.get_or_load("k", loader) == "value" + assert await cache.get_or_load("k", loader) == "value" + assert calls["n"] == 1 + + +@pytest.mark.unit +async def test_invalidate_forces_reload(): + calls = {"n": 0} + + async def loader(): + calls["n"] += 1 + return calls["n"] + + cache = FlagCache(ttl_seconds=60) + assert await cache.get_or_load("k", loader) == 1 + cache.invalidate("k") + assert await cache.get_or_load("k", loader) == 2 + + +@pytest.mark.unit +async def test_loader_exception_propagates(): + async def boom(): + raise RuntimeError("db down") + + cache = FlagCache() + with pytest.raises(RuntimeError): + await cache.get_or_load("k", boom) + # nothing cached on failure + hit, _ = cache.peek("k") + assert not hit + + +@pytest.mark.unit +async def test_lru_bound(): + cache = FlagCache(ttl_seconds=60, max_items=2) + + async def loader(v): + return v + + await cache.get_or_load("a", lambda: loader("a")) + await cache.get_or_load("b", lambda: loader("b")) + await cache.get_or_load("c", lambda: loader("c")) + assert cache.occupancy() == 2 + hit, _ = cache.peek("a") # oldest evicted + assert not hit + + +@pytest.mark.unit +async def test_sweep_drops_expired(): + async def loader(): + return 1 + + cache = FlagCache(ttl_seconds=0) # everything immediately expired + await cache.get_or_load("k", loader) + dropped = await cache.sweep() + assert dropped == 1 + assert cache.occupancy() == 0 diff --git a/tests/test_unit/test_human_readable.py b/tests/test_unit/test_human_readable.py new file mode 100644 index 0000000..11503d4 --- /dev/null +++ b/tests/test_unit/test_human_readable.py @@ -0,0 +1,36 @@ +# tests/test_unit/test_human_readable.py +import pytest + +from Thunder.utils.human_readable import humanbytes + + +@pytest.mark.unit +@pytest.mark.parametrize( + "size,expected", + [ + (0, "0 B"), + (-5, "-5 B"), # characterization: truthy negatives pass through + (1, "1 B"), + (1023, "1023 B"), + (1024, "1.0 KB"), + (1536, "1.5 KB"), + (1024**2, "1.0 MB"), + (1024**3, "1.0 GB"), + (1024**5, "1.0 PB"), + (1024**8, "1.0 YB"), + (1024**9, "1024.0 YB"), # clamped at last unit + ], +) +def test_humanbytes(size, expected): + assert humanbytes(size) == expected + + +@pytest.mark.unit +def test_humanbytes_decimal_places(): + assert humanbytes(1536, decimal_places=0) == "2.0 KB" + assert humanbytes(1536, decimal_places=3) == "1.5 KB" + + +@pytest.mark.unit +def test_humanbytes_huge_does_not_raise(): + assert humanbytes(10**30).endswith("YB") diff --git a/tests/test_unit/test_media_types.py b/tests/test_unit/test_media_types.py new file mode 100644 index 0000000..1ac45d8 --- /dev/null +++ b/tests/test_unit/test_media_types.py @@ -0,0 +1,46 @@ +# tests/test_unit/test_media_types.py +import pytest + +from Thunder.utils.media_types import ( + canonical_media_type, + ext_and_mime_for_class, + ext_for, + mime_for, +) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "media_type,ext,mime", + [ + ("photo", "jpg", "image/jpeg"), + ("voice", "ogg", "audio/ogg"), + ("videonote", "mp4", "video/mp4"), + ("video_note", "mp4", "video/mp4"), + ("animation", "mp4", "video/mp4"), + ("audio", "mp3", "audio/mpeg"), + ("sticker", "webp", "image/webp"), + ("document", "bin", "application/octet-stream"), + ], +) +def test_class_lookup(media_type, ext, mime): + assert ext_and_mime_for_class(media_type) == (ext, mime) + + +@pytest.mark.unit +def test_unknown_class_falls_back(): + assert ext_and_mime_for_class("unknownthing") == ("bin", "application/octet-stream") + + +@pytest.mark.unit +def test_attr_resolution(): + assert canonical_media_type(attr="video_note") == "video_note" + assert canonical_media_type(attr="photo") == "photo" + assert canonical_media_type(attr=None, media=None) == "document" + + +@pytest.mark.unit +def test_direct_helpers(): + assert ext_for("photo") == "jpg" + assert mime_for("voice") == "audio/ogg" + assert ext_for("nope") == "bin" diff --git a/tests/test_unit/test_rate_limiter.py b/tests/test_unit/test_rate_limiter.py new file mode 100644 index 0000000..ac0392f --- /dev/null +++ b/tests/test_unit/test_rate_limiter.py @@ -0,0 +1,94 @@ +# tests/test_unit/test_rate_limiter.py +"""H6: window math, breaker, sweep -- pure logic, no Mongo.""" + +import time + +import pytest + +from Thunder.utils.rate_limiter import TokenBucket, rate_limiter + + +@pytest.mark.unit +async def test_check_limits_window(): + rl = rate_limiter + rl.enabled = True + rl._initialization_error = False + rl.max_requests_per_period = 2 + rl.rate_limit_period_seconds = 60 + rl.global_rate_limit_enabled = False + + uid = 90_001 + rl.user_requests.pop(uid, None) + + assert await rl.check_limits(uid, record=True) is True + assert await rl.check_limits(uid, record=True) is True + # window exhausted + assert await rl.check_limits(uid, record=True) is False + # advisory check does not extend the window + assert await rl.check_limits(uid, record=False) is False + assert len(rl.user_requests[uid]) == 2 + rl.user_requests.pop(uid, None) + + +@pytest.mark.unit +async def test_owner_bypass(): + from Thunder.vars import Var + + rl = rate_limiter + rl.enabled = True + assert await rl.check_limits(Var.OWNER_ID, record=True) is True + + +@pytest.mark.unit +async def test_sweep_prunes_stale_users(): + rl = rate_limiter + rl.enabled = True + rl.rate_limit_period_seconds = 60 + uid = 90_002 + old = time.time() - 3600 + rl.user_requests[uid] = _deque(old, old) + stats = await rl.sweep() + assert uid not in rl.user_requests + assert stats["user_windows"] >= 1 + + +def _deque(*timestamps): + from collections import deque + + return deque(timestamps) + + +@pytest.mark.unit +class TestTokenBucket: + async def test_burst_then_deny(self): + bucket = TokenBucket(rate_per_second=2.0, burst_multiplier=2.0) + allowed = 0 + for _ in range(10): + if bucket.allow(): + allowed += 1 + assert allowed == int(bucket.burst) # burst = 2x rate = 4 + + async def test_refill_over_time(self): + bucket = TokenBucket(rate_per_second=100.0, burst_multiplier=1.0) + while bucket.allow(): + pass + time.sleep(0.05) # ~5 tokens + assert bucket.allow() is True + + async def test_retry_after_positive_when_denied(self): + bucket = TokenBucket(rate_per_second=0.5, burst_multiplier=1.0) + while bucket.allow(): + pass + assert bucket.retry_after() > 0 + + async def test_zero_rate_always_allows(self): + bucket = TokenBucket(rate_per_second=0.0) + for _ in range(50): + assert bucket.allow() is True + assert bucket.retry_after() == 0.0 + + +@pytest.mark.unit +async def test_occupancy_shape(): + occ = rate_limiter.occupancy() + assert {"queued", "tracked_users", "global_window", "breaker_tokens"} <= set(occ) diff --git a/tests/test_unit/test_redaction.py b/tests/test_unit/test_redaction.py new file mode 100644 index 0000000..dadef58 --- /dev/null +++ b/tests/test_unit/test_redaction.py @@ -0,0 +1,35 @@ +# tests/test_unit/test_redaction.py +"""H10: shared redaction regexes.""" + +import pytest + +from Thunder.utils.logger import hash_path_token, redact_secrets + + +@pytest.mark.unit +def test_bot_token_redacted(): + text = "starting bot with token 1234567890:ABCdef-_GHIjklMNOpqrsTUVwxyz1234567" + out = redact_secrets(text) + assert "1234567890:ABCdef" not in out + assert "***REDACTED***" in out + + +@pytest.mark.unit +def test_mongo_uri_redacted(): + text = "connecting to mongodb+srv://user:supersecret@cluster.example.net/db" + out = redact_secrets(text) + assert "supersecret" not in out + + +@pytest.mark.unit +def test_clean_text_untouched(): + text = "no secrets here, just 42 and a link https://example.com/f/abc/file" + assert redact_secrets(text) == text + + +@pytest.mark.unit +def test_hash_path_token_is_stable_and_short(): + a = hash_path_token("1234567890:ABCdef-_GHIjklMNOpqrsTUVwxyz1234567") + b = hash_path_token("1234567890:ABCdef-_GHIjklMNOpqrsTUVwxyz1234567") + c = hash_path_token("different") + assert a == b and a != c and len(a) == 8 diff --git a/tests/test_unit/test_registry.py b/tests/test_unit/test_registry.py new file mode 100644 index 0000000..b10a8e5 --- /dev/null +++ b/tests/test_unit/test_registry.py @@ -0,0 +1,47 @@ +# tests/test_unit/test_registry.py +"""M1: registry drives the menu (owner-only hidden) and AGENTS.md drift.""" + +import re +from pathlib import Path + +import pytest + +from Thunder.bot.registry import COMMANDS, bot_commands, help_command_rows + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + + +@pytest.mark.unit +def test_owner_only_commands_hidden_from_menu(): + menu_names = {c.command for c in bot_commands()} + owner_names = {c.name for c in COMMANDS if c.owner_only} + assert owner_names.isdisjoint(menu_names) + # public commands are present + assert {"start", "help", "link", "ping", "dc", "about"} <= menu_names + + +@pytest.mark.unit +def test_descriptions_within_telegram_limit(): + for cmd in bot_commands(): + assert len(cmd.description) <= 256 + + +@pytest.mark.unit +def test_help_rows_match_public_commands(): + rows = help_command_rows() + for cmd in COMMANDS: + if cmd.owner_only: + assert f"/{cmd.name} " not in rows + else: + assert f"/{cmd.name} " in rows + + +@pytest.mark.unit +def test_agents_md_documents_every_command(): + """M1 code-gen drift check: AGENTS.md must mention each command name.""" + agents = (REPO_ROOT / "AGENTS.md").read_text(encoding="utf-8") + for cmd in COMMANDS: + assert re.search(rf"`/{cmd.name}`", agents), ( + f"AGENTS.md is missing `/{cmd.name}` -- update the commands table " + "when editing Thunder/bot/registry.py" + ) diff --git a/tests/test_unit/test_safe_call.py b/tests/test_unit/test_safe_call.py new file mode 100644 index 0000000..bd791e2 --- /dev/null +++ b/tests/test_unit/test_safe_call.py @@ -0,0 +1,68 @@ +# tests/test_unit/test_safe_call.py +"""H4a helper semantics: retry-then-retry, exhaustion, timeout.""" + +import asyncio + +import pytest +from pyrogram.errors import FloodWait + +from Thunder.utils.safe_call import tg_call + + +class Flaky: + """Calls fail N times with FloodWait, then succeed.""" + + def __init__(self, failures: int, wait: float = 0.01): + self.failures = failures + self.wait = wait + self.calls = 0 + + async def __call__(self, *args, **kwargs): + self.calls += 1 + if self.calls <= self.failures: + raise FloodWait(value=self.wait) + return "ok" + + +@pytest.mark.unit +async def test_retries_through_floodwait(): + flaky = Flaky(failures=1) + assert await tg_call(flaky) == "ok" + assert flaky.calls == 2 + + +@pytest.mark.unit +async def test_exhaustion_raises(): + flaky = Flaky(failures=3) + with pytest.raises(FloodWait): + await tg_call(flaky, retries=1) + assert flaky.calls == 2 # initial + one retry + + +@pytest.mark.unit +async def test_zero_retries_propagates_immediately(): + flaky = Flaky(failures=1) + with pytest.raises(FloodWait): + await tg_call(flaky, retries=0, timeout=0) + assert flaky.calls == 1 + + +@pytest.mark.unit +async def test_timeout_fires(): + async def hang(): + await asyncio.sleep(5) + + with pytest.raises(asyncio.TimeoutError): + await tg_call(hang, timeout=0.05, retries=0) + + +@pytest.mark.unit +async def test_on_error_hook_sees_exception(): + seen = [] + + async def boom(): + raise ValueError("boom") + + with pytest.raises(ValueError): + await tg_call(boom, on_error=seen.append, timeout=0, retries=0) + assert isinstance(seen[0], ValueError) diff --git a/tests/test_unit/test_shortener.py b/tests/test_unit/test_shortener.py new file mode 100644 index 0000000..ea7b662 --- /dev/null +++ b/tests/test_unit/test_shortener.py @@ -0,0 +1,66 @@ +# tests/test_unit/test_shortener.py +"""H5b/M5: plugin registry lookup, offline builders, host validation.""" + +import pytest + +from Thunder.utils.shortener import ( + BitlyPlugin, + GenericShortenerPlugin, + LinkvertisePlugin, + ShortenerSystem, +) + + +@pytest.mark.unit +def test_registry_lookup_bitly(): + system = ShortenerSystem() + assert system._get_plugin_class("bitly.com") is BitlyPlugin + + +@pytest.mark.unit +def test_registry_lookup_generic_fallback(): + system = ShortenerSystem() + assert system._get_plugin_class("shrinkme.dev") is GenericShortenerPlugin + + +@pytest.mark.unit +async def test_linkvertise_offline_constructor(): + plugin = LinkvertisePlugin() + out = await plugin.shorten(None, "https://example.com/file", "12345", "linkvertise.com") + assert any( + out.startswith(prefix) + for prefix in ( + "https://link-to.net/", + "https://up-to-down.net/", + "https://direct-link.net/", + "https://file-link.net/", + ) + ) + assert "12345" in out + + +@pytest.mark.unit +@pytest.mark.parametrize( + "short_url,domain,expected", + [ + ("https://shrinkme.dev/xAbc", "shrinkme.dev", True), + ("https://evil.example/xAbc", "shrinkme.dev", False), + ("https://shrinkme.dev.evil.io/xAbc", "shrinkme.dev", False), + ], +) +def test_host_validation(short_url, domain, expected): + assert GenericShortenerPlugin._validate_short_url(short_url, domain) is expected + + +@pytest.mark.unit +async def test_short_url_passthrough_when_not_ready(): + system = ShortenerSystem() + assert await system.short_url("https://example.com") == "https://example.com" + + +@pytest.mark.unit +async def test_cache_hit_is_returned_without_http(): + system = ShortenerSystem() + system.ready = True + system._cache["https://long.example/a"] = "https://shrinkme.dev/xyz" + assert await system.short_url("https://long.example/a") == "https://shrinkme.dev/xyz" diff --git a/tests/test_unit/test_stream_routes.py b/tests/test_unit/test_stream_routes.py new file mode 100644 index 0000000..c76624d --- /dev/null +++ b/tests/test_unit/test_stream_routes.py @@ -0,0 +1,111 @@ +# tests/test_unit/test_stream_routes.py +"""HTTP parsing primitives (H2 target) + L4 dual-hash + L6 disposition.""" + +import pytest +from aiohttp.web import HTTPBadRequest, HTTPRequestRangeNotSatisfiable + +from Thunder.server.stream_routes import ( + build_content_disposition, + parse_media_request, + parse_range_header, + validate_public_hash, +) + + +class TestParseMediaRequest: + @pytest.mark.unit + def test_hash_first(self): + mid, h = parse_media_request("AbCdEf12345/video.mp4", {}) + assert mid == 12345 + assert h == "AbCdEf" + + @pytest.mark.unit + def test_hash_first_with_trailing_slash_path(self): + mid, h = parse_media_request("AbCdEf12345", {}) + assert mid == 12345 + + @pytest.mark.unit + def test_id_first_with_query_hash(self): + mid, h = parse_media_request("12345/name.mp4", {"hash": "AbCdEf"}) + assert mid == 12345 + assert h == "AbCdEf" + + @pytest.mark.unit + def test_invalid_hash_raises(self): + from Thunder.server.exceptions import InvalidHash + + with pytest.raises(InvalidHash): + parse_media_request("12345/name.mp4", {"hash": "short"}) + with pytest.raises(InvalidHash): + parse_media_request("nonsense", {}) + + @pytest.mark.unit + def test_bad_message_id_raises(self): + from Thunder.server.exceptions import InvalidHash + + with pytest.raises(InvalidHash): + parse_media_request("AbCdEf_+*/x", {}) + + +class TestValidatePublicHash: + @pytest.mark.unit + def test_accepts_legacy_20(self): + assert validate_public_hash("a" * 20) == "a" * 20 + + @pytest.mark.unit + def test_accepts_new_32(self): + assert validate_public_hash("b" * 32) == "b" * 32 + + @pytest.mark.unit + def test_rejects_other_lengths_and_normalizes_case(self): + from Thunder.server.exceptions import InvalidHash + + with pytest.raises(InvalidHash): + validate_public_hash("c" * 21) + with pytest.raises(InvalidHash): + validate_public_hash("g" * 32) # not hex + assert validate_public_hash("A" * 32) == "a" * 32 # lowercased + + +class TestParseRangeHeader: + @pytest.mark.unit + def test_full_file(self): + assert parse_range_header("", 100) == (0, 99) + + @pytest.mark.unit + def test_open_ended(self): + assert parse_range_header("bytes=10-", 100) == (10, 99) + + @pytest.mark.unit + def test_closed_range(self): + assert parse_range_header("bytes=0-49", 100) == (0, 49) + + @pytest.mark.unit + def test_suffix_range(self): + assert parse_range_header("bytes=-10", 100) == (90, 99) + + @pytest.mark.unit + def test_unsatisfiable_raises_416(self): + with pytest.raises(HTTPRequestRangeNotSatisfiable) as exc: + parse_range_header("bytes=99999999999-", 100) + assert exc.value.headers["Content-Range"] == "bytes */100" + + @pytest.mark.unit + def test_invalid_header_raises_400(self): + with pytest.raises(HTTPBadRequest): + parse_range_header("bytes=1-2-3", 100) + + +class TestContentDisposition: + @pytest.mark.unit + def test_ascii_fallback_plus_rfc5987(self): + header = build_content_disposition("attachment", "video.mp4") + assert 'filename="video.mp4"' in header + assert "filename*=UTF-8''video.mp4" in header + + @pytest.mark.unit + def test_non_latin_gets_ascii_fallback(self): + header = build_content_disposition("attachment", "视钑 file.mp4") + assert header.startswith("attachment;") + assert 'filename="' in header and "视钑" not in header.split("filename*")[0] + assert "%E8%A7%86%E9%A2%91" in header # encoded filename* diff --git a/tests/test_unit/test_time_format.py b/tests/test_unit/test_time_format.py new file mode 100644 index 0000000..c70dc48 --- /dev/null +++ b/tests/test_unit/test_time_format.py @@ -0,0 +1,29 @@ +# tests/test_unit/test_time_format.py +import pytest + +from Thunder.utils.time_format import get_readable_time + + +@pytest.mark.unit +@pytest.mark.parametrize( + "seconds,expected", + [ + (0, "0s"), + (-10, "0s"), + (59, "59s"), + (60, "1m"), + (61, "1m 1s"), + (3600, "1h"), + (3661, "1h 1m 1s"), + (86400, "1d"), + (90061, "1d 1h 1m 1s"), + ], +) +def test_get_readable_time(seconds, expected): + assert get_readable_time(seconds) == expected + + +@pytest.mark.unit +def test_non_int_input_is_handled(): + # float truncates; garbage returns "N/A" via the guard + assert get_readable_time(90.9) == "1m 30s" From 6fcff3a57a99a60a99068d23a8a89c61660e6698 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 10:27:23 +0000 Subject: [PATCH 06/49] =?UTF-8?q?refactor(H4):=20finish=20FloodWait=20migr?= =?UTF-8?q?ation=20(force=5Fchannel,=20clients)=20=E2=80=94=2097=20->=207?= =?UTF-8?q?=20blocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining 7 inline FloodWait references are the deliberate ones: - safe_call.py (the helper itself, 2) - custom_dl.py streaming pool loops (2, explicitly allowed by plan) - rate_limiter.py requeue-on-FloodWait executor logic (1) - canonical_files.py re-raise into the ingest retry loop (1) - broadcast.py classification of exhausted FloodWait as failed send (1) --- Thunder/bot/clients.py | 7 +--- Thunder/logs/bot.txt | 18 ++++++++++ Thunder/utils/force_channel.py | 66 +++++++++++++++------------------- 3 files changed, 47 insertions(+), 44 deletions(-) diff --git a/Thunder/bot/clients.py b/Thunder/bot/clients.py index a6951f7..975378b 100755 --- a/Thunder/bot/clients.py +++ b/Thunder/bot/clients.py @@ -5,7 +5,6 @@ import os from pyrogram import Client -from pyrogram.errors import FloodWait from Thunder.bot import StreamBot, multi_clients, work_loads from Thunder.utils.config_parser import TokenParser @@ -65,11 +64,7 @@ async def start_client(client_id, token): max_concurrent_transmissions=1000, sleep_threshold=Var.SLEEP_THRESHOLD, ) - try: - await client.start() - except FloodWait as e: - await asyncio.sleep(e.value) - await client.start() + await tg_call(client.start) work_loads[client_id] = 0 print(f" β—Ž Client ID {client_id} started") return client_id, client diff --git a/Thunder/logs/bot.txt b/Thunder/logs/bot.txt index 390f7a7..15e73da 100644 --- a/Thunder/logs/bot.txt +++ b/Thunder/logs/bot.txt @@ -54,3 +54,21 @@ 2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer 2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) 2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer +2026-09-06 10:25:46,103 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: +2026-09-06 10:25:46,103 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer +2026-09-06 10:25:46,103 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) +2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: +2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer +2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) +2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer +2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) +2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer +2026-09-06 10:27:03,783 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: +2026-09-06 10:27:03,783 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer +2026-09-06 10:27:03,783 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) +2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: +2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer +2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) +2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer +2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) +2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py index 13b12ee..934f507 100755 --- a/Thunder/utils/force_channel.py +++ b/Thunder/utils/force_channel.py @@ -1,13 +1,12 @@ # Thunder/utils/force_channel.py -import asyncio - from pyrogram import Client -from pyrogram.errors import FloodWait, UserNotParticipant +from pyrogram.errors import UserNotParticipant from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from Thunder.utils.logger import logger from Thunder.utils.messages import MSG_COMMUNITY_CHANNEL +from Thunder.utils.safe_call import reply_safe, tg_call from Thunder.vars import Var _force_link = None @@ -24,11 +23,7 @@ async def get_force_info(bot: Client): 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) + chat = await tg_call(bot.get_chat, Var.FORCE_CHANNEL_ID, retries=1) if chat: _force_link = chat.invite_link or ( f"https://t.me/{chat.username}" if chat.username else None @@ -48,48 +43,43 @@ async def force_channel_check(client: Client, message: Message): 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) + member = await tg_call( + client.get_chat_member, + Var.FORCE_CHANNEL_ID, + message.from_user.id, + retries=1, + ) + if member is None: + logger.error( + f"Failed to get chat member for {message.from_user.id} in " + f"force channel {Var.FORCE_CHANNEL_ID} after retries." + ) + return False + return True 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( + await reply_safe( + message, MSG_COMMUNITY_CHANNEL.format(channel_title=title), reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Join", url=link)]]), ) + except Exception as e: + logger.warning(f"Could not send force-sub prompt: {e}") 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.") + await reply_safe(message, "You must join the channel to use this bot.") + except Exception as e: + logger.warning(f"Could not send force-sub notice: {e}") 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." + await reply_safe( + message, + "An unexpected error occurred while checking channel membership. Please try again.", ) + except Exception as inner_e: + logger.warning(f"Could not send force-sub error notice: {inner_e}") return False From d3dda0598d3028be486433385b21ef5882edb9be Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 11:08:51 +0000 Subject: [PATCH 07/49] =?UTF-8?q?recheck:=20close=20audit=20gaps=20?= =?UTF-8?q?=E2=80=94=20real=20fixes=20+=20permanent=20gates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real bugs found by the recheck pass: - shortener: allow_redirects=False on ClientSession() raises TypeError at runtime (aiohttp rejects it there) and silently disabled the M5 shortener; redirects now enforced per-request on all 4 plugin calls - database: missing await on AsyncCollection.aggregate() broke the duplicate-user dedup path (async-for over a coroutine raises TypeError) Audit gaps closed (plan fidelity): - H1: commit uv.lock (hash-pinned, 70 pkgs, uv sync --frozen verified on py3.13.15) + pip-audit in dev group + uv lock --check CI gate - H4b/L8 hygiene: remove committed runtime log Thunder/logs/bot.txt, gitignore Thunder/logs/ (+ fix log.text -> log.txt) - L8: __version__ now injectable via APP_VERSION env (default 2.2.0, aligned with pyproject; /status reads it) - quality.yml: docker publish restricted to refs/heads/main so feature pushes can never overwrite fyaz05/thunder:latest - mypy made BLOCKING: all 44 errors fixed (2 real bugs above + typed dict/float annotations + defensive list-narrowing + targeted stub-union ignores with comments); Makefile lint no longer || true - 45 tracked files restored to 644 (stray 755 mode bits) Gates on final tree: ruff check+format PASS, mypy 0 errors (35 files), 103 unit tests PASS (py3.13.15, locked deps), vulture PASS, bandit medium+ = 0, pip-audit clean, dep count = 8, uv lock --check PASS --- .github/workflows/quality.yml | 12 +- .gitignore | 2 + .python-version | 0 AGENTS.md | 7 +- Dockerfile | 0 LICENSE | 0 Makefile | 22 +- Procfile | 0 README.md | 0 Thunder/__init__.py | 7 +- Thunder/__main__.py | 0 Thunder/bot/__init__.py | 4 +- Thunder/bot/clients.py | 0 Thunder/bot/plugins/admin.py | 0 Thunder/bot/plugins/callbacks.py | 17 +- Thunder/bot/plugins/common.py | 16 +- Thunder/bot/plugins/stream.py | 27 +- Thunder/logs/bot.txt | 74 -- Thunder/server/__init__.py | 0 Thunder/server/exceptions.py | 0 Thunder/server/stream_routes.py | 5 +- Thunder/template/req.html | 0 Thunder/utils/bot_utils.py | 25 +- Thunder/utils/broadcast.py | 3 +- Thunder/utils/canonical_files.py | 0 Thunder/utils/commands.py | 0 Thunder/utils/config_parser.py | 0 Thunder/utils/custom_dl.py | 8 +- Thunder/utils/database.py | 7 +- Thunder/utils/decorators.py | 0 Thunder/utils/file_properties.py | 0 Thunder/utils/force_channel.py | 7 +- Thunder/utils/human_readable.py | 7 +- Thunder/utils/keepalive.py | 0 Thunder/utils/logger.py | 12 +- Thunder/utils/messages.py | 0 Thunder/utils/rate_limiter.py | 4 +- Thunder/utils/render_template.py | 4 + Thunder/utils/shortener.py | 16 +- Thunder/utils/time_format.py | 0 Thunder/utils/tokens.py | 0 Thunder/vars.py | 0 config_sample.env | 0 heroku.yml | 0 pyproject.toml | 1 + requirements.txt | 0 thunder.sh | 0 update.py | 0 uv.lock | 1795 ++++++++++++++++++++++++++++++ 49 files changed, 1940 insertions(+), 142 deletions(-) mode change 100755 => 100644 .gitignore mode change 100755 => 100644 .python-version mode change 100755 => 100644 AGENTS.md mode change 100755 => 100644 Dockerfile mode change 100755 => 100644 LICENSE mode change 100755 => 100644 Procfile mode change 100755 => 100644 README.md mode change 100755 => 100644 Thunder/__init__.py mode change 100755 => 100644 Thunder/__main__.py mode change 100755 => 100644 Thunder/bot/__init__.py mode change 100755 => 100644 Thunder/bot/clients.py mode change 100755 => 100644 Thunder/bot/plugins/admin.py mode change 100755 => 100644 Thunder/bot/plugins/callbacks.py mode change 100755 => 100644 Thunder/bot/plugins/common.py mode change 100755 => 100644 Thunder/bot/plugins/stream.py delete mode 100644 Thunder/logs/bot.txt mode change 100755 => 100644 Thunder/server/__init__.py mode change 100755 => 100644 Thunder/server/exceptions.py mode change 100755 => 100644 Thunder/server/stream_routes.py mode change 100755 => 100644 Thunder/template/req.html mode change 100755 => 100644 Thunder/utils/bot_utils.py mode change 100755 => 100644 Thunder/utils/broadcast.py mode change 100755 => 100644 Thunder/utils/canonical_files.py mode change 100755 => 100644 Thunder/utils/commands.py mode change 100755 => 100644 Thunder/utils/config_parser.py mode change 100755 => 100644 Thunder/utils/custom_dl.py mode change 100755 => 100644 Thunder/utils/database.py mode change 100755 => 100644 Thunder/utils/decorators.py mode change 100755 => 100644 Thunder/utils/file_properties.py mode change 100755 => 100644 Thunder/utils/force_channel.py mode change 100755 => 100644 Thunder/utils/human_readable.py mode change 100755 => 100644 Thunder/utils/keepalive.py mode change 100755 => 100644 Thunder/utils/logger.py mode change 100755 => 100644 Thunder/utils/messages.py mode change 100755 => 100644 Thunder/utils/rate_limiter.py mode change 100755 => 100644 Thunder/utils/render_template.py mode change 100755 => 100644 Thunder/utils/shortener.py mode change 100755 => 100644 Thunder/utils/time_format.py mode change 100755 => 100644 Thunder/utils/tokens.py mode change 100755 => 100644 Thunder/vars.py mode change 100755 => 100644 config_sample.env mode change 100755 => 100644 heroku.yml mode change 100755 => 100644 requirements.txt mode change 100755 => 100644 thunder.sh mode change 100755 => 100644 update.py create mode 100644 uv.lock diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index de56113..51aa63a 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -22,20 +22,22 @@ jobs: pip install -r requirements.txt pip install pytest pytest-asyncio pytest-cov ruff mypy bandit vulture pip-audit + - name: Lockfile is current with pyproject (uv.lock must never drift) + run: uv lock --check + - name: Ruff (lint + format check) run: | ruff check Thunder/ update.py ruff format --check Thunder/ update.py - - name: Mypy (permissive baseline, tightened per phase) + - name: Mypy (blocking since the Sep 2026 quality pass: 44 errors fixed, 0 remain) run: mypy Thunder --ignore-missing-imports - continue-on-error: true - name: Unit tests run: pytest -m unit - name: pip-audit - run: pip-audit -r requirements.txt --strict || pip-audit -r requirements.txt + run: pip-audit -r requirements.txt - name: Bandit (medium+ severity) run: bandit -r Thunder -ll --skip B101 @@ -53,7 +55,9 @@ jobs: fi docker: - if: github.repository == 'fyaz05/FileToLink' && github.event_name == 'push' + # publish images from main only (a feature-branch push must never + # overwrite the fyaz05/thunder:latest tag) + if: github.repository == 'fyaz05/FileToLink' && github.event_name == 'push' && github.ref == 'refs/heads/main' needs: quality runs-on: ubuntu-latest steps: diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 3ca1470..b035926 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,8 @@ htmlcov/ .ruff_cache/ .mypy_cache/ log.text +log.txt +Thunder/logs/ .vscode/ **/__pycache__/ *.session diff --git a/.python-version b/.python-version old mode 100755 new mode 100644 diff --git a/AGENTS.md b/AGENTS.md old mode 100755 new mode 100644 index 4227f81..b18f04f --- a/AGENTS.md +++ b/AGENTS.md @@ -16,9 +16,12 @@ bash thunder.sh # best-effort self-update (shell-free) + python3 -m T ## Dependencies Managed in `pyproject.toml`, exported to `requirements.txt` (8 direct deps, -all exact-pinned; the CI dependency-count gate fails beyond 8): +all exact-pinned; the CI dependency-count gate fails beyond 8). +`uv.lock` pins the full transitive graph with hashes β€” regenerate it with +`uv lock` whenever `pyproject.toml` changes (CI fails if it drifts): ```bash +uv sync --frozen # reproducible env from the lockfile pip install -r requirements.txt # aiohttp, pyrofork, tgcrypto-pyrofork, pymongo, Jinja2, python-dotenv, psutil, uvloop ``` @@ -31,7 +34,7 @@ For Cloudflare-protected shorteners install the optional extra: ```bash make format # ruff autofix + format -make lint # ruff + mypy (permissive) +make lint # ruff + mypy (blocking: 0 errors expected) make test # unit tier (hermetic: no network, no Mongo) make audit # pip-audit + bandit + vulture + dependency-count ``` diff --git a/Dockerfile b/Dockerfile old mode 100755 new mode 100644 diff --git a/LICENSE b/LICENSE old mode 100755 new mode 100644 diff --git a/Makefile b/Makefile index f817148..ebb20dc 100644 --- a/Makefile +++ b/Makefile @@ -3,26 +3,26 @@ # L8: developer entry points (see CONTRIBUTING.md) format: - ruff check Thunder/ update.py --fix - ruff format Thunder/ update.py + ruff check Thunder/ update.py --fix + ruff format Thunder/ update.py lint: - ruff check Thunder/ update.py - mypy Thunder --ignore-missing-imports || true + ruff check Thunder/ update.py + mypy Thunder --ignore-missing-imports test: - pytest -m unit + pytest -m unit coverage: - pytest -m unit --cov=Thunder --cov-report=html + pytest -m unit --cov=Thunder --cov-report=html audit: - pip-audit -r requirements.txt - bandit -r Thunder -ll --skip B101 - vulture Thunder whitelist.py --min-confidence 80 + pip-audit -r requirements.txt + bandit -r Thunder -ll --skip B101 + vulture Thunder whitelist.py --min-confidence 80 run: - python3 -m Thunder + python3 -m Thunder clean: - rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ + rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ diff --git a/Procfile b/Procfile old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 diff --git a/Thunder/__init__.py b/Thunder/__init__.py old mode 100755 new mode 100644 index 1613fd7..a768d57 --- a/Thunder/__init__.py +++ b/Thunder/__init__.py @@ -1,6 +1,11 @@ # Thunder/__init__.py +import os import time StartTime = time.time() -__version__ = "2.1.0" + +# L8: build-time injectable version (Docker/PaaS may set APP_VERSION; the +# pyproject.toml [project] version is the single source of truth for the +# default). Exposed by /status and /stats. +__version__ = os.getenv("APP_VERSION", "2.2.0") diff --git a/Thunder/__main__.py b/Thunder/__main__.py old mode 100755 new mode 100644 diff --git a/Thunder/bot/__init__.py b/Thunder/bot/__init__.py old mode 100755 new mode 100644 index c45c4d7..fa38684 --- a/Thunder/bot/__init__.py +++ b/Thunder/bot/__init__.py @@ -14,5 +14,5 @@ max_concurrent_transmissions=1000, ) -multi_clients = {} -work_loads = {} +multi_clients: dict[int, Client] = {} +work_loads: dict[int, int] = {} diff --git a/Thunder/bot/clients.py b/Thunder/bot/clients.py old mode 100755 new mode 100644 diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py old mode 100755 new mode 100644 diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py old mode 100755 new mode 100644 index 57747f9..39233de --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -74,8 +74,9 @@ async def get_force_channel_button(client: Client): try: chat = await tg_call(client.get_chat, Var.FORCE_CHANNEL_ID, retries=1) if chat: - invite_link = chat.invite_link or ( - f"https://t.me/{chat.username}" if chat.username else None + # numeric channel id always resolves a full Chat (see force_channel.py) + invite_link = chat.invite_link or ( # type: ignore[union-attr] + f"https://t.me/{chat.username}" if chat.username else None # type: ignore[union-attr] ) if invite_link: return [ @@ -108,7 +109,7 @@ async def help_callback(client: Client, callback_query: CallbackQuery): await edit_safe( callback_query.message, help_text, - reply_markup=InlineKeyboardMarkup(buttons), + reply_markup=InlineKeyboardMarkup(buttons), # type: ignore[arg-type] disable_web_page_preview=True, ) except Exception as e: @@ -130,7 +131,7 @@ async def about_callback(client: Client, callback_query: CallbackQuery): await edit_safe( callback_query.message, MSG_ABOUT, - reply_markup=InlineKeyboardMarkup(buttons), + reply_markup=InlineKeyboardMarkup(buttons), # type: ignore[arg-type] disable_web_page_preview=True, ) except Exception as e: @@ -153,7 +154,7 @@ async def restart_broadcast_callback(client: Client, callback_query: CallbackQue await edit_safe( callback_query.message, MSG_ERROR_BROADCAST_INSTRUCTION, - reply_markup=InlineKeyboardMarkup(buttons), + reply_markup=InlineKeyboardMarkup(buttons), # type: ignore[arg-type] disable_web_page_preview=True, ) except Exception as e: @@ -193,7 +194,11 @@ async def close_panel_callback(client: Client, callback_query: CallbackQuery): @StreamBot.on_callback_query(filters.regex(r"^cancel_")) @guard_callback async def cancel_broadcast(client: Client, callback_query: CallbackQuery): - broadcast_id = callback_query.data.split("_")[1] + # callback data is always a str for sent buttons; tolerate the stub union + raw = callback_query.data or "" + if isinstance(raw, bytes): + raw = raw.decode("utf-8", errors="replace") + broadcast_id = raw.split("_", 1)[1] if broadcast_id in broadcast_ids: broadcast_ids[broadcast_id]["cancelled"] = True try: diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py old mode 100755 new mode 100644 index a8f9ed3..5bebb45 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -173,7 +173,11 @@ async def send_user_dc(msg: Message, user: User): [InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=url)], [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")], ] - await reply_safe(msg, text=txt, reply_markup=InlineKeyboardMarkup(btns)) + await reply_safe( + msg, + text=txt, + reply_markup=InlineKeyboardMarkup(btns), # type: ignore[arg-type] + ) async def send_file_dc(msg: Message, file_msg: Message): @@ -195,7 +199,7 @@ async def send_file_dc(msg: Message, file_msg: Message): file_type = next((attr for attr in type_map if getattr(file_msg, attr, None)), "unknown") type_display = type_map.get(file_type, MSG_FILE_TYPE_UNKNOWN) - dc_id = MSG_DC_UNKNOWN + dc_id: int | str = MSG_DC_UNKNOWN fid = parse_fid(file_msg) if fid: dc_id = fid.dc_id @@ -205,7 +209,11 @@ async def send_file_dc(msg: Message, file_msg: Message): ) btns = [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] - await reply_safe(msg, text=txt, reply_markup=InlineKeyboardMarkup(btns)) + await reply_safe( + msg, + text=txt, + reply_markup=InlineKeyboardMarkup(btns), # type: ignore[arg-type] + ) except Exception as e: logger.error(f"File DC error: {e}", exc_info=True) @@ -280,7 +288,7 @@ async def ping_command(bot: Client, msg: Message): await edit_safe( sent, MSG_PING_RESPONSE.format(time_taken_ms=ms), - reply_markup=InlineKeyboardMarkup(btns), + reply_markup=InlineKeyboardMarkup(btns), # type: ignore[arg-type] disable_web_page_preview=True, ) except MessageNotModified: diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py old mode 100755 new mode 100644 index c798417..c2f094d --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -66,17 +66,21 @@ async def fwd_media(m_msg: Message) -> Message | None: try: - return await tg_call(m_msg.copy, chat_id=Var.BIN_CHANNEL) + result = await tg_call(m_msg.copy, chat_id=Var.BIN_CHANNEL) except Exception as e: if "MEDIA_CAPTION_TOO_LONG" in str(e): logger.debug(f"MEDIA_CAPTION_TOO_LONG error, retrying without caption: {e}") try: - return await tg_call(m_msg.copy, chat_id=Var.BIN_CHANNEL, caption=None) + result = await tg_call(m_msg.copy, chat_id=Var.BIN_CHANNEL, caption=None) except Exception as e2: logger.error(f"Error fwd_media copy (no caption): {e2}", exc_info=True) return None - logger.error(f"Error fwd_media copy: {e}", exc_info=True) - return None + else: + logger.error(f"Error fwd_media copy: {e}", exc_info=True) + return None + if isinstance(result, list): # defensive: pyrogram returns a list for multi-chat copies + return result[0] if result else None + return result def get_link_buttons(links): @@ -189,7 +193,9 @@ async def _actual_link_handler(client: Client, message: Message, **handler_kwarg if shortener_val is None: return if message.from_user and not await db.is_user_exist(message.from_user.id): - invite_link = f"https://t.me/{client.me.username}?start=start" + # client.me is always populated after client.start(); the stub union + # is unavoidable at this layer. + invite_link = f"https://t.me/{client.me.username}?start=start" # type: ignore[union-attr] try: await reply_safe( message, @@ -555,8 +561,13 @@ async def process_batch( break chunk_ids = ids[chunk_start : chunk_start + BATCH_SIZE] try: - messages = await tg_call(bot.get_messages, msg.chat.id, chunk_ids, retries=1) - messages = list(messages) if messages else [] + fetched_msgs = await tg_call(bot.get_messages, msg.chat.id, chunk_ids, retries=1) + if fetched_msgs is None: + messages = [] + elif isinstance(fetched_msgs, Message): # single id -> single message + messages = [fetched_msgs] + else: + messages = list(fetched_msgs) except Exception as e: logger.error(f"Error getting messages in batch: {e}", exc_info=True) messages = [] @@ -633,7 +644,7 @@ async def worker(): failed = counters["failed"] processed = sum(1 for r in results.values() if r) - links_list = [results[mid]["online_link"] for mid in ids if results.get(mid)] + links_list = [rec["online_link"] for mid in ids if (rec := results.get(mid))] for i in range(0, len(links_list), LINK_CHUNK_SIZE): chunk = links_list[i : i + LINK_CHUNK_SIZE] chunk_text = ( diff --git a/Thunder/logs/bot.txt b/Thunder/logs/bot.txt deleted file mode 100644 index 15e73da..0000000 --- a/Thunder/logs/bot.txt +++ /dev/null @@ -1,74 +0,0 @@ -2026-09-06 10:14:29,282 - ThunderBot - INFO - Gate mode: public; legacy links: on -2026-09-06 10:15:02,579 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: -2026-09-06 10:15:02,579 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer -2026-09-06 10:15:02,579 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) -2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: -2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer -2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) -2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer -2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) -2026-09-06 10:15:02,637 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer -2026-09-06 10:15:38,814 - ThunderBot - INFO - Gate mode: public; legacy links: on -2026-09-06 10:16:56,293 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: -2026-09-06 10:16:56,293 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer -2026-09-06 10:16:56,293 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) -2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: -2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer -2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) -2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer -2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) -2026-09-06 10:16:56,349 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer -2026-09-06 10:18:46,850 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: -2026-09-06 10:18:46,850 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer -2026-09-06 10:18:46,850 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) -2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: -2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer -2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) -2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer -2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) -2026-09-06 10:18:46,908 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer -2026-09-06 10:20:07,765 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: -2026-09-06 10:20:07,765 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer -2026-09-06 10:20:07,765 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) -2026-09-06 10:20:07,834 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: -2026-09-06 10:20:07,834 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer -2026-09-06 10:20:07,834 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) -2026-09-06 10:20:07,835 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer -2026-09-06 10:20:07,835 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) -2026-09-06 10:20:07,835 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer -2026-09-06 10:22:36,612 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: -2026-09-06 10:22:36,612 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer -2026-09-06 10:22:36,612 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) -2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: -2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer -2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) -2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer -2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) -2026-09-06 10:22:36,673 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer -2026-09-06 10:23:21,576 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: -2026-09-06 10:23:21,576 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer -2026-09-06 10:23:21,576 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) -2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: -2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer -2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) -2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer -2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) -2026-09-06 10:23:21,636 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer -2026-09-06 10:25:46,103 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: -2026-09-06 10:25:46,103 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer -2026-09-06 10:25:46,103 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) -2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: -2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer -2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) -2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer -2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) -2026-09-06 10:25:46,184 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer -2026-09-06 10:27:03,783 - ThunderBot - CRITICAL - Invalid configuration -- 2 problem(s) found: -2026-09-06 10:27:03,783 - ThunderBot - CRITICAL - βœ– OWNER_ID='' is not a valid integer -2026-09-06 10:27:03,783 - ThunderBot - CRITICAL - βœ– OWNER_ID is required (your Telegram user id (get from @userinfobot)) -2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - Invalid configuration -- 5 problem(s) found: -2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– API_ID='not-a-number' is not a valid integer -2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– API_ID is required (numeric app id from my.telegram.org) -2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL='also-bad' is not a valid integer -2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– BIN_CHANNEL is required (storage channel id, e.g. -1001234567890) -2026-09-06 10:27:03,843 - ThunderBot - CRITICAL - βœ– MAX_BATCH_FILES='not-int' is not a valid integer diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py old mode 100755 new mode 100644 diff --git a/Thunder/server/exceptions.py b/Thunder/server/exceptions.py old mode 100755 new mode 100644 diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py old mode 100755 new mode 100644 index 551297d..1703815 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -3,6 +3,7 @@ import re import secrets import time +from collections.abc import Mapping from urllib.parse import quote, unquote from aiohttp import web @@ -53,7 +54,7 @@ "Access-Control-Expose-Headers": "Content-Length, Content-Range, Content-Disposition", } -streamers = {} +streamers: dict[int, "ByteStreamer"] = {} def get_streamer(client_id: int) -> ByteStreamer: @@ -62,7 +63,7 @@ def get_streamer(client_id: int) -> ByteStreamer: return streamers[client_id] -def parse_media_request(path: str, query: dict) -> tuple[int, str]: +def parse_media_request(path: str, query: Mapping[str, str]) -> tuple[int, str]: clean_path = unquote(path).strip("/") match = PATTERN_HASH_FIRST.match(clean_path) diff --git a/Thunder/template/req.html b/Thunder/template/req.html old mode 100755 new mode 100644 diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py old mode 100755 new mode 100644 index a18956f..21702f1 --- a/Thunder/utils/bot_utils.py +++ b/Thunder/utils/bot_utils.py @@ -60,14 +60,14 @@ async def _build_links( if shortener and getattr(Var, "SHORTEN_MEDIA_LINKS", False): try: s_results = await asyncio.gather(shorten(slink), shorten(olink), return_exceptions=True) - if not isinstance(s_results[0], Exception): - slink = s_results[0] - else: + if isinstance(s_results[0], BaseException): logger.warning(f"Failed to shorten stream_link: {s_results[0]}") - if not isinstance(s_results[1], Exception): - olink = s_results[1] else: + slink = s_results[0] + if isinstance(s_results[1], BaseException): logger.warning(f"Failed to shorten online_link: {s_results[1]}") + else: + olink = s_results[1] except Exception as e: logger.error(f"Error during link shortening: {e}") @@ -169,24 +169,33 @@ async def gen_dc_txt(usr: User) -> str: async def get_user(cli: Client, qry: Any) -> User | None: if isinstance(qry, str) and qry.startswith("@"): try: - return await tg_call(cli.get_users, qry) + result = await tg_call(cli.get_users, qry) except Exception as e: logger.debug(f"get_users failed for {qry}: {e}") return None + if isinstance(result, list): # defensive: pyrogram returns a list for list inputs + return result[0] if result else None + return result if isinstance(qry, str) and qry.isdigit(): qry = int(qry) if isinstance(qry, int): try: - return await tg_call(cli.get_users, qry) + result = await tg_call(cli.get_users, qry) except Exception as e: logger.debug(f"get_users failed for {qry}: {e}") return None + if isinstance(result, list): # defensive: pyrogram returns a list for list inputs + return result[0] if result else None + return result return None async def is_admin(cli: Client, chat_id_val: int) -> bool: try: - member = await tg_call(cli.get_chat_member, chat_id_val, cli.me.id, retries=1) + # cli.me is always populated after client.start(); fall back to an id + # that cannot match any chat member if it somehow is not. + me_id = cli.me.id if cli.me else 0 + member = await tg_call(cli.get_chat_member, chat_id_val, me_id, retries=1) except Exception: return False if member is None: diff --git a/Thunder/utils/broadcast.py b/Thunder/utils/broadcast.py old mode 100755 new mode 100644 index 505cc53..45af0f2 --- a/Thunder/utils/broadcast.py +++ b/Thunder/utils/broadcast.py @@ -3,6 +3,7 @@ import asyncio import os import time +from typing import Any from pyrogram.client import Client from pyrogram.enums import ParseMode @@ -29,7 +30,7 @@ from Thunder.utils.time_format import get_readable_time from Thunder.vars import Var -broadcast_ids = {} +broadcast_ids: dict[str, dict[str, Any]] = {} # Errors that mean the recipient will never be reachable again. _PERMANENT_ERRORS = ( diff --git a/Thunder/utils/canonical_files.py b/Thunder/utils/canonical_files.py old mode 100755 new mode 100644 diff --git a/Thunder/utils/commands.py b/Thunder/utils/commands.py old mode 100755 new mode 100644 diff --git a/Thunder/utils/config_parser.py b/Thunder/utils/config_parser.py old mode 100755 new mode 100644 diff --git a/Thunder/utils/custom_dl.py b/Thunder/utils/custom_dl.py old mode 100755 new mode 100644 index aae6a0e..092799f --- a/Thunder/utils/custom_dl.py +++ b/Thunder/utils/custom_dl.py @@ -34,6 +34,10 @@ async def get_message(self, message_id: int) -> Message: logger.debug(f"Error fetching message {message_id}: {e}", exc_info=True) raise FileNotFound(f"Message {message_id} not found") from e + if isinstance(message, list): # defensive: pyrogram returns a list for list inputs + if not message: + raise FileNotFound(f"Message {message_id} not found") + message = message[0] if not message or not message.media: raise FileNotFound(f"Message {message_id} not found") return message @@ -57,7 +61,9 @@ async def stream_file( target = ( await self.get_message(media_ref) if isinstance(media_ref, int) else media_ref ) - async for chunk in self.client.stream_media( + # stream_media is an async generator in pyrofork; the stubs + # union it with file_ref types, so narrow via ignore here. + async for chunk in self.client.stream_media( # type: ignore[union-attr] target, offset=chunk_offset, limit=chunk_limit ): yield chunk diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py old mode 100755 new mode 100644 index d07506a..b193764 --- a/Thunder/utils/database.py +++ b/Thunder/utils/database.py @@ -29,13 +29,16 @@ def __init__(self, uri: str, database_name: str, *args, **kwargs): self.file_ingest_locks_col: AsyncCollection = self.db.file_ingest_locks async def _deduplicate_users(self) -> None: - pipeline = [ + pipeline: list[dict[str, Any]] = [ {"$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): + # AsyncCollection.aggregate() is a coroutine in pymongo's async API: + # iterate the awaited cursor, never the coroutine itself. + cursor = await self.col.aggregate(pipeline) + async for doc in cursor: keep_ids.append(doc["_id"]) if keep_ids: result = await self.col.delete_many({"_id": {"$nin": keep_ids}}) diff --git a/Thunder/utils/decorators.py b/Thunder/utils/decorators.py old mode 100755 new mode 100644 diff --git a/Thunder/utils/file_properties.py b/Thunder/utils/file_properties.py old mode 100755 new mode 100644 diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py old mode 100755 new mode 100644 index 934f507..687266b --- a/Thunder/utils/force_channel.py +++ b/Thunder/utils/force_channel.py @@ -25,8 +25,11 @@ async def get_force_info(bot: Client): try: chat = await tg_call(bot.get_chat, Var.FORCE_CHANNEL_ID, retries=1) if chat: - _force_link = chat.invite_link or ( - f"https://t.me/{chat.username}" if chat.username else None + # Var.FORCE_CHANNEL_ID is a numeric channel id: get_chat always + # resolves a full Chat there (ChatPreview only comes from link + # resolution), so the stub-union members are unreachable. + _force_link = chat.invite_link or ( # type: ignore[union-attr] + f"https://t.me/{chat.username}" if chat.username else None # type: ignore[union-attr] ) _force_title = chat.title or "Channel" return _force_link, _force_title diff --git a/Thunder/utils/human_readable.py b/Thunder/utils/human_readable.py old mode 100755 new mode 100644 index b42e11d..7e87960 --- a/Thunder/utils/human_readable.py +++ b/Thunder/utils/human_readable.py @@ -10,10 +10,11 @@ def humanbytes(size: int, decimal_places: int = 2) -> str: if not size: return "0 B" n = 0 - while size >= 1024 and n < len(_UNITS) - 1: - size /= 1024 + value: float = size + while value >= 1024 and n < len(_UNITS) - 1: + value /= 1024 n += 1 - return f"{round(size, decimal_places)} {_UNITS[n]}B" + return f"{round(value, 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 100755 new mode 100644 diff --git a/Thunder/utils/logger.py b/Thunder/utils/logger.py old mode 100755 new mode 100644 index ac61ca3..3b035d5 --- a/Thunder/utils/logger.py +++ b/Thunder/utils/logger.py @@ -14,8 +14,8 @@ LOG_FILE = os.path.join(LOG_DIR, "bot.txt") logging._srcfile = None -logging.logThreads = 0 -logging.logProcesses = 0 +logging.logThreads = False +logging.logProcesses = False # -------------------------------------------------------------------------- # H10: shared secret redaction -- used by the access-log middleware and by @@ -75,7 +75,7 @@ def format(self, record: logging.LogRecord) -> str: _log_level = getattr(logging, _log_level_name, logging.INFO) _log_format = os.getenv("LOG_FORMAT", "plain").lower() -log_queue = queue.Queue(maxsize=10000) +log_queue: queue.Queue[logging.LogRecord] = queue.Queue(maxsize=10000) if _log_format == "json": file_formatter: logging.Formatter = JsonFormatter() @@ -92,7 +92,11 @@ def format(self, record: logging.LogRecord) -> str: console_handler = logging.StreamHandler(stream=sys.__stdout__) console_handler.setFormatter(console_formatter) -console_handler.stream.reconfigure(encoding="utf-8", errors="replace") +# reconfigure exists on io.TextIOWrapper (the real sys.__stdout__ under +# CPython); guard so wrapped/replaced streams can never crash boot. +_stream = console_handler.stream +if hasattr(_stream, "reconfigure"): + _stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined] listener = QueueListener(log_queue, file_handler, console_handler, respect_handler_level=True) listener.start() diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py old mode 100755 new mode 100644 diff --git a/Thunder/utils/rate_limiter.py b/Thunder/utils/rate_limiter.py old mode 100755 new mode 100644 index 020c1fd..9d45ae0 --- a/Thunder/utils/rate_limiter.py +++ b/Thunder/utils/rate_limiter.py @@ -620,8 +620,8 @@ async def _send_notification( logger.debug("Skipping notification for channel message (no from_user)") return None except (FloodWait, RPCError) as e: - user_id = message.from_user.id if message.from_user else "channel" - logger.warning(f"Error sending notification to user {user_id}: {e}") + who: int | str = message.from_user.id if message.from_user else "channel" + logger.warning(f"Error sending notification to user {who}: {e}") except Exception as e: logger.error(f"Unexpected error sending notification: {e}", exc_info=True) return None diff --git a/Thunder/utils/render_template.py b/Thunder/utils/render_template.py old mode 100755 new mode 100644 index 66de038..0d30de7 --- a/Thunder/utils/render_template.py +++ b/Thunder/utils/render_template.py @@ -124,6 +124,10 @@ async def render_page( if not message: raise InvalidHash("Message not found") + if isinstance(message, list): # defensive: pyrogram returns a list for list inputs + if not message: + raise InvalidHash("Message not found") + message = message[0] file_unique_id = get_uniqid(message) file_name = get_fname(message) diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py old mode 100755 new mode 100644 index 497c5df..18c9918 --- a/Thunder/utils/shortener.py +++ b/Thunder/utils/shortener.py @@ -89,6 +89,7 @@ async def shorten( "https://api-ssl.bit.ly/v4/shorten", json={"long_url": url}, headers={"Authorization": f"Bearer {api_key}"}, + allow_redirects=False, # M5: a 30x can never pass for a short URL ) as resp: if resp.status == 200: data = await resp.json() @@ -104,7 +105,9 @@ def matches(cls, domain: str) -> bool: async def shorten( self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str ) -> str: - async with session.get(f"https://ouo.io/api/{api_key}", params={"s": url}) as resp: + async with session.get( + f"https://ouo.io/api/{api_key}", params={"s": url}, allow_redirects=False + ) as resp: if resp.status == 200: text = (await resp.text()).strip() if text and self._validate_short_url(text, domain): @@ -121,7 +124,9 @@ async def shorten( self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str ) -> str: async with session.get( - "https://cutt.ly/api/api.php", params={"key": api_key, "short": url} + "https://cutt.ly/api/api.php", + params={"key": api_key, "short": url}, + allow_redirects=False, ) as resp: if resp.status == 200: data = await resp.json() @@ -143,6 +148,7 @@ async def shorten( f"https://{domain}/api", params={"api": api_key, "url": url}, headers={"Authorization": f"Bearer {api_key}"} if api_key else {}, + allow_redirects=False, ) as resp: if resp.status == 200: data = await resp.json() @@ -188,11 +194,11 @@ async def initialize(self) -> bool: timeout = aiohttp.ClientTimeout(total=SHORTEN_TIMEOUT_SECONDS) self.session = aiohttp.ClientSession( timeout=timeout, - # never follow redirects (M5): a 30x can never be mistaken - # for a successful shortening - allow_redirects=False, headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) FileToLink/shortener"}, ) + # NOTE: redirects are disabled per-request (aiohttp does not accept + # ``allow_redirects`` on the session constructor -- passing it there + # raises TypeError at runtime and silently disabled the shortener). self.domain = site plugin_class = self._get_plugin_class(site) self.plugin = plugin_class() diff --git a/Thunder/utils/time_format.py b/Thunder/utils/time_format.py old mode 100755 new mode 100644 diff --git a/Thunder/utils/tokens.py b/Thunder/utils/tokens.py old mode 100755 new mode 100644 diff --git a/Thunder/vars.py b/Thunder/vars.py old mode 100755 new mode 100644 diff --git a/config_sample.env b/config_sample.env old mode 100755 new mode 100644 diff --git a/heroku.yml b/heroku.yml old mode 100755 new mode 100644 diff --git a/pyproject.toml b/pyproject.toml index 663503a..6cfb1c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "mypy>=1.13", "bandit>=1.8", "vulture>=2.14", + "pip-audit>=2.7", ] [tool.ruff] diff --git a/requirements.txt b/requirements.txt old mode 100755 new mode 100644 diff --git a/thunder.sh b/thunder.sh old mode 100755 new mode 100644 diff --git a/update.py b/update.py old mode 100755 new mode 100644 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..98173a0 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1795 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version < '3.15'", +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + +[[package]] +name = "ast-serialize" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/c0/5bb6885a9608d86ee5712c0d88bc405d3a49f3e44231576e130ea2f53d34/ast_serialize-0.9.0.tar.gz", hash = "sha256:79fe8be1c934aa572940d1811d8dbe4d1b6f22291e3f16755c9b062e9ac92fb7", size = 951293, upload-time = "2026-09-02T15:50:45.078Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/76/497f19d9bdb3899a1efd82e2957f455d0c6e0cb9ebbc254735acb1f74235/ast_serialize-0.9.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ae1c46eb97865823f9843c4b80145e011874923e1a4a44b45738a5309d83e9f5", size = 889442, upload-time = "2026-09-02T15:49:21.144Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c5/9fb64b7106c5534739322c74be7b743c4f2e3b5fd05d5b8e677f05c54d5f/ast_serialize-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af082cb7e6c4fa3a428aa616c13d709a944076eba84a73184de15621cc1a915d", size = 1226721, upload-time = "2026-09-02T15:49:22.612Z" }, + { url = "https://files.pythonhosted.org/packages/27/67/b550fc81aa0133808410783c6d9a1b925e31610d226e836e21337850af55/ast_serialize-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7e9f2540741ad10657a209209f7e5cc6b530eb3ed145fd77258ab43542d96ad7", size = 1207369, upload-time = "2026-09-02T15:49:23.916Z" }, + { url = "https://files.pythonhosted.org/packages/5c/1e/ed9e66deb7da63e44d0c0fd3a8feef698882ed56ea521a29494d4616eb46/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a95485d5e8704af2ecc7f723757b88f992ae8122028d687ccf877cad2b4c3da4", size = 1273073, upload-time = "2026-09-02T15:49:25.336Z" }, + { url = "https://files.pythonhosted.org/packages/68/8f/cd337551d7a68c982425bbf91f183943d7ccc62394002c74807a7f0e60db/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b559cffac5a71a698d9194e4295765ff2132a10fd1284860f02b30f12c1f729e", size = 1279045, upload-time = "2026-09-02T15:49:26.75Z" }, + { url = "https://files.pythonhosted.org/packages/40/c6/98dc41eb4122d5da83241e805739838ed59e1e1b9006cbed89ded635a17f/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dad8a3f7106efcf252fc289c092ee0cee5c3512c0088bcf3fffa01458323092f", size = 1539300, upload-time = "2026-09-02T15:49:28.213Z" }, + { url = "https://files.pythonhosted.org/packages/9b/d2/d94cede4b3f2a4e329d8ca92218f0846cfcc9257be91c5bf1168671f4ab5/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:24de2bc930b7e1ca86641136b9875a1e5f80f52b484deddc67893eeaf9077bd9", size = 1291957, upload-time = "2026-09-02T15:49:29.643Z" }, + { url = "https://files.pythonhosted.org/packages/8e/85/8ac18d754225cf13392786b23d4ba84273ceee562699362f22e61942ce64/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ac7b4cdf89ca8318aae157d824017596784982851c8a89a621973f261574696", size = 1291779, upload-time = "2026-09-02T15:49:31.199Z" }, + { url = "https://files.pythonhosted.org/packages/62/ed/cc757fec9e96e29f19a6f818e05147e4f2949356258a3243412756f22a2e/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:95dcb93f30258dcf09d9b6a302dac9323b70e9df8c18ee0bf4ea1fa7cc5f1875", size = 1299730, upload-time = "2026-09-02T15:49:32.774Z" }, + { url = "https://files.pythonhosted.org/packages/a7/8c/a575ae0ae954f21a187b4c1d8cec28d81a009693bd13a1388eec72d9b55a/ast_serialize-0.9.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1ed5824b4b2fa37ad93fa2db23d8243a3d315b3e2d7ca70f99b2727d8788af05", size = 1344671, upload-time = "2026-09-02T15:49:34.217Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/0b2b15c0ae5f9a95f433016d1a59a3227eebbb378654d6354206c8ac8e8d/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3c69f2ce565c786bd3853ce42495e8a63610516f79651c7cb4f2cd0ddfaee52", size = 1448527, upload-time = "2026-09-02T15:49:35.68Z" }, + { url = "https://files.pythonhosted.org/packages/18/be/ee89cb6a5d3427946532f0611b514befdd69564803e9a9f9ce712f9d2654/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8c824d822a2ac54ec4228b88c0d170ab0024cf285eeee57b9d4f994003fb553", size = 1554045, upload-time = "2026-09-02T15:49:37.15Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4d/eaada807f98a2f0d370fec4b46c84f0e551a62911e751a76ecb32bef4dde/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c55010ea0fafc6bc8231809d328bda781bfe41a01c43522be5eb3713fd855cda", size = 1547578, upload-time = "2026-09-02T15:49:38.791Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0c/046c531f4af4e1bc3314077a84b3099f38b45e2d969faa24acedb3066d92/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:322282ac5337e5e776bc416f2b5201e680fa4dacf92ea739bd35e46a28a66c41", size = 1671896, upload-time = "2026-09-02T15:49:40.273Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/8d32aa0cf4e3e566399b2079a4c47f8ad3c62155f6ee1fe63631b6d3fdde/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:ded5ed06ec469407d6cd571ace7a7a25809cc388e4bac1dd35c7747469fc7fdf", size = 1472895, upload-time = "2026-09-02T15:49:41.814Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b9/8204c7e3d8abd4b0c7a56a8d5cce05fcacfd6a5d63ffbd812b8b94040d6d/ast_serialize-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3ee40752ddb4fb5c6a67161d13f3ce3df7987dcb9272260542a86b0be1519ae2", size = 1492731, upload-time = "2026-09-02T15:49:43.355Z" }, + { url = "https://files.pythonhosted.org/packages/f3/72/ff5c44c19409686798feeb1fbe209f2be78b4b64948d5aa2ddeec8901591/ast_serialize-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:d9c635eacfc02b91da6796d3b5ff9086e511b8a29b19f9b3f4f978b8d170f838", size = 1112847, upload-time = "2026-09-02T15:49:45.181Z" }, + { url = "https://files.pythonhosted.org/packages/20/75/fa5be1a94d189adafadf9c5f07fffd66af7a8061c4cff92f75285ef79d10/ast_serialize-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:62a96e327e2a178d6c295b10422e95c992a9286f0ec1b2bc7cd5b4873252a38c", size = 1146846, upload-time = "2026-09-02T15:49:46.63Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ef/b2bfb331b6e379d435543f6d3be0b590e7a2f441d31ccc17d75f2d2d7cb8/ast_serialize-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:41da4332492222d56345d5e436eed4fbec76caadee959f6ffa3cd2fc1bd51895", size = 1119605, upload-time = "2026-09-02T15:49:48.14Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f9/a4af1bf8b35927814c09d90c3965dbfaa75c489ba34372bffafbc2209f40/ast_serialize-0.9.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:383f56e3ae925f154632458f01b4bfcde3dd382f3ec04f5c7f6d72f76524ff48", size = 1226344, upload-time = "2026-09-02T15:49:49.79Z" }, + { url = "https://files.pythonhosted.org/packages/1d/6d/d3a95823a803c21f5c9df595a0bb93aada22e7aa22bf875fe00d89422d7f/ast_serialize-0.9.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9ef3d4173907bd19aa8f1683be9f06e7862e6cdf2ca6bca3633305c0df32063", size = 1207384, upload-time = "2026-09-02T15:49:51.22Z" }, + { url = "https://files.pythonhosted.org/packages/f0/97/6e7f46c8455b738609c29d1b7655307a168c4b40ce4c7a2c678c8ed9cf2e/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9482672ca8ec09f85cd050a053fb88c30c882c8e20ce7a140d8defe19c0ef2eb", size = 1273139, upload-time = "2026-09-02T15:49:52.679Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6e/25b70733f061766865cb04d913dc5332037c595796b871d52ab5b569abb8/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a6a568d1d489f0669a31aed90ca4845aa7f08e1b8cd5d05e1905dbdc3ae9b2b0", size = 1278242, upload-time = "2026-09-02T15:49:54.236Z" }, + { url = "https://files.pythonhosted.org/packages/9a/f0/b7820399d9c5a0b7f07c239b6da93d2e21a1b3785137fa00e16528e414b3/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a4dd005d4095a13eb312dc712c943c7730f262b000a87df963925328a38ffdb", size = 1541009, upload-time = "2026-09-02T15:49:56.149Z" }, + { url = "https://files.pythonhosted.org/packages/08/56/5146f1d2a77516e697f6f42825df79137e43560675cb4605c467775f8b4a/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:207ac73afa1f4654840593853c130eac2591dd942434176eea33f730afb3359b", size = 1290898, upload-time = "2026-09-02T15:49:57.502Z" }, + { url = "https://files.pythonhosted.org/packages/69/c4/87cd16228796d703de795a369b90b0f57f0f017f90f55c4c5876e3513a03/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae01129e2cc5d57a3c434d8a990019de039350310a2e1dd3c9f61311964cf25", size = 1291742, upload-time = "2026-09-02T15:49:58.982Z" }, + { url = "https://files.pythonhosted.org/packages/cc/eb/13465c297268c5170b2bb746d75f37a8fad44a94a89b593071affc1071d0/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:4c522377f383670abfe21c94edc3032cb3bd34d8fcacd280fa9556907d4edd4b", size = 1300180, upload-time = "2026-09-02T15:50:00.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/a7/10b84c4274b2507b0ed9cc1654058ad64bcedb6ac574753d6a461bc6e204/ast_serialize-0.9.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4cd886f6e900f5e13f758cb8ef359652e690e4f3f9c257a7269e095940534167", size = 1345857, upload-time = "2026-09-02T15:50:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/c7/dc/2702182c9773a15de9aabfaf66da7cb87548a56a6bac24f0c9176a4a13c3/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:f3f0f3359cf0f22bf096b07021ffe6bf0ec88ac8a2cf7ce5f4701af973112faa", size = 1448544, upload-time = "2026-09-02T15:50:03.496Z" }, + { url = "https://files.pythonhosted.org/packages/59/b5/eeef2124c9563b9861707ef4db91f153f3bb37b3e0cca9543bb88a4e9e53/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:6c428444656cffbd32c1e76626c6eec5237b58c8fcb0b5d3df75941cd50c4f3c", size = 1551572, upload-time = "2026-09-02T15:50:04.982Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8c/81d18349f1dffdcfeb80671bd737e342c86348e183692d9d0d8f573d1385/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:54f0babed5e2a4eb86a0716ac612aff33f933e7572e5bc067adcdbe672a26321", size = 1548118, upload-time = "2026-09-02T15:50:06.522Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1f/339131b60d1b0df13d9f3470cfac70f858b5649188ea01b2e7b39caeb720/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b779fdaee34d19900a5ba5fd6bd4cefe225650081f9de28de256eacee5113c2", size = 1674707, upload-time = "2026-09-02T15:50:07.919Z" }, + { url = "https://files.pythonhosted.org/packages/18/0a/ca77596fa229d88f96eca180d45dbe8efa11306f8b2b4f4ee301b3fe465f/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:4db7f524eaa857fbe650cac33b9cedb5ccda14393d40f640b75dfb06aa13c98c", size = 1473618, upload-time = "2026-09-02T15:50:09.312Z" }, + { url = "https://files.pythonhosted.org/packages/88/5c/6aebeb54dd226b480014ff4488e150aa23b1de3204e2bf3f87de27e6542a/ast_serialize-0.9.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:d2a37795a90809da6094825e7063118b4cf723b8701b134973b4566ed8b9ea09", size = 1492025, upload-time = "2026-09-02T15:50:10.755Z" }, + { url = "https://files.pythonhosted.org/packages/75/e6/c355d470a230f778311c28b80f6d934a497d094139f07230433eea18651b/ast_serialize-0.9.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:4411d1cba9eeecb301365343a7e96813b4a44fcdb20181557867ff7e751804cf", size = 1113010, upload-time = "2026-09-02T15:50:12.337Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0d/66609ace58564727b68731293cc986c2ea1d5e6ef40e96571e7fb515f0af/ast_serialize-0.9.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:b5c3724faf780e25def89c369eb6340a15dff06ac348160c61781e2373a4cd10", size = 1146404, upload-time = "2026-09-02T15:50:13.761Z" }, + { url = "https://files.pythonhosted.org/packages/b1/fd/da28e1c85f05fb9976f247d2a3aefce68866cb2939abcbdbddd9a5e3b835/ast_serialize-0.9.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:1de0933a4c1d104d77d6e75f053f5e628b54cf8f9fea809b8250cb04cda07bd3", size = 1118328, upload-time = "2026-09-02T15:50:15.214Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/487a158a99e4564244e000ed18475255dfea53fd34a84d8ca73633710500/ast_serialize-0.9.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5fc57f17fb4ce49b4eeccfcd2670e4a55659bd740bb8e8aedbe511ccab8b5f03", size = 889484, upload-time = "2026-09-02T15:50:16.643Z" }, + { url = "https://files.pythonhosted.org/packages/92/e4/175b0a64d6c96bc1b96598c6474ce8d1ef34e0b774bcf7183f4ce696fb10/ast_serialize-0.9.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dac690f99538d9df0d23ce0299e946add2744b007a36b480a292fe361c82553d", size = 1232635, upload-time = "2026-09-02T15:50:18.133Z" }, + { url = "https://files.pythonhosted.org/packages/28/0c/d51d8463aca43aaa833fdf1f25134d6cc1b483764896decca61306ad1f6e/ast_serialize-0.9.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:2223ead73b5a5399d39610cf9c4164ad0b2bf2025226626b87ae15226d93d3f7", size = 1219313, upload-time = "2026-09-02T15:50:19.497Z" }, + { url = "https://files.pythonhosted.org/packages/ef/19/c88bdc64f86095a9d6ab325ae422b2a5e1395cd63cd8aa539003d4d4ae1d/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b7c5f5838408fb000d76abd14e886836412b7ec7eccd028dbb5ed5819780008", size = 1279981, upload-time = "2026-09-02T15:50:20.811Z" }, + { url = "https://files.pythonhosted.org/packages/86/58/a492075826df1753896dc8e8f6ababae4016d8883b670ee3a1c34788b154/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1e05701fde79affa1cc53e391867f9da3eb03fa8501f87354292796b0f8398fd", size = 1286319, upload-time = "2026-09-02T15:50:22.203Z" }, + { url = "https://files.pythonhosted.org/packages/ae/79/3f6754eaa42fd2a6c36aac066890870cd44cbe0e25f75a67b1b99a2f4d82/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d013c36eb2f2ac0cb7d4d0e79918a92ab00fbce8f1542fe47f34a46e06168f82", size = 1551547, upload-time = "2026-09-02T15:50:23.528Z" }, + { url = "https://files.pythonhosted.org/packages/b1/05/8cfb7caadfaf28febaa6b61d31d778262f87f9366eda4dd9bd07ac940b75/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19cde5c2110f7b90ab1210a599178524f6c9f34862b20ba2b9aa7832c67bb35d", size = 1302468, upload-time = "2026-09-02T15:50:24.99Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e2/750a0b136bb02ff8e4a17d65a3a78cd478ee50724704df8215797a226ba3/ast_serialize-0.9.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1514f4a39704e2e815f9fc675fc13f19f694f212086b520110840782cf3c5295", size = 1300563, upload-time = "2026-09-02T15:50:26.354Z" }, + { url = "https://files.pythonhosted.org/packages/ab/17/4c0aa852ff1e4f2d6723e8ce827136c1e1febf2845d7941ccc45426778de/ast_serialize-0.9.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:30651ccdec6d23c49ee4711b1a1096d8dbd3be38eecf2f09fdd98a608ce7ac24", size = 1308999, upload-time = "2026-09-02T15:50:27.901Z" }, + { url = "https://files.pythonhosted.org/packages/4d/1b/6e73d0a29aedb0db30cc68f2557acaac06cd24c9783ccb90f84f89e4ce87/ast_serialize-0.9.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d9a46caf5e3f2cd266e8638b2f4ea8bf54cf376f015f8418397b4633fdb38e9b", size = 1358191, upload-time = "2026-09-02T15:50:29.237Z" }, + { url = "https://files.pythonhosted.org/packages/dc/38/2cf5d552de99e0e9804a16fea73e54d0a7382498adddf57c0f6dc09cbc70/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:aed6e413c6c22a23c33c47a01dd2adce01d7a7ed408748e896903f47d0a1aa47", size = 1458944, upload-time = "2026-09-02T15:50:30.77Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0b/5ef87adf955b6a027f616eb7b55f55a154c35ba600e9dd2d06ad2d30e5c2/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:871fb7c5b049897ee137b67efad7fe4545ad270f7eccd970da877833f8e63aa7", size = 1563421, upload-time = "2026-09-02T15:50:32.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/dd/9ced05a17feeb0f83e84010d80f5a1b7b7aa19e75f0376f4d3780803654c/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:bb378efb5537b43f38660e2e6d6e138a40885cf191d43443bb3ff7ff47e9cd9b", size = 1558536, upload-time = "2026-09-02T15:50:33.861Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8b/8ad486e44fc7081a2471055befc433dddc2e51c3a88dff141b3026f64602/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:b373beff65b01fcffca5aaad3269ae629f3a998b09efdd3635e48039008a5dec", size = 1682749, upload-time = "2026-09-02T15:50:35.257Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4c/7c282aba9cfb0b92d79fac45c04e4557d9a7f08d872e5a43577a50867e30/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:25c8d517c45cf2b1820fc2af6ac593783654818f79d05646d25d624360678a4e", size = 1482441, upload-time = "2026-09-02T15:50:37.319Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3a/e45914e8cad81b660915f3784d255460a6384183b76bfc2089fdd79ec7df/ast_serialize-0.9.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1675dc46578298ae00936164a160997801a6ca2385913150d8d16df634296cf3", size = 1499042, upload-time = "2026-09-02T15:50:38.76Z" }, + { url = "https://files.pythonhosted.org/packages/99/09/6988921dec19c810beef53539fec2e90ae551cd93853b3303a99fe45f772/ast_serialize-0.9.0-cp39-abi3-win32.whl", hash = "sha256:20fce3885eeff05a3d6afefa845c8168016e3ea1f6fc9cdc84c8db28b863a550", size = 1116391, upload-time = "2026-09-02T15:50:40.229Z" }, + { url = "https://files.pythonhosted.org/packages/fd/eb/839598a22a1f9af56d39e188451cad93dbcb0ce6539a45ac18fb8bf123fa/ast_serialize-0.9.0-cp39-abi3-win_amd64.whl", hash = "sha256:161914666a21d48b681982146ac0fa4086ef099d91c637cf595387f5f06aa099", size = 1156055, upload-time = "2026-09-02T15:50:42.05Z" }, + { url = "https://files.pythonhosted.org/packages/0d/45/c7cd8d36d3b506bbd02db5066fae3340284781168f0d08dac25deef5f69d/ast_serialize-0.9.0-cp39-abi3-win_arm64.whl", hash = "sha256:74473258a5c55855d5306c864a5c799fbff03a0f0ea1197346b2b5cc5b4ea48a", size = 1128237, upload-time = "2026-09-02T15:50:43.496Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "bandit" +version = "1.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "stevedore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/aa/c3/0cb80dfe0f3076e5da7e4c5ad8e57bac6ac357ff4a6406205501cade4965/bandit-1.9.4.tar.gz", hash = "sha256:b589e5de2afe70bd4d53fa0c1da6199f4085af666fde00e8a034f152a52cd628", size = 4242677, upload-time = "2026-02-25T06:44:15.503Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/05/a4/a26d5b25671d27e03afb5401a0be5899d94ff8fab6a698b1ac5be3ec29ef/bandit-1.9.4-py3-none-any.whl", hash = "sha256:f89ffa663767f5a0585ea075f01020207e966a9c0f2b9ef56a57c7963a3f6f8e", size = 134741, upload-time = "2026-02-25T06:44:13.694Z" }, +] + +[[package]] +name = "boolean-py" +version = "5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, +] + +[[package]] +name = "cachecontrol" +version = "0.14.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "msgpack" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, +] + +[package.optional-dependencies] +filecache = [ + { name = "filelock" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/3f/143b048436775b0f76ac3eec145c019e8173ccc2885c8f20319b996d5e83/charset_normalizer-3.5.1.tar.gz", hash = "sha256:6117b84ea48435e5356dc737f5121485c30920ba43375fa7b434fd753df0eac3", size = 171764, upload-time = "2026-08-15T08:20:44.807Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/61/2cb6ad133dbbb449fa2d37ccae973232f4827e799af258d15e589a3d1e9e/charset_normalizer-3.5.1-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:4f298bdadb8f0b9e5672877f647d1be9373ef5320c9e2f049795e26cad28b6a9", size = 211584, upload-time = "2026-08-15T08:17:33.597Z" }, + { url = "https://files.pythonhosted.org/packages/18/57/a305c968be1ca13f3dd1b32f445877e97addf55d80b65c7cb35fac82b777/charset_normalizer-3.5.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:88ca277405c2d3b71c4e1c2ee0e7966e807bcba86a69d11e19ba199d18ae4491", size = 223359, upload-time = "2026-08-15T08:17:35.022Z" }, + { url = "https://files.pythonhosted.org/packages/09/0a/d3646670292ce8d8f8cc11ac067d44885e697a5591f57a9221128da5e7b3/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9362dd90aa7dab48c0054a21187791ccf05473f7dba5d92b8033ae62164675e7", size = 194464, upload-time = "2026-08-15T08:17:36.452Z" }, + { url = "https://files.pythonhosted.org/packages/de/93/d51ec556e01042fed6f993ea859311bc7917b466684182fbbceb6ca24762/charset_normalizer-3.5.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:977cdbd483a9cff38179bea4fd754289a6f2195c7abd414aba85410b3e66cc5e", size = 197676, upload-time = "2026-08-15T08:17:37.819Z" }, + { url = "https://files.pythonhosted.org/packages/a4/a0/562247944386f7d4ef94467e84876600cc1e0f1b93239aaa9213d2bc3cbd/charset_normalizer-3.5.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e90251c0c7bdd54a100a0dce3c07b7e637278c93af29dbf78ebb89a58c4bac7d", size = 340473, upload-time = "2026-08-15T08:17:39.303Z" }, + { url = "https://files.pythonhosted.org/packages/31/e7/1d994be1b93d41e9502b8b0460eaa88a1dd8df335df415db87d6c3e91ab2/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94d78ecec2605a8d0398b0f365d5f12a63248438516f5dac536a5eff7337df4a", size = 240156, upload-time = "2026-08-15T08:17:40.66Z" }, + { url = "https://files.pythonhosted.org/packages/09/53/27923ce5cc6cbccb832037b27dca98882d9c53e9b69e866bbbef4aae7fc8/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d59b75732e9b6f27388e10c14b0259cc5f2e48c78627d185e6a177b58ad3cffe", size = 228246, upload-time = "2026-08-15T08:17:42.003Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/5a97e84d63af1d55c07439cb80e56d99a8efb4295700eb4e18c0d1615d2c/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0d929fc574b4d6fd9e7c0f5c2ede8716a41911923aa7fa5fce38e0818aa4a1ac", size = 263660, upload-time = "2026-08-15T08:17:43.627Z" }, + { url = "https://files.pythonhosted.org/packages/7a/c2/071575791dcc88316c0a9a65ce38897a82e4cfe4a325f0f7fe1b1ac47bcf/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:394fea06235c8543390050ed5f529187074b029fb027213f6c46ac11ab5d950e", size = 260354, upload-time = "2026-08-15T08:17:45.094Z" }, + { url = "https://files.pythonhosted.org/packages/fb/af/63240b0c0248c075c2535a1f1bd992821d8251b9f173abc13329661d09e4/charset_normalizer-3.5.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62b55f6722735a6c472f88361cde6640608773d9443cebdbb51abf436a1fcdd3", size = 250638, upload-time = "2026-08-15T08:17:46.496Z" }, + { url = "https://files.pythonhosted.org/packages/4d/66/70dfad64f15be09c15ccfee81330a7e515895dbe296dd23114e9a231268a/charset_normalizer-3.5.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa48b1b63d639f9483e0633e092f5851e2348c352f1f9bb6c8182f87884ef876", size = 244583, upload-time = "2026-08-15T08:17:47.963Z" }, + { url = "https://files.pythonhosted.org/packages/c0/24/ef36367d38b9ddd4bccbf72888c342e8de1f5ae506fa0b2dcf970e2732a1/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c71fb0d56c920c269cd3e2e3fe7c610e3f1fdb21a6ce60efa6430ff63676cea6", size = 242038, upload-time = "2026-08-15T08:17:49.481Z" }, + { url = "https://files.pythonhosted.org/packages/db/ab/55e683ba0fff2e43adafc10daa3001eac90fdaa419a97227d5a7067eedde/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:485a0d363cafefcd2538a73c7c838daa2035f09b2c9f9b5e3133f80c6aeb84c2", size = 233677, upload-time = "2026-08-15T08:17:50.845Z" }, + { url = "https://files.pythonhosted.org/packages/bd/67/0f40eaf8d1b6e7cf15e82382a2965efaca787fc1c2794b7021d37aaf5036/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0ea61a470e070686aa30892fed79e297d2c8d0ab46b8bcdf027d38c51da591", size = 264491, upload-time = "2026-08-15T08:17:52.61Z" }, + { url = "https://files.pythonhosted.org/packages/5c/64/12b4c2a11ee8df4fcc518c78b0d93e3a92bd3d5253d1617ce74ff0e8c7ef/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:90b7481fb62fbe172c558bc6fd1c4c98d82004a54a7551f20e11ac9bf0b8708c", size = 245196, upload-time = "2026-08-15T08:17:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/37/2e/651d910af6d0fba325eee1cda37ec5443462ed25360e666c144166eb6091/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:35fe081843b35aad20ffeccec3eeffbe637b15d14f3fb22cc1b59cd8ec17e93c", size = 261660, upload-time = "2026-08-15T08:17:55.491Z" }, + { url = "https://files.pythonhosted.org/packages/90/c6/b09e05e6db7f64338e0dc067c79577b1138da86c1e38369096851d96be88/charset_normalizer-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd0350afdc3aabd5576f60ea109228bd5538139713c7b094c5cd27c73a98bc6f", size = 252618, upload-time = "2026-08-15T08:17:57.025Z" }, + { url = "https://files.pythonhosted.org/packages/76/4e/362d4f9fdcdf5556fb2aa3ce7d4a58ebce03ed1ff03aa1d9aca8d02f13f3/charset_normalizer-3.5.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:9d9a0dc7cbe9bec24c3f767c9122c41fe5a1bc43f47cd099d00d393e09769de4", size = 140362, upload-time = "2026-08-15T08:17:58.425Z" }, + { url = "https://files.pythonhosted.org/packages/b4/d4/703be739b26acce318bd29eb3b25b7209e1b1f527f9eae3d1f1f01fdde2b/charset_normalizer-3.5.1-cp313-cp313-win32.whl", hash = "sha256:d63600d620ad0064c3a748b950ac5ea38a80190e5498532efefa4b7b3f1da1f3", size = 177755, upload-time = "2026-08-15T08:18:00.037Z" }, + { url = "https://files.pythonhosted.org/packages/8a/33/56d97ade41c8db611e727168c52ae46c9224c362ec28d4b65d7e9869e8da/charset_normalizer-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:aea996a6aba25260827c9ea511d1addfde2da9eb686ac961838509086188b7e6", size = 199295, upload-time = "2026-08-15T08:18:01.506Z" }, + { url = "https://files.pythonhosted.org/packages/5b/75/5b20dd1e6573a01a08158fe104104fa2c8abf941745596954185726cd46c/charset_normalizer-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:fd0a274c0e5f9a21565cd9d3dd749b61f96b7aa1e20a93aa1ba4029518f2e5c0", size = 179856, upload-time = "2026-08-15T08:18:02.929Z" }, + { url = "https://files.pythonhosted.org/packages/29/cd/2b812ce5e888f1ce69a5350281e58aab07ae64a958ecae8912f30865718e/charset_normalizer-3.5.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:774d157f112367ff4abd29019f38f023c24e00e56edc7829c20e358a5a913ad8", size = 212318, upload-time = "2026-08-15T08:18:04.403Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4a/a6ee107430768a5334e6d63f31f148a04a1a491ef161a1ac9415a73f2fa8/charset_normalizer-3.5.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:26422d45fd13551cf564c58932f7d72b4f58b93b0fcf18c35ba6be12b46bb102", size = 224897, upload-time = "2026-08-15T08:18:05.997Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d9/35ae3f64f29d0179c35c3baefe575904df2913dde519129c7f75995a2b1d/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:09a7bba9f739468c8e78c36a75c33768e53cb1959fc638f510454c14683f00d5", size = 194848, upload-time = "2026-08-15T08:18:07.397Z" }, + { url = "https://files.pythonhosted.org/packages/74/76/f2fc7380f056cc273a53af37f50d08ad54b2c59f61078f31432edcf1c2bd/charset_normalizer-3.5.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c9548dc78002099910abaebc0a72ac58b7d30931869e0351c09b507dff4ece3", size = 198163, upload-time = "2026-08-15T08:18:08.989Z" }, + { url = "https://files.pythonhosted.org/packages/e9/40/095ce62fa078483cccc1fa2b36e6bc9580b85422a20ee9f925341c50e44f/charset_normalizer-3.5.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c428c6c31eb5f4277d7f8eccaf767fbd548ddd5ce3c8b4f4cbbfab3d96b5904c", size = 341823, upload-time = "2026-08-15T08:18:10.458Z" }, + { url = "https://files.pythonhosted.org/packages/f1/5a/0e58b1c04a1596e0256f407274a92d5fb2ee21324409d1fab1da48a65b5b/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f06b7eae9dbe77fe1d644ca244dad508de8d302870a43f3c559b521270938a0", size = 242458, upload-time = "2026-08-15T08:18:11.989Z" }, + { url = "https://files.pythonhosted.org/packages/22/95/b4618ce912e6db0b1aae89ba788e38e8a7eba0f3025cc66e8c0699f977b2/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b7430cf5728e68f6c462254009a6ef4086e1bea43cf2f57aa9c55fb4f50ff96", size = 226717, upload-time = "2026-08-15T08:18:13.401Z" }, + { url = "https://files.pythonhosted.org/packages/8a/76/c681192bbda3d55356db5dadd64381d5202b37c6b598fcda5282e88b5d3d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab743e9bc90c1f73552ec33e10e3331315acd2c397b36065b591b0181de533cc", size = 266111, upload-time = "2026-08-15T08:18:14.961Z" }, + { url = "https://files.pythonhosted.org/packages/88/be/55127bfca72c0cff6c022488d140d7c5b04c771e3b72e9bdb4836d54979d/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6f7deae3feb4edfa2efaf7c574fe88cbf055038a6abdb40188e4fff66d5699f", size = 263128, upload-time = "2026-08-15T08:18:16.515Z" }, + { url = "https://files.pythonhosted.org/packages/e0/91/39c3af510b0aa32bbda03374259200f28430febfd1bf5e511fe765282ce5/charset_normalizer-3.5.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15f024313246a4ed976c60f440bb8d257815513a681d212ff74fd46f7d715a90", size = 251240, upload-time = "2026-08-15T08:18:18.127Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/cbe418bbc6ecdfc3e05a0116002897c4b403a5e838d697e64c78e9f0190d/charset_normalizer-3.5.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:823f82903d189af463d7df250ef1f7f696f3cee08cc8d91deb565e8d425f6506", size = 245282, upload-time = "2026-08-15T08:18:19.625Z" }, + { url = "https://files.pythonhosted.org/packages/cc/a4/689bb42e8e7cd492f3cb64907c6bc00ad247ec9a3628cd3f8eed126e8ae1/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:01e93745f7f219b703b60ba7afead36cfc4242782be5af484673fc500df12da5", size = 244597, upload-time = "2026-08-15T08:18:21.121Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ce/9962938e179cf9f699d3f1e7b3114b5d7642dee6a893745229f9dd04f274/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:329fc3ccb63ad22d867d84c2adea759a64079a37ba4a343433b02c7a2816871e", size = 231376, upload-time = "2026-08-15T08:18:22.57Z" }, + { url = "https://files.pythonhosted.org/packages/85/54/46000450ada53bd9eac5429a2c8c54cd2d9b39c0c255f229aea9af0948a5/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:bb57753e36e4855b8ca375069482250a6246372331a3e4f3407eaebb007443f5", size = 266715, upload-time = "2026-08-15T08:18:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/3d/bb/618749d70f792b44252a777bf89bfb86823b9bbc1ea13fe8ce759b07f38a/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fce8cbd4997efeb450bd298b54f755dcdff18d496f7a5ddbb4867c6d7c88fdc3", size = 245848, upload-time = "2026-08-15T08:18:25.726Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3f/ffb64458527c7668031d5eb095d978de561958dc9f5b53f8e488a533e603/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6c9cdde8becb25a7fde49924511aa2644d6f8081cc8df8e9452724303348d8e3", size = 264521, upload-time = "2026-08-15T08:18:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ab/74a55fd803916a35ac461daf002708191aac19b546b80dc8cabfedc63d98/charset_normalizer-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9ac4444d8d4fd4c4bd08bf451ed3167aa9e7ec6cdb41b648794f1d1103652e36", size = 253054, upload-time = "2026-08-15T08:18:28.568Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/6a9034b7d3c60b17499afb482df5878bf9fa20b50cc3887d5ef017a833db/charset_normalizer-3.5.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:f03ac127268b43ef4fe9e6ab6794a6794b49485a0cc0c1db79876d2f33f75bc7", size = 140580, upload-time = "2026-08-15T08:18:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/1d362e1a00d035d66b9869e1281eee115907f7e390a16a07824ab5737360/charset_normalizer-3.5.1-cp314-cp314-win32.whl", hash = "sha256:1f5883d77fd409a261abb5dc8ccbe335720d798b1de4abb3b1d47ccbbc76b53b", size = 180325, upload-time = "2026-08-15T08:18:31.877Z" }, + { url = "https://files.pythonhosted.org/packages/7a/7c/4938c329b6a9d446f6a59aa2092ff7118f274209b5ed0e26893d1d30a63c/charset_normalizer-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:c658c50ac0c98cd755a2dd50b7977d3bca7df401dcc47fbdfa87db53ef7d4e8b", size = 204175, upload-time = "2026-08-15T08:18:33.466Z" }, + { url = "https://files.pythonhosted.org/packages/ac/33/eeb384dbd8dec570661354592f4f2e1b2fcc92585624d146a000caf53841/charset_normalizer-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:4bea7f8ebe90bbd7f0e4a2de42ca6924ba23e3e76418c408ff82f1d46fabd687", size = 184123, upload-time = "2026-08-15T08:18:34.913Z" }, + { url = "https://files.pythonhosted.org/packages/1c/6c/c73fa9d5a85f6ab05395de61c5f6984e0a9ff40bb5ff888d46dff02526c6/charset_normalizer-3.5.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fbc597639158fd7c14d55e808718848319540f51b0e6746e3eefa59723a4a348", size = 381682, upload-time = "2026-08-15T08:18:36.349Z" }, + { url = "https://files.pythonhosted.org/packages/30/c7/63565f860921457feba93bae6c86fb7746deb4cffeed2f375cb845318146/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e71c909f353863b2b89c83de2ebed71ea6d0df8a6ef65a128193c5e650766bef", size = 240826, upload-time = "2026-08-15T08:18:37.887Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/7ae8807410dfa33f8e6f1715740adeaafa8a816cc4cb33508f54b1f7c896/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7ac76cf9afd34929d76eb7fcb63be476a4853d8a96f0dcf2d0db68a0cbdf9885", size = 227861, upload-time = "2026-08-15T08:18:39.315Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/887c1642f0da26000b0e0652d91071113c0e72cea33952e225cf589f49a9/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3a370082ce34d0612f421e15fe011c53bb1feff21a26d06ad4fb244dab5a375", size = 260758, upload-time = "2026-08-15T08:18:40.88Z" }, + { url = "https://files.pythonhosted.org/packages/3e/11/e6f5b9a3d0e55b0ef7505cd3765cdd48f22db89994c947b316f52f801fd8/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:256dd4d85d9e4dc595e2bc983c980e73f62ddeb3165c58b4c3dfe78c5c8548c1", size = 259950, upload-time = "2026-08-15T08:18:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ee/e4e10a94d51cd1ee638aa7e00b65399e6b2a4e8376ab6d2eac9f95586671/charset_normalizer-3.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:58d4aa13a59c969dbfdf9e6a9560e242cbfd9e8a8f50c2747714df1a423adf65", size = 249329, upload-time = "2026-08-15T08:18:43.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/25/d5f4198819e6059735a84e8d0bfb72dc33976da67b97adcd3fb5a5e07ec6/charset_normalizer-3.5.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0c6dfb5ca6723eeed15aa8e564a014d69fcb8812f94eef11fe3631e0508199f5", size = 243137, upload-time = "2026-08-15T08:18:45.368Z" }, + { url = "https://files.pythonhosted.org/packages/a5/e9/e925ca7569cf9fb9701fd82503fee73eea5268fdb856bdd64947092d3daa/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c010f5581d9c612804cc59fcf7b524b707fbcb72828551237ab545bb5c7034af", size = 242820, upload-time = "2026-08-15T08:18:46.842Z" }, + { url = "https://files.pythonhosted.org/packages/34/17/672c251a888ed2aebcdd2fe830ad0104e25ff83c43f5c4f9c15e9fc6853c/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:52ec005752a56ae79547a05c0139ca2501a0c866390b6115008456b9f0e7cde1", size = 230504, upload-time = "2026-08-15T08:18:48.353Z" }, + { url = "https://files.pythonhosted.org/packages/3f/fc/f6a85abebd42ce4da2f1db0aa56cc6a0df1995e318b3875d14401b8381d1/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2bced4061f000f7187254a02ad3433ae17eaf991747ceea2f478422590a5bba9", size = 263087, upload-time = "2026-08-15T08:18:49.859Z" }, + { url = "https://files.pythonhosted.org/packages/98/66/7c42677e739ba66746b297e2046918d793078094dc239e1e72768cffccc6/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9eea3ab2597a5e65fe65296e2d6a84570845a6b55532d90333d740d48bbc850a", size = 243269, upload-time = "2026-08-15T08:18:51.601Z" }, + { url = "https://files.pythonhosted.org/packages/de/d8/a50b79237f417af10f8c2a501ce8d1ca87829a22e69117891ca4ba20a69e/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:496846868fea80e479324862fa877f02411f2fd0f83b79ccee2607aa68b2a032", size = 258766, upload-time = "2026-08-15T08:18:53.23Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1d/0fc91aeaeb3c83b748f532399ce67cf84604b48297405d740000f7a9e786/charset_normalizer-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:85d5855daafc240cc045c026d7a15fd198a09b0fc8ff6f5ecbb5297b509cb11e", size = 250814, upload-time = "2026-08-15T08:18:54.768Z" }, + { url = "https://files.pythonhosted.org/packages/ae/10/3d8c777cf9024615295aa1b808324ad5b4a77855869c00824bad74ffaf8a/charset_normalizer-3.5.1-cp314-cp314t-win32.whl", hash = "sha256:58d3e12c88e0950bca850ae1f7c256055c097639c2edb9eb123af9807d8b15e4", size = 191074, upload-time = "2026-08-15T08:18:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/4d/81/ae557d3c44d1a1d688696d60563413a0866a91b7ebc50f20df838be3d8c8/charset_normalizer-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:acaf604462bf330b0d07e7a07c1d6e4adac79e5fb13e9c5140590542cafacc00", size = 216476, upload-time = "2026-08-15T08:18:57.889Z" }, + { url = "https://files.pythonhosted.org/packages/27/e9/61c01fb8b804692569c036b3fc50495814502dcf13a60649c6055390b02c/charset_normalizer-3.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:fdb8a068947befafba9952162645dc2fecaeb400e64584829ed5e9b2fbe21a7f", size = 194115, upload-time = "2026-08-15T08:18:59.418Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4e/8544831ef59d8f27ce92c80871380fdacc8076a8a56ed62f82e54f991333/charset_normalizer-3.5.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:9085f87b0e38a2b92b8923059b4e8789fe40d9279712d15dcc670048d77079af", size = 342048, upload-time = "2026-08-15T08:19:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/7f/a6/e3b46852424246065355644f4fb6dbccc0239a42a2eee27ecfc8957f0bcd/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2679de311c7946dde5d3b6f44941844133ff5c7cb86099c0061ab1e8901c20a8", size = 242997, upload-time = "2026-08-15T08:19:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/03/3b/0cc9a26777334ab2f2e3089b948bbf4e4fe72ea70b897715ef6415043ec8/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:baf3775a2635e5a11fbd5e4e64ee69c7e86875d224a5c72aca4c141064589a90", size = 237014, upload-time = "2026-08-15T08:19:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/027335f0aa337a2a2e121bac1ad88c4f02ba6053ea0926802784f3db11af/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac8c94b6539074e0f40899301273ac8402b9b3e01c7b7ba269ff30340aaaf20", size = 266174, upload-time = "2026-08-15T08:19:05.598Z" }, + { url = "https://files.pythonhosted.org/packages/86/d3/e367787febe4e74769dec0f406f2c3c8d1b955fce5aee1fd0f94e8367a45/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8fe532b3c966d1fb794e0698e4589d0444017ae77fc0b31edea13c0e35bcc449", size = 263361, upload-time = "2026-08-15T08:19:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/af/3d/391b193eb9f3e84b02f9314088c386debdc0debee843535aaea2e2c6715d/charset_normalizer-3.5.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c84bec0ab5ae0c64bfe73a7d2adcb5ce73b467523fc27fd6a28ab2aa6cbe35a", size = 252143, upload-time = "2026-08-15T08:19:08.816Z" }, + { url = "https://files.pythonhosted.org/packages/2e/57/de221f1745a90d418199761967e2776bfe2c275a1194220985e8c1d37833/charset_normalizer-3.5.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:854066be00447fa8de2ccbbe893e2ffc4b123ef16d897af794c1e18bd4a714b0", size = 252086, upload-time = "2026-08-15T08:19:10.255Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e3/d119f86a01f9331e8186175f24873b1d74a7ee9e2e4b4d68f9947dae5afd/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:21b82d8082f6f5e7f456ef0bd16323d08de1266efbfeb476e64b2a91d1471a4e", size = 245231, upload-time = "2026-08-15T08:19:11.807Z" }, + { url = "https://files.pythonhosted.org/packages/26/de/d8e48c135ae480879539cdb179c8d3b50c7879497d75dd899b5763b69cee/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:838648accb3a7fd9803fd45c87bce8509648eb0c11bc34e216141300977244f2", size = 241546, upload-time = "2026-08-15T08:19:13.416Z" }, + { url = "https://files.pythonhosted.org/packages/67/c4/217755fd1abc50d326c252922cd642002758095a81ff45010337b8b3ef65/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:195ce897c6153c0700078142cf8efe3e6454ca4cf4357499e4078dfd83396626", size = 267033, upload-time = "2026-08-15T08:19:14.981Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d7/34d8e404e358d2adcc5a228c2134643af00104c8fb0bf525f3688d756f05/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:978eab16f55b4ab2c2a745be9a0a840bf8f09a7f227d9c76eb30214d078865a5", size = 252045, upload-time = "2026-08-15T08:19:16.618Z" }, + { url = "https://files.pythonhosted.org/packages/5e/fa/40414471acf0aa0692ca77305aa00e434fcd8288f0941c93c30e9a5f8f2f/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:cc0329df4caaceb950d2f580b5ac716a377f7059624a0bafaeaf8a218c6ed774", size = 264866, upload-time = "2026-08-15T08:19:18.101Z" }, + { url = "https://files.pythonhosted.org/packages/32/90/fcc850bae791abd2e0c041847f13e270aa08692a79f3e00de6d2dce1cb50/charset_normalizer-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:687c9ca3035544b113bea2055e180af96fb63c0c476e22a9180f51925186e7b7", size = 253932, upload-time = "2026-08-15T08:19:19.734Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/53afe99068b3c10b4cbae592a52ef72a7c92c0188440e83ee3a078fd8f75/charset_normalizer-3.5.1-cp315-cp315-win32.whl", hash = "sha256:706bfd38730a5ac7a365793269a00f4e988178cec121391f4248d84ad8c972e9", size = 180320, upload-time = "2026-08-15T08:19:21.37Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bc/f46a132041b29e4a8779ed712d3df1bf112e94ca8de58b66d7ec2c0cf8b9/charset_normalizer-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:92caef967d287a407085d61176fce4012b1dd62daed4eb6d5ceb26d3d2538712", size = 204174, upload-time = "2026-08-15T08:19:23.088Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5d/9ed554480eda8e447b673648628fdc29574d23dbad01fe11837adedd1cae/charset_normalizer-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:5fc45d653ea8c9a20479167e11d4a0f8cb2fa3470737ab6f9c827532313187b7", size = 184126, upload-time = "2026-08-15T08:19:24.471Z" }, + { url = "https://files.pythonhosted.org/packages/3b/32/9b8929bf384061ee1fe5d9c27c6f9776d3d824039ad4e14c88ec00c7808e/charset_normalizer-3.5.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:59171c6e45bf07d0d5cab3b0bf81d945035530f6873398b3b531c31184d46663", size = 381441, upload-time = "2026-08-15T08:19:26.038Z" }, + { url = "https://files.pythonhosted.org/packages/96/10/e9aa7923d3ddac652c99a1c5f7be494e737e151566a44abe018daf757f2c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9dbdd9205662134957cf0c324f639bdc5031c0ca056e2369e238db75187c0f11", size = 241742, upload-time = "2026-08-15T08:19:27.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/53/a2d249ebddf47b889a100c0bdcb61a2f9dbb8bc24ef325cc062e4f476877/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4b018dc5a0eee4676e38fe84a47a427816c590b93b55d9025274ec4d6ffc2dc", size = 235298, upload-time = "2026-08-15T08:19:29.274Z" }, + { url = "https://files.pythonhosted.org/packages/7d/07/469f78af590f7d5cd48e20d8dbfa3d66deeff9ba37768c04d886b5afd45c/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ced3fdd71aaa83ce593746c2edb42b7a59cb4c19c8b5c407781c72e493aae55a", size = 262500, upload-time = "2026-08-15T08:19:30.955Z" }, + { url = "https://files.pythonhosted.org/packages/55/66/3bb56a47f7dcba014055b1a1d33c6f08bbe9c1e74dba154cfa25f90ae885/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:19a3dd5aa73cef1c99687c4fc57db016a9c17104ae1185da88ba566a5d3bebe4", size = 258888, upload-time = "2026-08-15T08:19:32.458Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c1/2adc2800903fb013210349313b710a5376856578d9e33e6b9a1d8b36714a/charset_normalizer-3.5.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cc5d36d96478aa9c60654bd932525bf32964c62a7281eafdf16d85003a8d6004", size = 250243, upload-time = "2026-08-15T08:19:33.94Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/a18d0dd1157ab655cc2cb14a545f4a4784bbad70ab3502412e36097502d9/charset_normalizer-3.5.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04368edf83514385ffc3e1cfd4546e595f4f1272dd23ba437a93a9cc3741d47b", size = 249871, upload-time = "2026-08-15T08:19:35.413Z" }, + { url = "https://files.pythonhosted.org/packages/ad/c3/525f508cd1e58d0450ac55ed40ac75bc3a97482c59def5278456a5fbf03c/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5db6052055d34d41230fb78d7c439c23dc536a9896f6cb039e8dd92cfc1263", size = 243580, upload-time = "2026-08-15T08:19:36.886Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/49a91fe7e97c8140094ca5c64161ab623a70d9f636bf834eace14048acb5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:252d099029bcbea642f2a06c4ed5046bdf8b5a8150b64afa5e027e88b106e5ee", size = 239807, upload-time = "2026-08-15T08:19:38.392Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/56a48c296601274c4689b864a8e2dfb209b81dfcb39472753ce95eea662b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:6199d5606e2bbf2b096cf64d03f8b6790c91081d5ac866b8e7bb6422738cc60c", size = 264083, upload-time = "2026-08-15T08:19:39.856Z" }, + { url = "https://files.pythonhosted.org/packages/10/4c/dc48409274a1817ff349711d26c62aa0c597df865d4d69ef79160c859193/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:77efcff2b23071c349402ac1066667a3d011f62398d81408c9b88ad991747c9e", size = 250317, upload-time = "2026-08-15T08:19:41.53Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/d325912115caec62d6bdd77bbab5e0b7da5d234a9f20affdffcbcb530d0b/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:a5cbd90ecf0fc62e64726917ad083b73001f0563657a87ec3c0b504e277dc90d", size = 258173, upload-time = "2026-08-15T08:19:43.07Z" }, + { url = "https://files.pythonhosted.org/packages/34/f7/b13b1ccae2c8ec63980d13be1890eb73f8aeabbfce02a24aabc0908788f5/charset_normalizer-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:4d26f14f041e83dd8edfd61f4cd4fa7285d31798b5bf1f28e70c367ba6c41d61", size = 251960, upload-time = "2026-08-15T08:19:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/1e/25/ed3f9919c5aef8cc818be1f972f565f7610d7b2076b8ebb98839516ffc3c/charset_normalizer-3.5.1-cp315-cp315t-win32.whl", hash = "sha256:ac13b004224fb341e1e25a1ed5e19d32f57cdb2a403e01f003b46f051a550f6f", size = 191186, upload-time = "2026-08-15T08:19:46.293Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/43c2b3e9d8267092b913eb8b0603f0f71993c395632886bd37a7223f96cf/charset_normalizer-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:35aea775dc2bd5f54cd84a1cd2696cc3207c479cb9cf0bd346f0d343e4300ddb", size = 215947, upload-time = "2026-08-15T08:19:47.853Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/9aad3e9c8865e5e0efa9a7f6f81c37a67635a985145ecd44528a81e088ee/charset_normalizer-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:fb78f6e7fcd8ad785d28cd577168bc1aaee827b25bb8755638f694794ea98f0a", size = 193909, upload-time = "2026-08-15T08:19:49.383Z" }, + { url = "https://files.pythonhosted.org/packages/5b/97/fb4e82231aba271ffd775a1b4993b0defc4e3059f286ae41d9433409fe85/charset_normalizer-3.5.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:41876ee62a3dddf48ff1121ad8f0798032aa03f2fd35f21f34a4cab14f18d8d2", size = 331467, upload-time = "2026-08-15T08:19:50.959Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2f/fe3f187327aac18e2d54e9d2b08e15d27bf9b642d9e51c219f130fc34d1a/charset_normalizer-3.5.1-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6dac12ff6b846103483683f60c5f8fee205121adc58ffd87e90a90a3af69e99", size = 253057, upload-time = "2026-08-15T08:19:52.654Z" }, + { url = "https://files.pythonhosted.org/packages/d7/c7/9e48cee5c161fe24da823b61bf381921d77cb994a0a4de148e95018c1984/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cee5dd7c6fb5dd52a0fe2a740f9bc6e3593f5f8b1788bde49de02086f30182b2", size = 240930, upload-time = "2026-08-15T08:19:54.163Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/716601f3cc69be7b198951150c75ead1ece33c3c8036ff6ffa46029659a0/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:343fb4f2821043bd87095f7b08a1a181febc8e36ac64212143bbfd0a0e1bc235", size = 230822, upload-time = "2026-08-15T08:19:55.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/05/71bfc5caa0abcc45aea1f6a4d50ac68e59605ddc7666fe8494f4cd229665/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ae4a097991662cd4fff0ddc74e0fe7874f82e00042fa0ea00855645ed0c79598", size = 260037, upload-time = "2026-08-15T08:19:57.312Z" }, + { url = "https://files.pythonhosted.org/packages/c3/92/de7e32ed05341e7a9c4c877c318418197b7f2d66a3b68d561bf2ac57ca3e/charset_normalizer-3.5.1-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b599739b93b2cbeded49645ae3c8d1405c29ddfbceac1545c87a3f9580a9e96", size = 255097, upload-time = "2026-08-15T08:19:59.056Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7b/ade0a122600319dfa0b1000ab0f9731c94a817904cf3c5de408c73a4ede7/charset_normalizer-3.5.1-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b39b69b347e5e47a3b5b8cfc005c68c1ba347474e3960236c4944a8ecd174962", size = 250166, upload-time = "2026-08-15T08:20:00.612Z" }, + { url = "https://files.pythonhosted.org/packages/75/9c/019fbb9f4834491a160951349b1a3714439376f66e5f7cf18b4f18f0c7aa/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:a2028475ba855475b8b4d3cfeb4994269c967aea8b9892dfba907f4263a863a3", size = 241821, upload-time = "2026-08-15T08:20:02.321Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b8/11d4840bfc99330cc7fbcc2681ee5a044553a6e77655508d8f9b2bff7b34/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:36047af20e17097c3bb9476c2b7655f2f7aa51322c0ba58c07695bedf755a950", size = 232529, upload-time = "2026-08-15T08:20:04.008Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/2b3a21492d9f65171ac75d872f5018260013d00bfa0ff70ec9f179148cbd/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4c4fb141a727957c93edfe5c32a26ceb6b5f6461d67146e2d39f51e16170bea8", size = 260348, upload-time = "2026-08-15T08:20:05.877Z" }, + { url = "https://files.pythonhosted.org/packages/d6/aa/a69a2028e8bd052476c245460ab19d7de595de084dd968f2d75cd50c3e25/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:2f293479cce755c75f1697e87c409b7ae4c555c7dfecb6e988ad13abba943031", size = 247234, upload-time = "2026-08-15T08:20:07.487Z" }, + { url = "https://files.pythonhosted.org/packages/35/8a/3d130aeabcaf3d2466af76b7b141c08d9e89c9016ab4b7cdd0f7dc2d1c62/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:3588e376b3ea2eea84976f67273d679f229e24c66dce7b82ae45aef04ff6e072", size = 256917, upload-time = "2026-08-15T08:20:09.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/c2/a7379b840292d0c1ab9fbd17d1f3967aa81794dc95bc74be8999d7fedcf7/charset_normalizer-3.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e199fb99720074809a7720f1c0b4d919eea8b87e88713e0f8f602f7bef543d9d", size = 254846, upload-time = "2026-08-15T08:20:10.727Z" }, + { url = "https://files.pythonhosted.org/packages/01/65/d43b714731bb2f40d4053dfa00ecfc1c5a301f8e3316c5db3a09af59fe94/charset_normalizer-3.5.1-cp37-abi3-win32.whl", hash = "sha256:dd732602a7009217f658d5863d12d79d373a4de0eebc111094bcdd3bb8e0a6cc", size = 174216, upload-time = "2026-08-15T08:20:12.334Z" }, + { url = "https://files.pythonhosted.org/packages/35/4f/b911ed898b26a09789eba9c9200c999aff6c61b4bafaf4838e56d1a1e1a3/charset_normalizer-3.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:70055ff39b97c99e7ae40ea3e393fb62aa2e44dbd9b29f8d14f42fb0025c3959", size = 199764, upload-time = "2026-08-15T08:20:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/f0/a7/920baf467bfd9bf689f3b318340f37aee4572a71f162bd8db51da55ba4fa/charset_normalizer-3.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:87e4f41d375c0b9be2fb5251aee4b8a689169e134535aed81bf085c3b647451e", size = 287318, upload-time = "2026-08-15T08:20:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d1/f5/deb1a27aa20746c0278ac998c4179e272004699b2d33959ce020c5ac1615/coverage-7.16.0.tar.gz", hash = "sha256:077f0964087883176ff6ab9b074694cae29f8c708273b13ca62c183c6ed716cd", size = 945620, upload-time = "2026-08-28T21:54:37.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/c5/e62c87f4799d1e3647d5b2ae16ea1d12205d72fde1ea8529e13fe050f678/coverage-7.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1545c52ce756b8a97007f439a220297f1cd72a2cbbcdffccdf1c1f70e74f9a42", size = 223215, upload-time = "2026-08-28T21:51:55.628Z" }, + { url = "https://files.pythonhosted.org/packages/89/e9/5e62fda9397175fb206f75368b6e85da06d831c181b6d0f67ca073cd2f89/coverage-7.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0598aadae641f30a0796b75b45c0b9c5de8619bd5cfb251bb0cc254e86e6dd13", size = 223585, upload-time = "2026-08-28T21:51:57.355Z" }, + { url = "https://files.pythonhosted.org/packages/b9/40/bede08621b1ba67e88c4d3336c22b52cb7911ff1fa4ef055344b6670e58a/coverage-7.16.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4080ad6bad9f14690e6b2104f5e8d137ccc65a4b5427a36662090637d4bd16d5", size = 254575, upload-time = "2026-08-28T21:51:59.233Z" }, + { url = "https://files.pythonhosted.org/packages/12/d8/ab0bdaa45dfd6b8cbf1a3ec548fdf827684b1997f9724375c5b3e89144fb/coverage-7.16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9883a2f8206ce3af59117dc278e5d043fea06912bca3f199816129e5e2de354", size = 257172, upload-time = "2026-08-28T21:52:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/1d/bb/135de81784bbd7dfedcab2b92b03d71d75b09b0815b42d6dabb052def5a6/coverage-7.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:984e5430fc6f858385009e92549955157d79335b1f3e13e1031e0f89d1284261", size = 258410, upload-time = "2026-08-28T21:52:02.76Z" }, + { url = "https://files.pythonhosted.org/packages/ad/72/ce44ecc062fb2e43d9447bb76154d091c2139232f20c125297c4b58f4c6a/coverage-7.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b1374099dd1ad0d31fbb6c95d00a56a3c5e85fb3343dca14fc12f78323a2b42a", size = 260539, upload-time = "2026-08-28T21:52:04.821Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/9389c36a41e59406ca2bba493807c2294d2e5186a7e9ebcc2e63a0f2a711/coverage-7.16.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34d8686bce035c8465b318a8c2890e69ba14a00801a27f4eb6bdc97c23944d87", size = 254756, upload-time = "2026-08-28T21:52:06.68Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0f/7762447b15e01fb84263608540123c4d9941f06303265ee74d801ccbec0e/coverage-7.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:857fceba6ff4b507ee0ad98798a33d544a8473df0c542bf04251ee4ed5ee6292", size = 256540, upload-time = "2026-08-28T21:52:08.529Z" }, + { url = "https://files.pythonhosted.org/packages/e6/fa/c60dc75a8346c1dbebebc7279b19971c88f70dd575f0bc10bc0cb16f92d5/coverage-7.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bbf08d951abaa1ce89e28c998361d56b952413846b459cd017f116ad4c9adbfa", size = 254508, upload-time = "2026-08-28T21:52:10.323Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/4e0834f3a1fccaa8bf625a2a1d73bde0fa32577dc3249853c0dd0e7f2b20/coverage-7.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1a03e78f53e4d2ab13adac19958a89322d1829913e5623d642627bf60b35da21", size = 258659, upload-time = "2026-08-28T21:52:12.124Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ec/fe712d3a11fd6e874565a5fa5497c48b8ece561d9611da040b44cdcf8386/coverage-7.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:dcd3dafcdd78305d27c59a1006b53a4990acb89e68d8fbe0992f4f83503c827f", size = 254326, upload-time = "2026-08-28T21:52:14.181Z" }, + { url = "https://files.pythonhosted.org/packages/e7/78/093e12072e01034c65ff380f76c74b79dd83e44fa92b689a2154389be734/coverage-7.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c1bcfe470a796fbea6234accd81d258a31574dc0b7bf569e16be757572c4de17", size = 256102, upload-time = "2026-08-28T21:52:16.003Z" }, + { url = "https://files.pythonhosted.org/packages/9b/c0/265176117ca5d06e3f65575842884cdda96cf213350a31e9d41c80d65854/coverage-7.16.0-cp313-cp313-win32.whl", hash = "sha256:1420370276f1694b663207b8245c3628aafb9624fe3cebf313a13d860e55ee67", size = 225250, upload-time = "2026-08-28T21:52:17.82Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/8a87f2c04fde322430b45d16d8f543693e9894c5b2d2ca238a287c00beca/coverage-7.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:496277c8d7beed695e02c7be53516a0152e4caef8738a0feab6a638546cce449", size = 225790, upload-time = "2026-08-28T21:52:19.641Z" }, + { url = "https://files.pythonhosted.org/packages/23/40/c21feacd9edfe7063195bf9cc84d650e9938fc6a23063e4f027199b160e1/coverage-7.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:181c2906b9b3759955c1c33c51fbb91c754fbd0b82ea49e2c81061f5a052082c", size = 225180, upload-time = "2026-08-28T21:52:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/ea/73/850675f262391b322c4c988b6cdc32cdc6629288f0fb158687b587a393a8/coverage-7.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:54b7fba6a74d010de34319a0419d5b65af8c00f539ad0b6f39fc6f342ab99697", size = 223258, upload-time = "2026-08-28T21:52:23.558Z" }, + { url = "https://files.pythonhosted.org/packages/61/c1/4f54c6d47c80d1cc58ef8fe6b74e6eb50f9e2c0f6e2de6cf38dbca2937b8/coverage-7.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4ff0b3dd52208d2b30903022d5087f82000507b504753dfeee83e4f32d6883", size = 223587, upload-time = "2026-08-28T21:52:25.627Z" }, + { url = "https://files.pythonhosted.org/packages/3c/be/298f2456230fb44e272a4e53a41b3f3c39f0821c242d7b7daa9787b4d6f7/coverage-7.16.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:35a9676bf86097f790113ebd9fb67681804ef54d40941d2f10ba68c02239e575", size = 254632, upload-time = "2026-08-28T21:52:27.689Z" }, + { url = "https://files.pythonhosted.org/packages/a3/9c/a1bda6439c19c4783d50df896142b67b9e7d432db36675d339a32778669d/coverage-7.16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f98d438add63546745e5e847192e3e9ab897ed6f2ca96f8281e2f5a15958ae62", size = 257139, upload-time = "2026-08-28T21:52:29.741Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cd/cd735c9be757f97237c305f36897a5e5b348bdbc12ebed3b2b80060dd8a9/coverage-7.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:151855767480be14db595cbc2040f6a4db965cdfeebd354d79b0256742b029e0", size = 258484, upload-time = "2026-08-28T21:52:31.68Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/84b2e1e8aae9db3f549782f28ce25bba5fd6a9c7bfba3782ffe8b4cd2559/coverage-7.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:183613f664718b340589d7f005c7e92b4b601cffd20a8a4117cfda3e983b080f", size = 260798, upload-time = "2026-08-28T21:52:33.642Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4f/e04cf52483619a4dc5dd6367b30c9a8ac52243567fdfacec9b11a441565c/coverage-7.16.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:785b114356c99c0dd5b3f57b9696cfd57b7704f4c53847df8dc88c6cc0d9bcb6", size = 254612, upload-time = "2026-08-28T21:52:35.543Z" }, + { url = "https://files.pythonhosted.org/packages/da/33/627c4113f66bfffd43807f54dbf080c4632ecf12e4ef7a3bdd4ec38e46a2/coverage-7.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:30f5aee6d1d517abcdfd4f9cad027969ff79a1440a22da263f9514e31b5b66e9", size = 256495, upload-time = "2026-08-28T21:52:37.485Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/aaca432f4e008a88f2bc4d1459aa7016d8d1bbbe801f7e4fa3cf2746557b/coverage-7.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:190ffa0f5af966254c249fb3aeaca2cef389785e3e287fd577d39e134d20f8a3", size = 254454, upload-time = "2026-08-28T21:52:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/cc/db/8430aa87ef0a508f4c17c1b8fa7e0cf80231988d9081aa36c194036592d6/coverage-7.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ccc37c00e1a5d30840902c54557e104d04aead872cedf6d2281c8725a467e06", size = 258728, upload-time = "2026-08-28T21:52:41.32Z" }, + { url = "https://files.pythonhosted.org/packages/76/88/cd8aa8c82493ffbd291d3ef5554452fffc634c6c6098a04ac848c79c98f3/coverage-7.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6c60cde430c0e7e3be612973af39b4cff90ec2e2defe7b2b701daea3a0ffff04", size = 254271, upload-time = "2026-08-28T21:52:43.278Z" }, + { url = "https://files.pythonhosted.org/packages/a8/49/fe16c811ea9314a84b48f34e4bf5a3d9013091093b285a74b2272fc863d7/coverage-7.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c5297028c8df849a61b29129cadfe682f90b5b396f528eb319a57d7678eefdad", size = 255927, upload-time = "2026-08-28T21:52:45.461Z" }, + { url = "https://files.pythonhosted.org/packages/d1/45/d0bd410e78cfbf768acc8099b335e1d5c0d5c26103c796d2bebdee001715/coverage-7.16.0-cp314-cp314-win32.whl", hash = "sha256:136988df5bc5a48795d9c42c75c4bbda5d9a78e750a080c1233010edff93a1af", size = 225424, upload-time = "2026-08-28T21:52:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/17/78/1ce6ce4646822e9308dcdb1942eaf31bfd7da43247b8886338b0d6fe3767/coverage-7.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:ce2ba5e9f1842fe09165825abfb3bc6b527c71a27bc2eb3a10f2284ced64506d", size = 225918, upload-time = "2026-08-28T21:52:49.692Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cd/e1323fe3a7dfcdd709451a43fe708ca1dfd36a7fc07b34eb7bd1dfdfb52d/coverage-7.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a89d07e48d9baead9a15599923a02f62c6df6c3d85aa84ef34be3c9fd6aeb91f", size = 225344, upload-time = "2026-08-28T21:52:51.665Z" }, + { url = "https://files.pythonhosted.org/packages/39/fb/1c15460d4cf915f09ae3ad3862fef4f901838991c5641b0cec545050d810/coverage-7.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:6e2854b62601c89a63814ad5def3b90d99c6724cc4cb977f75b725e5fca4b1e3", size = 223986, upload-time = "2026-08-28T21:52:53.572Z" }, + { url = "https://files.pythonhosted.org/packages/9f/73/347d2d0009ac211f79ee2a2364fd2aa19d6b9628dc22ed13a9b9386097ab/coverage-7.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f093faf23df888518d273be6da65f0ec5a25b5d8b670231e4d87de07361042e7", size = 224254, upload-time = "2026-08-28T21:52:55.59Z" }, + { url = "https://files.pythonhosted.org/packages/5a/2f/51442e6ad9d705369596f08496021647e276d5b57311818fd4312d93509b/coverage-7.16.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b7dbbbf6551eb94618e7bc76ab61cc2740a5b3d13294171bd6adb36e12346c3c", size = 265619, upload-time = "2026-08-28T21:52:57.645Z" }, + { url = "https://files.pythonhosted.org/packages/ea/8e/0f752276f6d13efbd019ab6d90792e20d6272c44cda039dc5c6d27b91e7f/coverage-7.16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51e7d0e311d2fba3915f971236cbdd4ad821fc7a23988221c0b33c964b0eba22", size = 267734, upload-time = "2026-08-28T21:52:59.611Z" }, + { url = "https://files.pythonhosted.org/packages/fa/02/4df3baef8029881c9d1a380859f2be73f90080d430def567d182e8566a35/coverage-7.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0bb04ee77e557d7476471969d35fbbfb5fc8a4152e9409aa5811780c36d9b23e", size = 270156, upload-time = "2026-08-28T21:53:01.658Z" }, + { url = "https://files.pythonhosted.org/packages/9f/30/ce10fdb74055ebbfb5c8a025d8845dc19c76e4b2c42bb5c755b56678990c/coverage-7.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c72c9b201dc0e8c2c8821d49858fd865010d08181bf877d2320971b6464ebfd5", size = 271279, upload-time = "2026-08-28T21:53:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/71/19/c7e1fc9504d90da848493bad4018dd235c713a80633e48c5f0a41b63d45e/coverage-7.16.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0fca700cae4635656668ba6e2b66a85aac9f2622d7b2bcf82e844c409eaa1313", size = 264677, upload-time = "2026-08-28T21:53:05.741Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f3/4021519dd41583ab396c81955387f927779641f6bac26818b6918a45aafc/coverage-7.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:584896fb8b650e999e24ef57e9513e482c12f8e15a73ee9d4584e23c99465867", size = 267610, upload-time = "2026-08-28T21:53:07.763Z" }, + { url = "https://files.pythonhosted.org/packages/55/fc/df65aac93938d8f506434c8e96440c1d696f6be0a6a01d3c6bfe5d49403e/coverage-7.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:949eae7e0f562b1518355aaef4b03523e49a6d3fea12aa3542d9e36c863f8267", size = 265217, upload-time = "2026-08-28T21:53:09.786Z" }, + { url = "https://files.pythonhosted.org/packages/32/2d/dc9a5e62715165fcb4c715f965f411e324917c9daeddde16536e9d36ce3f/coverage-7.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:64f0611ee05364fc85cc3e5bc371804117a76fd337720e6017332fc7c534257a", size = 268948, upload-time = "2026-08-28T21:53:11.866Z" }, + { url = "https://files.pythonhosted.org/packages/8b/4e/fe73a5560f25fca52acda76fc1554f30de081793ae4de97e920f8ab161d7/coverage-7.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:050a291b3cfe5e0df5999ef2fa5a7aff6e2db329f069d47eb63f02bde2e7e96b", size = 264061, upload-time = "2026-08-28T21:53:13.996Z" }, + { url = "https://files.pythonhosted.org/packages/b3/f7/bb78cc4b97085ebbd77fa18cbc25abfab462814efa3e2363b4e50885c775/coverage-7.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a336b1e2990a64f5c356a9b8380fb9c029d56c832b801255250c44d603271bfd", size = 266371, upload-time = "2026-08-28T21:53:16.233Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ec/84b4af5cd4ad498477b3bfb2217e47b048da919451053790efda66f7383c/coverage-7.16.0-cp314-cp314t-win32.whl", hash = "sha256:058631257350b31784ed43ceb808298b6f074edf4ebca4c7ce5082e6bf873a61", size = 225736, upload-time = "2026-08-28T21:53:18.632Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/50fc0e6c675c3ef14895a74bab2d6120cb5d6f4b562a3d3f5046797758dc/coverage-7.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:ed35097438dfa980c1ec75bc83edf8acbe7a374d7007e571957a257fbd0e2fb3", size = 226570, upload-time = "2026-08-28T21:53:20.754Z" }, + { url = "https://files.pythonhosted.org/packages/fc/24/9effce7bcd3c6eeb4da3561905837509e582dcdde7a7f07d6ef2c8512f76/coverage-7.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0466f4a5c0370461b7d8c7eb259d7d1db0b5756f13d66230b04d22a1d380ee11", size = 225879, upload-time = "2026-08-28T21:53:22.747Z" }, + { url = "https://files.pythonhosted.org/packages/4a/2c/318e4379106bc8047ba235e3732ddc87d1b393ac3db9776f5405ff14f322/coverage-7.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:80d7d5d744a041f08637df743ac086204ec5acbcd8432a42b00b49e607358024", size = 223257, upload-time = "2026-08-28T21:53:25.376Z" }, + { url = "https://files.pythonhosted.org/packages/81/4d/a5c54d9144e9db6505749758ba50a28be624148873751728a59cbb72d27a/coverage-7.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c5feffce90c3d602e149de1c477578efc34dee5f069f9764cc15808ce01ee15c", size = 223596, upload-time = "2026-08-28T21:53:27.461Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/38e93a10899c9315964c0a4e729b3e5867f8f46e977808f9c6fbda52525a/coverage-7.16.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:acadbf2f2a18d7f9c7f119ac798c00c540d7c79c93abd71ed648c87891303633", size = 254699, upload-time = "2026-08-28T21:53:29.715Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/acddda030b4630f68167f3daa94b41d22071847822a70d8178d43dcf678e/coverage-7.16.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4212cec9b42fd9929e70b462732fefd8b13406371871c82f3c14397499d6550b", size = 257614, upload-time = "2026-08-28T21:53:31.948Z" }, + { url = "https://files.pythonhosted.org/packages/15/7e/225b182497c1ce6d3f0d76a3074a4dbc9f272300e92bb100df53b03de0aa/coverage-7.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c5a43cc0ef101637ae920a9eed24cf0549ef815621eae68b3ad577ec5a7ad2f", size = 259236, upload-time = "2026-08-28T21:53:34.291Z" }, + { url = "https://files.pythonhosted.org/packages/2e/19/76641ddc50cb2410ebbd0ed7fe1052614d0e5612e802a2817521adb9febb/coverage-7.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c76a9b50a344261fe4a9bd20c322b48d3913cc48e8c37f78c21a596008296e68", size = 261433, upload-time = "2026-08-28T21:53:36.401Z" }, + { url = "https://files.pythonhosted.org/packages/12/9e/5f89de8b7c2017f36b68b4e4a25940723a748b21474820bf61e8bce0891c/coverage-7.16.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80cf547379ad6b1878fd03b033b51188beab4b41824c96e7839e014a4cb947be", size = 255182, upload-time = "2026-08-28T21:53:38.496Z" }, + { url = "https://files.pythonhosted.org/packages/1a/c1/ce94b2ec502e79775efb5efa22c741ebb0bd2be10bdd29650825ff57bdcb/coverage-7.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:4b1d09cb5d8dc2c7164450f5217e6f0717497de9c588806a0780d352abef904a", size = 257329, upload-time = "2026-08-28T21:53:40.87Z" }, + { url = "https://files.pythonhosted.org/packages/86/8d/3f5374df3a6ca19ee5f98a6bd21dbb05f1e9d399bd9978e9821d260eab5e/coverage-7.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:cd1e85abed2d2499c16664137ac802356316f92b4e2bf3c150bdf0c45f5dd9ae", size = 255210, upload-time = "2026-08-28T21:53:43.393Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b8/1bc5751496d0be6fd9dde8ca547d9a8a9f07847856aba3f3ae5ac594cd81/coverage-7.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:360967a6fd77794c167529eec2d16ff8e38216110619d23acc3fd466a1648bee", size = 259442, upload-time = "2026-08-28T21:53:45.725Z" }, + { url = "https://files.pythonhosted.org/packages/7a/dc/8aca78e47e1e6fcc761cd28a20daf4a84bd847a7369e2701a93ccfc3d1fd/coverage-7.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:92cbc2bf4f7f67c79f1d3ca4fe8c50faddf48e852a3d07eaaf02dc014889832f", size = 254618, upload-time = "2026-08-28T21:53:48.292Z" }, + { url = "https://files.pythonhosted.org/packages/73/fd/787842cdf6ce16ac5c1bd8a26549bab3b3f27b02500075bc540dc7853bca/coverage-7.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cce4dc8528453128c6fae523b15f3887fbea1d4d7c9eb9639d3d4fdcbe570c73", size = 256541, upload-time = "2026-08-28T21:53:50.805Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/8df302cbef373dd1f3401044cdb94dfc74517e5af2af27b4d0e721557e0e/coverage-7.16.0-cp315-cp315-win32.whl", hash = "sha256:5205baea687133613dced668a3d0168ea1479349615bfc255849a7944988c889", size = 225429, upload-time = "2026-08-28T21:53:53.177Z" }, + { url = "https://files.pythonhosted.org/packages/85/87/5bad7ac45f76b3728ca211028ee561c2ede3ba44da401129e28bb8737291/coverage-7.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:4fcb5f07a9b7083bfb715115d27ce263ba2b5b89dddeee536b295ba0e3c2c627", size = 225903, upload-time = "2026-08-28T21:53:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ea/67d84b11caf240f059ec313f616d82212df5004e8bc85802c1edfc50bb3d/coverage-7.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:d568a8adcec0eda42ec23e5e65dfb8c184fc255120f9e99b484f7c869d923fb9", size = 225334, upload-time = "2026-08-28T21:53:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/65/21/a88349cce3ff720729b754916ac47e2e3646a8137552e4fa7cdd5967cc7f/coverage-7.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3e8037e8213adf882e9d7eedd2c5c557933ab0b9632c42d98fe98ec9bcdb4025", size = 223980, upload-time = "2026-08-28T21:54:00.082Z" }, + { url = "https://files.pythonhosted.org/packages/fd/02/4d54abf3e6a4d8b7675921b20e91163b1064a5a9dbefebb71c05065dd136/coverage-7.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:289f2ed4d56eebf029b649e7dfc3c1153b111962a75e294cdd8e4a1598a04cc3", size = 224276, upload-time = "2026-08-28T21:54:02.381Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/10dbc96d95d20b9b041045d293480bd49e536180e93af62dd7662376284d/coverage-7.16.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b83f6ac575530783771c8dcf05284f7c8b5b12f1e7cb226d63445aac4497a3a", size = 265135, upload-time = "2026-08-28T21:54:04.558Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/6b326544afd1a8aef3a495bbae109a7ab5baf23e04a2741d8d64e2df2ba2/coverage-7.16.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c3ff6580f2dfc5bec34717b85b2e6cf5ec993b721e7bb58a794babd525a8178", size = 268216, upload-time = "2026-08-28T21:54:06.97Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/1dc8265f3ed990690e24d5f31ff79bc9fb9b25d54f9f89bebad5a6a8b7a1/coverage-7.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507596cee23e9968b1934fe86d799b76166541af0a293930918b1b48a5c84bd2", size = 270772, upload-time = "2026-08-28T21:54:09.234Z" }, + { url = "https://files.pythonhosted.org/packages/66/a7/3a8463713a402b44044ec832f4a76e442ce4b3a207804303f4d1dc1a9bb4/coverage-7.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edc2be98e6c55ccc5ff7832bb64f023a4b03dba39dfa84b850046cf08a8249b0", size = 271752, upload-time = "2026-08-28T21:54:11.701Z" }, + { url = "https://files.pythonhosted.org/packages/25/3b/dd5e795cfbe1842f69899189089ae289a96d6a68de312960ea668542e33c/coverage-7.16.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c0690994b84a15a53bdd39e0b2fdb539b22533820623eb86ba75b93760c645b", size = 265589, upload-time = "2026-08-28T21:54:14.12Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b6/fd90636cbd95cb018312f6ca1ca2bbd70fbe8e4ee6f3992fc36a4230364e/coverage-7.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:de24c62bf798940a14674a47489a81b79915ec4134f556d5199830e065225dd0", size = 268596, upload-time = "2026-08-28T21:54:16.303Z" }, + { url = "https://files.pythonhosted.org/packages/91/10/ef2d59264f3b3b358cc5885ca375e6cdbda7c195e78304d5aae800a72d9d/coverage-7.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:69474d81f198774c9d2937599ca5da04c9e1c5de5032da23c607ce4960ce360e", size = 265072, upload-time = "2026-08-28T21:54:18.597Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5b/400891c364c0170408d172501b340b18611800f4c42d8fbb16f9f5497c24/coverage-7.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:72a0795cc6d34acc2b03dfeabdc82b61b72087f2737018b56ac92c1cf5446c54", size = 269768, upload-time = "2026-08-28T21:54:20.985Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/9792c80271df04d287d21ed5d662fd8fa58b1737888d817679b1ce5d2fab/coverage-7.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:d9a218d3f9c7d6916684ed5ba94f620661117a730e733cd6ef5e87accc5872eb", size = 265211, upload-time = "2026-08-28T21:54:23.344Z" }, + { url = "https://files.pythonhosted.org/packages/81/67/5b8f827cfa6616e6bd7ba9397acfe7e3c4fd5b9fca4125511d5089f55d5a/coverage-7.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:49fa72ead28c8216f8916398a4f3c4669acb30a061822810ee20a727a1be2897", size = 267170, upload-time = "2026-08-28T21:54:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ee/c135d2d2cb617d744bc3e13c922f2fae66964494176ddef225dc4656bd2c/coverage-7.16.0-cp315-cp315t-win32.whl", hash = "sha256:27461af9f3ed7d2cf2411eb083784f87055ebf42211789ae3a216c48609bc743", size = 225731, upload-time = "2026-08-28T21:54:28.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/4d/dc3d53eadf155916e183bf5dfacbfc4aa5bfb7f13b7da11c01caa7a05cbc/coverage-7.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:c5612cc20ca76abc883e50269af47c1494b42958bb63dbb9aa79729a1ab5f7d3", size = 226562, upload-time = "2026-08-28T21:54:30.42Z" }, + { url = "https://files.pythonhosted.org/packages/2f/00/ac9da1a60a4e84c3ad0f7db4723fd327154a8f9add210c0dcd2db3ec5156/coverage-7.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:2ddaa9e2af4760a329d80008b7a3b4762fbb0dbcb169199360f9a5179c32f2dc", size = 225872, upload-time = "2026-08-28T21:54:32.806Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5a/234e8fadf85c3cc48cb31c247b9e8e0c7f06ece80f5b29f9b8c241f9da4c/coverage-7.16.0-py3-none-any.whl", hash = "sha256:245f7de6d023a5bba375dbec9f2e0869bfa26ac0cc639bbb7b4c814884000b73", size = 214977, upload-time = "2026-08-28T21:54:35.189Z" }, +] + +[[package]] +name = "curl-cffi" +version = "0.16.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/e1/730125c43e3e331d98e17af3cb310ba526b3f1101b7635ca23d976ebfcf5/curl_cffi-0.16.3.tar.gz", hash = "sha256:d15d0c2a35f2d75bec430c28946c2a833f421c85773bdb0795182cc5c515665b", size = 239020, upload-time = "2026-09-02T11:58:23.266Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7a/ec08ef0665c4ef4ea76b47042eb1c043e4afb374d8b9218e00272c9e73a2/curl_cffi-0.16.3-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:0f1f6878863fba393801e4d59b2f2766d1983b5c9d9dfa11d4becfd6a74cc937", size = 3025646, upload-time = "2026-09-02T11:57:39.326Z" }, + { url = "https://files.pythonhosted.org/packages/4c/86/e21b8ed384db26401a4438f20f01c7bcd9c3a6f8ceede458344e2d62775c/curl_cffi-0.16.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:f3b63da797912bc82911e34dfe449725514e4281527fb516931fc457087cfb44", size = 2784023, upload-time = "2026-09-02T11:57:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/97/2d/25b106e64178829be1ce171b6cd45ba354ab7a2a5169001866b38d4c440f/curl_cffi-0.16.3-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d5a4103f2baa1fcf619ec3101b419827d367044ba106b206228137cc71a5a9c5", size = 12834219, upload-time = "2026-09-02T11:57:42.711Z" }, + { url = "https://files.pythonhosted.org/packages/e7/dd/db27a521777d0cf00f9a1554453ae730539dd134bca108d8df256a85c91e/curl_cffi-0.16.3-cp310-abi3-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:f2795f0ef2e8cc0e6d702e52367af6600f5bcf10e44d683e256254adc7e3589f", size = 12655304, upload-time = "2026-09-02T11:57:45.334Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/2bbf141baa0fc3921d31a90de5465b7a94188845a8fe84dee86bf7bd90f1/curl_cffi-0.16.3-cp310-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a875a661e2f9a949be29454880bbb9553307a487c4c08819738298cf5c1622e2", size = 13484311, upload-time = "2026-09-02T11:57:47.58Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d4/745ca299a2a223ee18574ec7cff75de620a92ce69b3cb09490db8fba614b/curl_cffi-0.16.3-cp310-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0851e710608122a2716bdee35788bbd7e9d4a0fd42899b2bca9181277095af8e", size = 12840616, upload-time = "2026-09-02T11:57:50.016Z" }, + { url = "https://files.pythonhosted.org/packages/5e/bf/98d72d7a081cc155a71ab66bde6a18640d4ac5d4f6766f729a92cb4257c0/curl_cffi-0.16.3-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d7e553442cefec100dfd1fca4ae7035ab6c094457244bf60a38670b8ac8185d", size = 12612602, upload-time = "2026-09-02T11:57:52.584Z" }, + { url = "https://files.pythonhosted.org/packages/36/cf/2fdaff71fd6f39c5994495af8378e6e26bca8e447d94d2f75a76337908c8/curl_cffi-0.16.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:60621b3f561346046dd62be33abfb50c8b88a8007699d6b11d39ad4755312c4a", size = 12588697, upload-time = "2026-09-02T11:57:55.138Z" }, + { url = "https://files.pythonhosted.org/packages/74/55/68c399019bc24ea6ac783c98139a2555f88589631f3d127fe6b9073f019b/curl_cffi-0.16.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:20a7b1b473371cfaf2118958034977e457c6fa279fbd11543c9e0ab58be9eedd", size = 13253555, upload-time = "2026-09-02T11:57:57.524Z" }, + { url = "https://files.pythonhosted.org/packages/9b/72/1732a24ef4a2aeba994b80ec163debe8deda403c07e4abbc0443bca078b8/curl_cffi-0.16.3-cp310-abi3-win_amd64.whl", hash = "sha256:fe87b66e324ed7318166698e02169f3208dbda32b872a27d2bc61a9c19b335eb", size = 1978602, upload-time = "2026-09-02T11:58:00.033Z" }, + { url = "https://files.pythonhosted.org/packages/45/bb/67bec3132aeabac99dfe2f299a9b43dcb5de23ad96219ee98516d177fc9c/curl_cffi-0.16.3-cp310-abi3-win_arm64.whl", hash = "sha256:5a2ba880019f9e5a9e8f38ae22de6e4ea4c8d34a51ae4f1a2fce962c7b632006", size = 1713140, upload-time = "2026-09-02T11:58:01.558Z" }, + { url = "https://files.pythonhosted.org/packages/49/e3/b88f9b1a60a1e29b42e9371c1b3f4fdd83bf8177fcf863df67438da12693/curl_cffi-0.16.3-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:01c31369b1c8063c7e459152c508c90de7a4218aa66ee3a1f575ae37ce44bc5a", size = 8607348, upload-time = "2026-09-02T11:58:03.095Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f4/3dedff1a31c93a9b18acaa346e23832c29bc18075138e90e9af795188e5e/curl_cffi-0.16.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:0c8b70191dc88ea770a5c39d7e213bff1606e248c13566777e6527f0d8cf96ec", size = 8607323, upload-time = "2026-09-02T11:58:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/73/b7/99708ed83c11132ec0311a28ed46fe1cd10e8cb6ecd3c82f01f1f80c3c2c/curl_cffi-0.16.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8055ec9d7c15237747be254739c40057e3684f56854e95c12aaf3c95838ba2d6", size = 3026149, upload-time = "2026-09-02T11:58:06.973Z" }, + { url = "https://files.pythonhosted.org/packages/5d/d5/6c0400fb64097c4662da4e5d2d1e7daa8027d1431b1c8880c0f8f2051ae1/curl_cffi-0.16.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:391096e903ec98b909bb355e008ec7c211d710b6a23663a7f1f10aa54a027538", size = 2784153, upload-time = "2026-09-02T11:58:08.726Z" }, + { url = "https://files.pythonhosted.org/packages/87/a4/3c8702d25e21f420e88707701af15006e72a2a2b9f3fa419c7c80ce7451c/curl_cffi-0.16.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6cef43f248b3635de9b82337e0ed2c7403aa1506e51587144d552702eb9d0775", size = 12839612, upload-time = "2026-09-02T11:58:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/be/bf/44a7e7a1e309136a1b086332feb03c7718169af550bdbf7eab52ae0497e0/curl_cffi-0.16.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e1fffac4b5a02c5ec74d184d668c5b882f80fa1d961e7adba6e1755877af41e1", size = 13488945, upload-time = "2026-09-02T11:58:13.15Z" }, + { url = "https://files.pythonhosted.org/packages/52/83/5321d5fb67ff16195fb0c3bd5434be4532c85967c80546092a1cf3654cc9/curl_cffi-0.16.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:849026be5b36cf7b95d5fce63a84aa7b17248e83b4374e67715e7387ca2be50c", size = 12592235, upload-time = "2026-09-02T11:58:15.58Z" }, + { url = "https://files.pythonhosted.org/packages/12/aa/0b4e110729a86b434196d15e2e2839d992a9b8f3003f0569c77e27a9faca/curl_cffi-0.16.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:82cc688349c8e8955d346cc5cc7759b68742edc587ae47ba5783a096502a7a92", size = 13259593, upload-time = "2026-09-02T11:58:17.971Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3a/e4f199cfc9f131411543aacdf6811d8b72b81ce6ac6e9f6ddffecfc31e54/curl_cffi-0.16.3-cp314-cp314t-win_amd64.whl", hash = "sha256:72376595490c4822ad1a5360adb568660ca66dff4ba2c2de2912778c15f43edb", size = 2031033, upload-time = "2026-09-02T11:58:19.949Z" }, + { url = "https://files.pythonhosted.org/packages/18/8f/9354e5552982d38abd3ce2db859f049fee6bff0eee4e25aacaaa2b29f0b4/curl_cffi-0.16.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b450fad876aa9f9ed3edfb6e3a48a8c28eafa66aae634eff17800a8b5006568d", size = 1782234, upload-time = "2026-09-02T11:58:21.629Z" }, +] + +[[package]] +name = "cyclonedx-python-lib" +version = "11.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "license-expression" }, + { name = "packageurl-python" }, + { name = "py-serializable" }, + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/17/40/6509e6cfd7f2f3255501690f46375fdd224949e21fd1e96f4f4c8a9041b1/cyclonedx_python_lib-11.12.0.tar.gz", hash = "sha256:16767c4039de90c04e9f03348f8f0ed4b8ff842eaa7eefcad3a95685f970dacf", size = 1445378, upload-time = "2026-08-13T07:52:18.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cf/f0/b2cb999244f2f4194df63ecff6939228178fa63a33be809c412684ca8db7/cyclonedx_python_lib-11.12.0-py3-none-any.whl", hash = "sha256:0e807521a921a5c3cb8ce1153f8a61d29eedfe76a46aac2796b7c6b573391a54", size = 529453, upload-time = "2026-08-13T07:52:16.836Z" }, +] + +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "filelock" +version = "3.32.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/a0/50c2c0ce5e74d7721bbb1b19a26ebd339aac5878553a6e35308c2f31f935/filelock-3.32.5.tar.gz", hash = "sha256:f6a6a28f743f9b95ce19db5abe0f376f75eb56517dff21e1a4751e2657d3e83d", size = 222838, upload-time = "2026-08-31T18:56:34.729Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/36/d2/b70a31e13d04456d28493f31d2aa087e99eeb2767ef0293b2625727ccb8c/filelock-3.32.5-py3-none-any.whl", hash = "sha256:142cd9fa77a872c5e78c62329a0d15278fadc686eb89e760017968961a4fd6b2", size = 100003, upload-time = "2026-08-31T18:56:33.078Z" }, +] + +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/9b/356320fbae2ac8467e21c5e73e1389c80468e4998c62cc7d3536cc51b614/librt-0.15.0.tar.gz", hash = "sha256:4e66cbe84437497d951b799d3e1551291b6fb3d643820a7014b3655d57a59162", size = 214338, upload-time = "2026-08-07T10:49:42.663Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/42/467b53a601b406ccd7b97c1fd54b59cb34f9185ad5ce7e9d5c3c4e8961c8/librt-0.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:db13ca398005abcbe538deda87b686d9bd08b7001cf40c4c06b444960ae10a26", size = 151029, upload-time = "2026-08-07T10:47:19.312Z" }, + { url = "https://files.pythonhosted.org/packages/3e/e6/36c2299b7a94b84fdd01220d8a777a71be5be0925bb0dbdf71c0a06a34d9/librt-0.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:aa1f1995789dca3698bc550aaceb09a51bd5df0a057ff84ff15296cd1975b801", size = 155194, upload-time = "2026-08-07T10:47:20.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/ed5071f9325845e670bd36012757419767fbf56af77ed483077b9e4db541/librt-0.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55456ea87d8df21808446d03817be2f65e20391c1c615d9187440dff28cd08dc", size = 502568, upload-time = "2026-08-07T10:47:21.652Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/6450c67c3615d87704bcbc21323fafc69c799b06a044c447529f725d4b01/librt-0.15.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5a86a5a08c2235316bdb359d5dbb6ce0abfca7fac06363103e2c5af571d92f95", size = 496153, upload-time = "2026-08-07T10:47:22.925Z" }, + { url = "https://files.pythonhosted.org/packages/e1/d6/5f52b722bc75076954b3bfd49be15ea362df4d580c6fb315d0f617100d30/librt-0.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e56b6a368529bed262da40ce13f8fef590db0479819cca84f16a1f01ac356d0b", size = 513336, upload-time = "2026-08-07T10:47:24.213Z" }, + { url = "https://files.pythonhosted.org/packages/8d/e2/c08fd1d36ce63ea5a12b85c5d37f4550b5f86a692167e41e5a74222607ae/librt-0.15.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:234d8d394721fa0d786af15ebf1f3fb7f3ed82fd1cd0cde45c2f247b5d4281d2", size = 531661, upload-time = "2026-08-07T10:47:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d8/d9482fcbeb177b9eb87bb3899eeb3b42be690313c652f9e146b1d0681fb2/librt-0.15.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d8363d7accb0286ac3a0e633f396e93800dafb8150494505daf9515bbda591f3", size = 524487, upload-time = "2026-08-07T10:47:26.79Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/075171517b41f861753034fbb151b42cfc83bcc853849f24f5e66fd60ccf/librt-0.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0f0ee3644d951f31055ad07d77d92520e84505dd7a432cc4cd501dd70ee06785", size = 543201, upload-time = "2026-08-07T10:47:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/b0/03/42c2330f37eeb475b6affeedd06518f60035f323af3a839335e3fc9fef2d/librt-0.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2cfd1a81a648806e6a7717be4cc4d1bb392fa229752bf8444ba365e381e984d6", size = 546467, upload-time = "2026-08-07T10:47:29.396Z" }, + { url = "https://files.pythonhosted.org/packages/57/1e/1ad4c5638f7e64d8560328bd25c54b409a661bdb6ff254b38ff90744288d/librt-0.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:a6cd22c9da0d866558e46a041f1cc0c2bbb26b61b137b2347fa834c332e1d101", size = 555139, upload-time = "2026-08-07T10:47:30.815Z" }, + { url = "https://files.pythonhosted.org/packages/49/41/39fa7d15db1204cd1cbe6514680fbdc243adf754a0885061308f43afc013/librt-0.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6d5225ef8801e4ea5e482fa9b5dfb891dd9ef6f6d870f1f25d449ca2c70ac218", size = 536050, upload-time = "2026-08-07T10:47:32.222Z" }, + { url = "https://files.pythonhosted.org/packages/1e/88/c6dcf0dd8e26dc0c9a499a2abab8646c86dcaf9ecea9524cb46d3686331a/librt-0.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d28a05796b99f749bf8794f17ba9ba1612d0076b802e9cfc62c554634e9ce3b", size = 573700, upload-time = "2026-08-07T10:47:33.527Z" }, + { url = "https://files.pythonhosted.org/packages/1b/9b/ab54c71a7918a7c34fa5327fb61390a77446a07a146fbfb1165250a61035/librt-0.15.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:2067ff438048cead9d223ca5675bae2a25e520a7c3e6c1498bf9c6892d22caab", size = 82194, upload-time = "2026-08-07T10:47:34.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b2/4f9a243bb892395f3becb80789ade13771701091f9f07ab8230247953ba8/librt-0.15.0-cp313-cp313-win32.whl", hash = "sha256:1cd3b721f24c206398b9e26da3c3a9c011e6e89d06f318ba8ebefc30f1003890", size = 106231, upload-time = "2026-08-07T10:47:36.251Z" }, + { url = "https://files.pythonhosted.org/packages/bf/af/64aff4885a40b93132382f2c314647d722574605416504379184ef3045ea/librt-0.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:f395a4a9a03ac062dbe9a9f82e0c720502e590a38feee6a757bc82e9c63afbd8", size = 126996, upload-time = "2026-08-07T10:47:37.453Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/335bccf6c7cb9028cb0b54aead27d9ece3f01f83bc6baa2abace5da655c1/librt-0.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:0a15cb554761247d84a3ec0cbdf4078d70725384f0e4662c0fa3b26266eb60ad", size = 112188, upload-time = "2026-08-07T10:47:38.729Z" }, + { url = "https://files.pythonhosted.org/packages/a8/93/949053fb462eecc4a9a5ee770a81f4b40be7b79538b245545d4aebc6b58b/librt-0.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f5de7feedc56337a088eb15cd9fafa9938367362221d8cc62c642b7f94821993", size = 149833, upload-time = "2026-08-07T10:47:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/61/ca/8281aa6cd560a3420e4497729f6b704b53be3eeaaef82d5aeadddaf7441f/librt-0.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6c0eb900c0e91f4aebe680845242e614f1864edfd44106380d0752ac29522bf8", size = 154088, upload-time = "2026-08-07T10:47:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/dd/02/1a1662dceaba6a086360891448d5ce9a7d3555976cae59a31a39d744b9c7/librt-0.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e8c9a650a188e38bac005048cbe6342e81407782944d01934540ab75e417df21", size = 494215, upload-time = "2026-08-07T10:47:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/69/84/99211619dc656370a3740c33d2b0b6d5a3fb1e73689314f6ed477a397dc4/librt-0.15.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:92bfed8deec93df30286b9fe9e3b1dd17329cc076a192b4ee5ec223841d54953", size = 491173, upload-time = "2026-08-07T10:47:43.683Z" }, + { url = "https://files.pythonhosted.org/packages/d4/aa/5448d0b05f4579b635d3899176817ebf561af0e57bacd425b5b1887264c1/librt-0.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec4b19788f835711a2072f9dbe6b03b3bf32ed1f0fb30cf399bdd59d9f0c33fa", size = 505512, upload-time = "2026-08-07T10:47:45.314Z" }, + { url = "https://files.pythonhosted.org/packages/95/82/01940e40b83c43a546c4a3c896cf34ca272a9690899d55914e4827b3dcce/librt-0.15.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4c7bacb70930f3d0a56f4ecf1be474a1f0d941b01dd73b756f3c256d42cb879", size = 523073, upload-time = "2026-08-07T10:47:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/88/fa/759c0030f3ee371439eb26de34fc745807caf0abb878af7af4b8b7c3dd3d/librt-0.15.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e79f05e4a08b4d880342673312bbc895b56df7765605796f15902eb5367d3ae", size = 515080, upload-time = "2026-08-07T10:47:48.319Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/894e072228fcb159703c655da69f8cd10dbed489c36e3df7dd032a2483be/librt-0.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a417149c0cba4d50b61e992e5a15e69eaf96746609b461cc4ed168aeef6b79dd", size = 534164, upload-time = "2026-08-07T10:47:49.875Z" }, + { url = "https://files.pythonhosted.org/packages/98/a3/0078e91c1f36f8815db17827de15650b9a3fe56c55fbf998c854b34e40d3/librt-0.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:da7a94d6a3411f579d72aa3e3bc5fbca7ed4549f3dbd7e5de3aa567333374285", size = 540616, upload-time = "2026-08-07T10:47:51.408Z" }, + { url = "https://files.pythonhosted.org/packages/86/33/81a29b796dd52a45e9ef7974c7732926e8f10f15b8d2be505665979f896d/librt-0.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:856f743ae607f2c1380eccb566c0038a9fb3eabf0fc2be2704d76d9f73557239", size = 545890, upload-time = "2026-08-07T10:47:52.818Z" }, + { url = "https://files.pythonhosted.org/packages/05/82/8be1baa1350e5d30cfd70ae79d0a6f4dc5862ef47f7bb2808aabc9bb86e5/librt-0.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:779a6e7c894737e5983e7790a9c78c4000c30e23c9aada08081bdbea53b0fa60", size = 523287, upload-time = "2026-08-07T10:47:54.165Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4f/d1be6a01a35c20ef734e0e44113f87d4af756a9354a89dcfbe3b4f8af5e1/librt-0.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96bb17dbe8bab3c0954fbebfc69ed395599de75b6bbc35e3270a878e15d4dd65", size = 565868, upload-time = "2026-08-07T10:47:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/67/88/649cfa33f5825927b160610f670bdab012a64d627eddb94fa795ea4292fd/librt-0.15.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:7220697efaa6e5348fc3d18ee7f8563d4bfecd9872b37ffb915bfc1d08840622", size = 81619, upload-time = "2026-08-07T10:47:56.886Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/8e88a8d5e48fc8d1a817787fb6811dfff6499acd6c8683dd83934aa6ede0/librt-0.15.0-cp314-cp314-win32.whl", hash = "sha256:f54598964d357b1c5ab77cf5d92f21e598fe0e23cdbe9618480807f81b4eba15", size = 100138, upload-time = "2026-08-07T10:47:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/80/92/20fd6c4b6a1b1a564b076d55cd3d427d8428217d7638dc25a654cc4791d4/librt-0.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ff5893a2c23d886aa9ce786de5ac6ddc74aeeaf90743682b74d920e117d2e28", size = 121258, upload-time = "2026-08-07T10:47:59.564Z" }, + { url = "https://files.pythonhosted.org/packages/fc/28/6af430b44d9ebb897b865a3c363b6dcace51357be2347cc0f8f869656a86/librt-0.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:3722a099730704c9a3d70c879fc0f51daec25fe5f1555672d97bc595abeafb95", size = 106467, upload-time = "2026-08-07T10:48:01.097Z" }, + { url = "https://files.pythonhosted.org/packages/7e/aa/b42bb798942ced219f6d63b27e07f91237887a8d0bd0921666db79a13790/librt-0.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:38c0c7d4b6fc06c3324b3f9162c8391bfc4fd9dde53afe1033ce7edb48d5a714", size = 159523, upload-time = "2026-08-07T10:48:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/75/03/1b53cd4ef904e73b1d828a5f90143bf94a2967d7cfff0b9ccf93e12aa9b4/librt-0.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b2fdd7ead3c995c37940a790690660d0ca006c302db26cc51933f6766866fc3", size = 161638, upload-time = "2026-08-07T10:48:03.725Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/9f9c9fba097d49e9e694c2b4dc331df31884645ecbc58a93b4b5fc69d2c5/librt-0.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2fde98cf1fc4bac144ce23c2c4c017b924ba714509ea9334977b0b27050c837d", size = 701795, upload-time = "2026-08-07T10:48:05.135Z" }, + { url = "https://files.pythonhosted.org/packages/4c/05/0966840bda0380c8ae167b9043c6230202941cc90ea29c48e096964c765e/librt-0.15.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:e3b461183c5fa7681b48560f91515f53a953122fb30c71e07abc67d7ddf58c38", size = 682147, upload-time = "2026-08-07T10:48:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/18/af/1c47ca573c30ea47d195aec26133af522fea1104afaace028d7b32247ea8/librt-0.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4bbcc257e3babea20a91715c361b24554ec4e8f51aa578568afc230799fe1a19", size = 696397, upload-time = "2026-08-07T10:48:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/2e/0f/1aed6223d4f9f9d1171a8596ff100ea4c3f7699fea7a4ba657c3e60daa6c/librt-0.15.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b845b8d48088fad0cadc84be4b8fda63203be7e9237b71015b3925443c1f35ab", size = 722542, upload-time = "2026-08-07T10:48:09.569Z" }, + { url = "https://files.pythonhosted.org/packages/c6/22/9e3a929aea456c97d69e6ef3884efea56d4807f97399471cc946baebd8af/librt-0.15.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b30e600e8f337b9bd7f39b86d9fdfedc73cc46e3d0f745931a23a234220bb7e2", size = 729709, upload-time = "2026-08-07T10:48:11.129Z" }, + { url = "https://files.pythonhosted.org/packages/e9/1b/c327ef6018e3a9ca0b8e7c5eddeeb331ba8f9b76c24e126d37d0f6d62faf/librt-0.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:64b0c8c35aa4c4ed79896359f3e0b285cbe4e610042106500da4811c322cc108", size = 752891, upload-time = "2026-08-07T10:48:12.558Z" }, + { url = "https://files.pythonhosted.org/packages/d7/d1/d5f1ea02c56930087009e39db9b70660a663e76c730b27b925d786718457/librt-0.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0da0d94cb802f32a0524653e7201f2cef72d5f700a5407678f5290483d4fcd08", size = 745301, upload-time = "2026-08-07T10:48:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/d9/3c/5f7c585d15ebb2250c73e7c0ee4e9e47be72c65d520c07ddbcdc62037674/librt-0.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4a6369168d371207339b1e50d4532b06a7121586141f82599505a3f315751d47", size = 747921, upload-time = "2026-08-07T10:48:16.453Z" }, + { url = "https://files.pythonhosted.org/packages/7f/52/1443a446486eba966bcbca1696b472e4f210320ec42f490a47f48fbf0fdc/librt-0.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c434e072557ade9cbc642d052c89d031efe47d5c9614523619d0d74a02378e81", size = 727561, upload-time = "2026-08-07T10:48:18.089Z" }, + { url = "https://files.pythonhosted.org/packages/79/91/2270a9380f11725cf83ce1925a5e32dd1dde2be9bba597f25c10a38644e7/librt-0.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7eec6a42018bc1d45763b1c162d3d2bf7c3b9a1b0ed30d3e91dcba390efefcc", size = 774417, upload-time = "2026-08-07T10:48:19.611Z" }, + { url = "https://files.pythonhosted.org/packages/9e/3b/f4b1548d4f5b99186737fe27aec238e9823e8d5d23bf4df007c030689dc5/librt-0.15.0-cp314-cp314t-win32.whl", hash = "sha256:6912fa5e635d74529ac7cdb1bdf6ca3af4453da8d1edbe0110ee1cb4ad407ebf", size = 104381, upload-time = "2026-08-07T10:48:21.048Z" }, + { url = "https://files.pythonhosted.org/packages/80/b6/134afad262def1de04c0843c376d02135f1168af43f22e09a52bd8394727/librt-0.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8e11699ed745931c395acd3621b07062e0f840efa6935aad87a64ed0995f0915", size = 127034, upload-time = "2026-08-07T10:48:22.561Z" }, + { url = "https://files.pythonhosted.org/packages/99/5f/1b6846b20572bd699c9e9ec321a5f781845bee477df2aa2a43b28bc40119/librt-0.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:5d2a91724463bfed4f573cd7a9fdc856d2e230d0c0e5a61416a93481dccd8605", size = 110827, upload-time = "2026-08-07T10:48:23.804Z" }, + { url = "https://files.pythonhosted.org/packages/c6/44/4de9f4ddadb009a55c7758eb5736d62534a7daaf27bd71bc50e64b606b06/librt-0.15.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:8443e38dcfcfdbcf5add5118c623efd788d65ac2e25756d6251a54a06a4d0aca", size = 149843, upload-time = "2026-08-07T10:48:25.148Z" }, + { url = "https://files.pythonhosted.org/packages/1f/eb/5d9ab71e30119c44094e0275f38b47dd327aea0f843a080396677029d508/librt-0.15.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6d15a29033c57490cfe2069097c6fc4049e4e65ffbb749be7dc453b7c4c68965", size = 154510, upload-time = "2026-08-07T10:48:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9c/8505d1b8f5e8c19587bd03f7429993b3e9ce5c06819d856bfb11d919374c/librt-0.15.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2c05c729b589e734c09578bf5964be48a911765484840d017bbc84f49d4c4ad", size = 497543, upload-time = "2026-08-07T10:48:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/1d/9a/3a8390775cb095765aded027ac9c63e7c8ea74e731498607544c6505de0e/librt-0.15.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fa60887537e1d0cd2d9982269d33a709bf54b195cd2b9364fc0a758022af5bd9", size = 480452, upload-time = "2026-08-07T10:48:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/e7/40/258a4a7117ee915d66de5cd9b8ade65a440993161107ce3a686f1859955c/librt-0.15.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d8bc24219b24c0af375718942ab75e3544b2763085f40f965be4326734ae8328", size = 507768, upload-time = "2026-08-07T10:48:31.007Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/2f4dd296c97a0b85b98894519b279408ec9dd602d4f692b1ea0e25dee670/librt-0.15.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:86a21a7bd3fe3a419512ef424cc1c020f6771d0b29cfddff36d1635a855e63f0", size = 525122, upload-time = "2026-08-07T10:48:32.7Z" }, + { url = "https://files.pythonhosted.org/packages/49/dd/29eab42be13b2bf0ea8cb227135a45d44693e30a7e8b92871981ff56b82b/librt-0.15.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dbab647e88d90b3167b91efe7091e248653688ed4337e4f90907a722c7361bb9", size = 520371, upload-time = "2026-08-07T10:48:34.294Z" }, + { url = "https://files.pythonhosted.org/packages/91/ed/4bad71adeca8fe208b775c2a35417fa5a2584c8f4791daaf89a89450fea1/librt-0.15.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:d8edcf6f550e918dca779c069b9e156385c60b406f99fc7641f32c52f7193659", size = 537258, upload-time = "2026-08-07T10:48:35.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/63/59dba6143fdcc7240c54458b629f3250000a61b8945890fc9efd451b19c5/librt-0.15.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:8b62076030baa2d8b1501a46bf0e19c27a489aa90671c55665bff7887f7660b0", size = 527432, upload-time = "2026-08-07T10:48:37.466Z" }, + { url = "https://files.pythonhosted.org/packages/ec/21/21a24c6a2327d8362580efebe77286bf47b0f4062ec5ea41766e609d3c7d/librt-0.15.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:d00d20d1818e82a07a0ee0aa89a98b17ed7916b92441090b683719cb20a59b6d", size = 548108, upload-time = "2026-08-07T10:48:39.384Z" }, + { url = "https://files.pythonhosted.org/packages/5a/6d/fc68c89a7971418b41f9a873623ff935cb864097544c6a2f8ce491c8ef5d/librt-0.15.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:4e6ee93fc3cf848dcbf0cce2eca73d8e7dcd0cc2b6df3a529d57750b30a4c55c", size = 529681, upload-time = "2026-08-07T10:48:41.392Z" }, + { url = "https://files.pythonhosted.org/packages/65/7e/c2d98766124400d722063a630b0fde38a9fc768705d37eecca15c47dc192/librt-0.15.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:32896a0af72508ea979e0acb4e4c04cbeeae04938167950d535c83c45597167d", size = 567736, upload-time = "2026-08-07T10:48:43.124Z" }, + { url = "https://files.pythonhosted.org/packages/55/6c/f8c34a95e3a515c6e1c192b89511e7253c89a7760c6b500d57ffdb8d2dc8/librt-0.15.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:ec3ba415afaf951f6951b1dd16d3c8e4f540065fc382d7e70b823a79567ca374", size = 81673, upload-time = "2026-08-07T10:48:44.645Z" }, + { url = "https://files.pythonhosted.org/packages/c9/9e/e23fa8e78679ec45728188650b39e8ff476c83b691c96f749217df3b1b7c/librt-0.15.0-cp315-cp315-win32.whl", hash = "sha256:d2813ba2503764f0450680c533d13df7cff9b49df1411062eded5f67db4195b9", size = 100081, upload-time = "2026-08-07T10:48:46.171Z" }, + { url = "https://files.pythonhosted.org/packages/e1/dc/3eb4c5e297343f0620a55532cd7c8d764d3001fa2159212dadf480464827/librt-0.15.0-cp315-cp315-win_amd64.whl", hash = "sha256:b87d67e33afaf265262f2a66db578284b88ee2e6fcd224579cb5c15518677ad8", size = 121228, upload-time = "2026-08-07T10:48:47.631Z" }, + { url = "https://files.pythonhosted.org/packages/97/70/43abce19f04e49762f8ec834c8fafee13cc40fd6b94a72a24e534febfcd0/librt-0.15.0-cp315-cp315-win_arm64.whl", hash = "sha256:713bd7df21170b982e729e46870f31d6b437bd1a9b4648cffb529bd3c2ec5c4b", size = 106487, upload-time = "2026-08-07T10:48:49.095Z" }, + { url = "https://files.pythonhosted.org/packages/de/15/83f2deddb9368b8951ec8c9477269b5b9b8bd9bbf15e57402d0f38817dca/librt-0.15.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:3de789c82752730f94782a5ee518baf9c05edf85733aeaf73bb6e518755cdf54", size = 159448, upload-time = "2026-08-07T10:48:50.649Z" }, + { url = "https://files.pythonhosted.org/packages/06/bf/043097353f9b3c73b583d07f6b8e552795463f4bfc8caf85e42eee50c26a/librt-0.15.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:e0b5deec9a8664eb722c797241970fd4aa1894d25fda36a1ddac0f7407606bd6", size = 161686, upload-time = "2026-08-07T10:48:52.174Z" }, + { url = "https://files.pythonhosted.org/packages/f4/2a/8ae77f9719d42ce71cd708560a3557b38ac3c17a0383e57f87084de45bbe/librt-0.15.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5563302a8359bc2295bb7084d1a8ed1519df96afb30eb2aa4e0bff7b54228988", size = 710668, upload-time = "2026-08-07T10:48:53.782Z" }, + { url = "https://files.pythonhosted.org/packages/61/34/c0436ea134deb9a0d6da80a396a2739a81cb31e0418f7227239e23140898/librt-0.15.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:22d6263b9d39d7bbb286fa791945646e3218f1be2d693e36fb630f1d0e59cd13", size = 679396, upload-time = "2026-08-07T10:48:55.645Z" }, + { url = "https://files.pythonhosted.org/packages/4a/9f/001e0d99aa9250d5cd5715a9081291a20656083459f9019cda15255329e1/librt-0.15.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:39ffd14646190c454f0d86e0d256b33f00a87a26ab410e619773b841d0e41416", size = 704313, upload-time = "2026-08-07T10:48:57.46Z" }, + { url = "https://files.pythonhosted.org/packages/2d/53/b34fa9d0ff00f136f4d58ebb4c411ff634baed1eb412bb602a2bc8dcafcb/librt-0.15.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47318cd3a61401452de11282242937e3e057c4fd3dbaf601e269d0928a06c0a", size = 729847, upload-time = "2026-08-07T10:48:59.231Z" }, + { url = "https://files.pythonhosted.org/packages/86/ac/fa4d7a424665040e95baf480a6d523446057684b6758624c85338e8a23b2/librt-0.15.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a56a1d4f859a82ca5b99fc4b82c9b027b15e3c455c5cd99e7d0719f27bb20b6c", size = 742736, upload-time = "2026-08-07T10:49:01.151Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/e17a9bb5de6fb8c3186ed1a7d68d21618b027ac2d3633e03d3b6109c67ae/librt-0.15.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:077471b3182db4e17c36ae91555f36a4d2c00080b267f749bcad34a478a9a302", size = 763454, upload-time = "2026-08-07T10:49:03.039Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ec/ecd02cd30935b931b9cdbfed6ab5a099c51b280b4e7baa274da80978ed27/librt-0.15.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:411ca4d1b905b860ceba7570dd6717a71dedaddcc4b0f77ece710aa41ee11f8d", size = 743296, upload-time = "2026-08-07T10:49:04.941Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b5/b3c2b8353ce820a4854f78d19321344242f89fa71c975b71132ba9bf242a/librt-0.15.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1256589e0b0adb31751d685a68bce29d73407ddf4ef05d4188f49d5dcf9566d9", size = 756217, upload-time = "2026-08-07T10:49:06.825Z" }, + { url = "https://files.pythonhosted.org/packages/3c/52/6cc22542ba59146b05cca2a656f9ff8bb67e38e63d12c3b0cc183d837bf1/librt-0.15.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:f42b74a53e5f26a0ba0007411a7455b66c67ce4022a39cc1f56fc4efd65bcbab", size = 741934, upload-time = "2026-08-07T10:49:08.839Z" }, + { url = "https://files.pythonhosted.org/packages/40/32/a04b72b1aa86e3be23b2ecff8c1aad2dcc955bd3956d6d26e7e34267e57a/librt-0.15.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:291bf73caf78b9e88d6fae9bfd693207ff7d832e2fdbe2cf8e746bc13f5f892b", size = 783763, upload-time = "2026-08-07T10:49:10.661Z" }, + { url = "https://files.pythonhosted.org/packages/6c/f0/89eb11dffbe9279ff37144dec786927314502ae0b114f1449dc78c458aab/librt-0.15.0-cp315-cp315t-win32.whl", hash = "sha256:c16d15ee371643ab48dc8248a3e680ebbeca573a13af2c3dd0c985b142d77162", size = 104313, upload-time = "2026-08-07T10:49:12.305Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4a/1f1978c200f563beda63c36adff2d65bbecb81e365e8e69e572f5f70fbc6/librt-0.15.0-cp315-cp315t-win_amd64.whl", hash = "sha256:dbd605739f228912dc49027cb764456b9757750bdc2b6b7773164db7096c6fd1", size = 126889, upload-time = "2026-08-07T10:49:13.881Z" }, + { url = "https://files.pythonhosted.org/packages/38/a6/800800bfed7b1fb10fc3f3d557785c3854e80d3f7a9800d784b176a1fc2d/librt-0.15.0-cp315-cp315t-win_arm64.whl", hash = "sha256:84d244b00604d17df3fc7736c327892d6bba66181254aa4087be807b6c342bdc", size = 110700, upload-time = "2026-08-07T10:49:15.499Z" }, +] + +[[package]] +name = "license-expression" +version = "30.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "boolean-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "msgpack" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" }, + { url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" }, + { url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" }, + { url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" }, + { url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" }, + { url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" }, + { url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" }, + { url = "https://files.pythonhosted.org/packages/5e/50/3e92c403346652cabd08cb8faceef847bae917ea3b3c81b64a5b6d09ed41/msgpack-1.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04", size = 84315, upload-time = "2026-08-27T10:02:41.181Z" }, + { url = "https://files.pythonhosted.org/packages/b6/dc/8efe6dd96a12ab043930cb4cffb40b6e7f061491d6ec7a3d2b75ef1fda42/msgpack-1.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77", size = 84634, upload-time = "2026-08-27T10:02:42.621Z" }, + { url = "https://files.pythonhosted.org/packages/1b/89/996573095bf7b038c04dd65ddbc4f1a4d381b0f7a44ff9186f3c7b8325c2/msgpack-1.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe", size = 404194, upload-time = "2026-08-27T10:02:44.096Z" }, + { url = "https://files.pythonhosted.org/packages/b6/4e/46f5a5d949dbd054dab60cb15aac7ac6ae6774c134532893414689bf2f53/msgpack-1.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f", size = 412343, upload-time = "2026-08-27T10:02:45.747Z" }, + { url = "https://files.pythonhosted.org/packages/da/e8/739a94197358a313307e6e9e7d8d22ef66add39222de911a44161aa96920/msgpack-1.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea", size = 372620, upload-time = "2026-08-27T10:02:47.578Z" }, + { url = "https://files.pythonhosted.org/packages/03/d4/09b92e1fcdccea9466bfae45455367ac52362ae445d96a602e51b7a8df73/msgpack-1.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b", size = 394603, upload-time = "2026-08-27T10:02:49.172Z" }, + { url = "https://files.pythonhosted.org/packages/47/db/d11bd6f258a60703dcdc7a3772818ad0c2f602ee4c2acfb24088c6c3ebc3/msgpack-1.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5", size = 372666, upload-time = "2026-08-27T10:02:50.886Z" }, + { url = "https://files.pythonhosted.org/packages/71/cf/fbbbac0c6e5fbb9d51abc23e3b5fe8620f5c01e0588797cf664a623bb9e1/msgpack-1.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54", size = 410889, upload-time = "2026-08-27T10:02:52.51Z" }, + { url = "https://files.pythonhosted.org/packages/94/60/8366558da954095e04e7fbc351f9387d87a682feaee9a235ceda966f794b/msgpack-1.2.2-cp314-cp314-win32.whl", hash = "sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248", size = 66774, upload-time = "2026-08-27T10:02:53.984Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3d/1ce873c8057c65e4fbb076ffe1c99c9ae39d90a00a2540d7b06c652a292f/msgpack-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc", size = 73424, upload-time = "2026-08-27T10:02:55.277Z" }, + { url = "https://files.pythonhosted.org/packages/d5/55/e36f2a33e38657f33850d74e0bf256838a0d45802c298cc501a32bffcc08/msgpack-1.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8", size = 67657, upload-time = "2026-08-27T10:02:56.493Z" }, + { url = "https://files.pythonhosted.org/packages/64/58/7e764b957bae80ae281a9cb28761068c8bae8d5c6ac0873e43cc69d176c7/msgpack-1.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650", size = 86594, upload-time = "2026-08-27T10:02:57.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/f0/250f5985b6ee533e60d357571a808aaae03c54118294dc3db7158e27feb1/msgpack-1.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c", size = 87374, upload-time = "2026-08-27T10:02:59.256Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2c/126ec8f187877c5f688631c543d1d3a3d75b2e66b83fb9de3ed7c13a39b6/msgpack-1.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3", size = 428157, upload-time = "2026-08-27T10:03:00.9Z" }, + { url = "https://files.pythonhosted.org/packages/95/21/d2d81d50aaedb14147d01f22094185794db3ad8a8791b60afacba0627c89/msgpack-1.2.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3", size = 426669, upload-time = "2026-08-27T10:03:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/c6/fc/f7d484ee5b572719608e7ffad569bea22ff11309a96ca2fae85eec94226b/msgpack-1.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22", size = 380625, upload-time = "2026-08-27T10:03:04.244Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c4/b924cbd5516676f4e612329f18602a833bd055ffbe27f808eeba0f01bfea/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839", size = 411328, upload-time = "2026-08-27T10:03:05.869Z" }, + { url = "https://files.pythonhosted.org/packages/27/9d/0c1d9683a951a80f270c3b7dac1022c18b9307617344dd44d904135d5e12/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929", size = 377892, upload-time = "2026-08-27T10:03:07.37Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/bf22338cdd22e0b40c8f28468cea5f3d9c320244c095d8303364bc012c41/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7", size = 419426, upload-time = "2026-08-27T10:03:09Z" }, + { url = "https://files.pythonhosted.org/packages/7d/42/6d02c19a01abd8d7ce817c321d2ee6af1a8e24d584dca619d1b6576a83bf/msgpack-1.2.2-cp314-cp314t-win32.whl", hash = "sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e", size = 71810, upload-time = "2026-08-27T10:03:10.498Z" }, + { url = "https://files.pythonhosted.org/packages/af/df/fda3a204415dab0a8c0db5461ef7205416ea52bd8581c5cafd361be07f3b/msgpack-1.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36", size = 78919, upload-time = "2026-08-27T10:03:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/63/d4/4b4b0ef25a86deca91feaf7252ca885ba4f2ada40461379120122a04fe96/msgpack-1.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f", size = 71925, upload-time = "2026-08-27T10:03:13.332Z" }, + { url = "https://files.pythonhosted.org/packages/3b/92/4b44bc8f3243ef8cf9cb5368c17a299d45b9df858f6dfdd98a0482dbbb37/msgpack-1.2.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5", size = 84293, upload-time = "2026-08-27T10:03:14.718Z" }, + { url = "https://files.pythonhosted.org/packages/80/05/c992bb65744665a41b5bf531fc0e1619bae0901f57738228ded90023c151/msgpack-1.2.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986", size = 84490, upload-time = "2026-08-27T10:03:16.12Z" }, + { url = "https://files.pythonhosted.org/packages/d7/bf/7f53b9e6709a4df7f9b9b81dc65f9dfaa32caf65bee94986ec2cb8fa07f1/msgpack-1.2.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516", size = 405332, upload-time = "2026-08-27T10:03:17.692Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5a/305c4dca14b50d0b51fb88ef04ec125b8f0be3e2ce730dcc62dbaa651cc5/msgpack-1.2.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21", size = 416798, upload-time = "2026-08-27T10:03:19.389Z" }, + { url = "https://files.pythonhosted.org/packages/7a/df/a645102b4cdfd9a94201cac4e900e9c1429fc16d86aa311c06eef82528c9/msgpack-1.2.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9", size = 377312, upload-time = "2026-08-27T10:03:20.928Z" }, + { url = "https://files.pythonhosted.org/packages/f4/26/c56d8d086d3fb1077bb48092b158b5ea2eee08b279e10c191275f13bc980/msgpack-1.2.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a", size = 395182, upload-time = "2026-08-27T10:03:22.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b5/3d46ba367a565e536d8d2a61eebcee71b1dc803da3ce74a22313b573d6fa/msgpack-1.2.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5", size = 377945, upload-time = "2026-08-27T10:03:24.151Z" }, + { url = "https://files.pythonhosted.org/packages/a5/2c/d5d2df273ed5306357da25b69400fd8d7a53c4d87d8976604b677484d61c/msgpack-1.2.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b", size = 413341, upload-time = "2026-08-27T10:03:25.85Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fb/32613bced3cad47b40b1b73dd04d687121349d83f748efc2575929121903/msgpack-1.2.2-cp315-cp315-win32.whl", hash = "sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf", size = 66730, upload-time = "2026-08-27T10:03:27.294Z" }, + { url = "https://files.pythonhosted.org/packages/74/56/d86171f7251015e9312e5a7f9fdd4cf89752fc2114b88fed453d2a040c66/msgpack-1.2.2-cp315-cp315-win_amd64.whl", hash = "sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff", size = 73477, upload-time = "2026-08-27T10:03:28.615Z" }, + { url = "https://files.pythonhosted.org/packages/13/1a/56b90f6defef61700b86baca3637c15f62ac0f9b21ab0f16613ab9d1f101/msgpack-1.2.2-cp315-cp315-win_arm64.whl", hash = "sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808", size = 67660, upload-time = "2026-08-27T10:03:29.895Z" }, + { url = "https://files.pythonhosted.org/packages/cd/20/12751ca0d8ec874701b54c392c2b19f51af8dd1de40a92a10e356f0aaf58/msgpack-1.2.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8", size = 86462, upload-time = "2026-08-27T10:03:31.348Z" }, + { url = "https://files.pythonhosted.org/packages/91/4c/cf6d12a3d709fe5f9771dd917c35e6ebcd55597a5b792287382fde056c95/msgpack-1.2.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84", size = 87412, upload-time = "2026-08-27T10:03:32.74Z" }, + { url = "https://files.pythonhosted.org/packages/ca/0d/0aac5752d1708dcb458f8754db34a4999514db3df2d2b798b9381293f638/msgpack-1.2.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b", size = 422057, upload-time = "2026-08-27T10:03:34.124Z" }, + { url = "https://files.pythonhosted.org/packages/81/30/70f281a3685b04aaf235a5237da11b978a02a865a5a479186205177ad676/msgpack-1.2.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782", size = 422696, upload-time = "2026-08-27T10:03:35.862Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/f76e8425efb0aa38988cd778ae290bfa120491d80d26872d88bb52fedb3f/msgpack-1.2.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f", size = 376495, upload-time = "2026-08-27T10:03:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/95/77/0809aa9b52b2868f7d01862dc14073708f0440421a65197b48453480034c/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695", size = 404683, upload-time = "2026-08-27T10:03:38.87Z" }, + { url = "https://files.pythonhosted.org/packages/02/d2/4e5ac915ba120172d210ef00165c5e6276c8a65db3a4a5cf36e946b83e23/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23", size = 375087, upload-time = "2026-08-27T10:03:40.486Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e3/8051d53e5495c87c6cf27eb42fb680361017037f87f322bdaf525f71e4a2/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212", size = 414421, upload-time = "2026-08-27T10:03:42.308Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4e/13783aa7c17414d7186c72c49bc718366f75e49f0ea58d4f81cb63ac3187/msgpack-1.2.2-cp315-cp315t-win32.whl", hash = "sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc", size = 71790, upload-time = "2026-08-27T10:03:43.715Z" }, + { url = "https://files.pythonhosted.org/packages/5e/9d/1d02994c7ae2603c98100984428ff0f67443572133bc18eca6058f732c1b/msgpack-1.2.2-cp315-cp315t-win_amd64.whl", hash = "sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d", size = 78766, upload-time = "2026-08-27T10:03:45.036Z" }, + { url = "https://files.pythonhosted.org/packages/60/54/89ed16e6f966a050dc78b0e94a545025211b07ce9f4bdfe07dff70c03fc2/msgpack-1.2.2-cp315-cp315t-win_arm64.whl", hash = "sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754", size = 71819, upload-time = "2026-08-27T10:03:46.375Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "mypy" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/6a/878cc1097d4035f82bd516658d0c528d2a9955bc7b363afcbd0b07fea11b/mypy-2.3.1.tar.gz", hash = "sha256:47c1b1207258513a9d93495f69c8be9de73916186f0e52703e8c461b7a623419", size = 3992554, upload-time = "2026-08-15T03:03:38.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/cf/862010ee800ca9c2bd0c4c0dacf0f092e5411824a09b8f97ad4be8fe250e/mypy-2.3.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:114dff494000f18bd10d5d95d84b8567b26da60279ecbe838131841df20e635d", size = 13964542, upload-time = "2026-08-15T03:02:21.43Z" }, + { url = "https://files.pythonhosted.org/packages/75/5a/3f3a2107b41e3e92e617e25daaee121413b91e9784bea733131ed4fecc5d/mypy-2.3.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8637731bb5eee3671eb2c3200827aa3564ed8a9309ecee4d1afe77e6d031bdb", size = 14168922, upload-time = "2026-08-15T03:03:00.351Z" }, + { url = "https://files.pythonhosted.org/packages/8b/41/04dc4fe7e63d7820fa4eff272e95157d30cbea921388f3ab3fe77794cd0b/mypy-2.3.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c80fbc405ed8020f5ff3802dc18cf060197bcdd3fbdd6a26ef2fd34dfdd5226", size = 15244791, upload-time = "2026-08-15T03:02:31.089Z" }, + { url = "https://files.pythonhosted.org/packages/96/fc/c3053b26b9054949285aa868cb6af8c10e7591541cacd79c5dcc06a1fcf9/mypy-2.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:84081f538ce27375045c02e3d7f81bd11d853400621ae245d87ce7b6c420ec74", size = 15501627, upload-time = "2026-08-15T03:03:34.128Z" }, + { url = "https://files.pythonhosted.org/packages/70/4e/d77daab008bbc4e5001374d7928f4a260d28f0e6747af444fc4763f7a310/mypy-2.3.1-cp313-cp313-win_amd64.whl", hash = "sha256:e9144ac16fde007096f9563eb2041b4433c2d705c4218edeb79e7e9d01035ee6", size = 11243961, upload-time = "2026-08-15T03:02:11.952Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f8/7eb68c136e4abd30569fe31ef2bfcb7eceae9952cab80017c04cd09f5d0c/mypy-2.3.1-cp313-cp313-win_arm64.whl", hash = "sha256:77ad9529e67dca28e511f5cd5671436584ce91f6d3bac159a353158187b986ac", size = 10213219, upload-time = "2026-08-15T03:02:26.361Z" }, + { url = "https://files.pythonhosted.org/packages/be/c4/42a49d44aeff804edf1b19acce0b49e8bd1a9c57dee9605dd8d980aa43d7/mypy-2.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:192abaedf75da1bc0b1cef104927e70ec49c1ef0031cc4825c7ee10a438ed24d", size = 13986778, upload-time = "2026-08-15T03:01:33.69Z" }, + { url = "https://files.pythonhosted.org/packages/45/13/9331fd2dfed7194d66c5304072894a8be3e51e9deda6863c1eceaa35a43d/mypy-2.3.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf678dffd16efcda2c15cbd30e9ecc0081388e29ea23687a88e686ed92638dc3", size = 14188467, upload-time = "2026-08-15T03:02:40.554Z" }, + { url = "https://files.pythonhosted.org/packages/78/f7/f4a34edab45667c5465855dc585a20e87978ffa8aee711445b7239d120c6/mypy-2.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e036f06b41630f4c8a1d48f9ac6aa26acc65f8be089973f5519da643318f03f", size = 15225538, upload-time = "2026-08-15T03:03:09.761Z" }, + { url = "https://files.pythonhosted.org/packages/40/05/534b3590757bd05794f73e07f6666c2a77b8597ffed795c94ce570096aa0/mypy-2.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71af9c8a894e862b58e92abb08e53b05a384a1e5e5d6dc7cda59126211a53d82", size = 15480805, upload-time = "2026-08-15T03:01:41.134Z" }, + { url = "https://files.pythonhosted.org/packages/55/da/bdfba852e2562f599624af5bb7d29e36b0b4f526f2b8bac85efe0dd1803d/mypy-2.3.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:3c80cd23d85368bdd9f37d5231dfd97d35bcbf5bf41af96ef3a9b078ad1957f9", size = 7761712, upload-time = "2026-08-15T03:02:36.008Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/60fc64a74cdba4f2a5d642d32317993e479163e1ac7d91b695e5d15e2264/mypy-2.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:4956f34d145e145562a0a0bf367f642bbc85c04ec2baf47ae015947c3169a85d", size = 11423968, upload-time = "2026-08-15T03:02:06.931Z" }, + { url = "https://files.pythonhosted.org/packages/a9/23/eb5950b24cd26ba3b78f87707a275568d633c77dae8e61c9661be6055ca6/mypy-2.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:cfb12e360242d23d91f5e978d94f58ea66acf5804c4fb6f2f794a20d4cb1b595", size = 10399323, upload-time = "2026-08-15T03:02:33.671Z" }, + { url = "https://files.pythonhosted.org/packages/82/c7/f80f4e46c0b9a00eb5f78a79d49dda8bdf56a5230f7257fb33e76be04da7/mypy-2.3.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5f1c50bb05b64e2026b52867e8d21106f01313c744a2c4ecc34c90d12e8d6e2", size = 15121308, upload-time = "2026-08-15T03:01:46.053Z" }, + { url = "https://files.pythonhosted.org/packages/5d/74/9b04f17c7074cc5188f02fb63a2ca1d43fedf479e84fe3091c39061a1d7f/mypy-2.3.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:667196b352f4cf304ded4c10f90cfc179263a1acfb3cdcfa984bdfd340d498bc", size = 15536590, upload-time = "2026-08-15T03:01:35.941Z" }, + { url = "https://files.pythonhosted.org/packages/26/04/c837ef6208e567774e2ed1f863f8ba6ec4817b1b6dd426315e5d559b6ec9/mypy-2.3.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9c53e395c12cad2c6d4b67d5da7c6057638a132d85c08b73646b18f802a0045", size = 16791074, upload-time = "2026-08-15T03:01:31.073Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/48730230afa45192d5bd429a6a2ff24a6f8dedda90fdf2b221792b54518f/mypy-2.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:18162b128c3f9c703cd35f5537446900b0d21a2549aa7a95d21380d2ef643fb0", size = 17069183, upload-time = "2026-08-15T03:02:28.566Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ea/ca23fc9c20eeda09a15c9cbcf50015d0e73f409f6ead059e42aa69a608ff/mypy-2.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:30c0477d4aab7b7f39c8397dc877f2c96b9fe5588ec379f372c56eb63d599f63", size = 12154679, upload-time = "2026-08-15T03:02:04.809Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/8d982126034990869466f73b8db80dcb2234a7ac39b4dad093e047a79835/mypy-2.3.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6941ab3619377bc3f32ca02876b07d27f216f5201604b664d3937ea0fdd23bb4", size = 10969159, upload-time = "2026-08-15T03:02:38.152Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f7/41e7f2d8117fbc7a7587286162ffe2f688984b69c46ed63cf5f2e4fc3bae/mypy-2.3.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:6f041a6de52c9217ca125e78ba0a335cb7fd98a1c0580978e49ab2b126f70b57", size = 13990694, upload-time = "2026-08-15T03:03:21.919Z" }, + { url = "https://files.pythonhosted.org/packages/06/85/8f665811a0c8f3bf6fa1d9acd665ec2d97a2bcc453ae68dcd92340941cd6/mypy-2.3.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5159ae60f5dbc3a498af5ba8365505808ac8031bc63f9e00304ad545d40bdd9b", size = 14203518, upload-time = "2026-08-15T03:01:48.455Z" }, + { url = "https://files.pythonhosted.org/packages/2d/82/91b866c8546b120bff83b73a439d90d2d63ef3aff113599e6b8e4d566848/mypy-2.3.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47a8a7a0a7f6f6e63995c0ac36fa0c07b127413fdc81f0439b7f3dccafd33561", size = 15220224, upload-time = "2026-08-15T03:01:23.577Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/c226c99208ee40de7c768369fa533f933afa003dfdc606ff021450724e91/mypy-2.3.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:2329c0501293d4e1f33bc15d04d6304d65a1cdda967ee93a05c1e681a3923133", size = 15501512, upload-time = "2026-08-15T03:02:09.453Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e7/7cfb3f106c393979f4cc37ad6c0586044d50401e3c35b0c003e4f3ba6bc9/mypy-2.3.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:bb26deed807bdb0457cf3e3f1cd7c4a1cf9d66864eaf1b4a61e06805d4c6b1f9", size = 7761913, upload-time = "2026-08-15T03:01:55.65Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/52affefa273b97939a1f474ae4a349c8718635c15b941112dfab4291b0c1/mypy-2.3.1-cp315-cp315-win_amd64.whl", hash = "sha256:375d7013876a8233b2d05be185bfa09f689696cd999ce8b1cfe6acac5c80e8a3", size = 11422533, upload-time = "2026-08-15T03:03:24.101Z" }, + { url = "https://files.pythonhosted.org/packages/2a/b7/75643e70c72a5b346d8a9b1543c967ea8824df2ee3fb7ccba652c272b7bb/mypy-2.3.1-cp315-cp315-win_arm64.whl", hash = "sha256:586b3612214cceabb3c0f588c97e7d1e535393f06a60e912e994f6b3ace97523", size = 10397931, upload-time = "2026-08-15T03:02:55.265Z" }, + { url = "https://files.pythonhosted.org/packages/10/ce/53be21f2d4adfcd26f63f1184a13ed797015ab463853f117e2e11e4d726f/mypy-2.3.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:ef0c6335cda9d807f8193d8ff6204a72bc909fa9882aacbca14f43cdb7188306", size = 15118669, upload-time = "2026-08-15T03:02:51.479Z" }, + { url = "https://files.pythonhosted.org/packages/62/43/20de757cd42989d291a17fad607742c4c74e875ce5cea00e5a5225020ac1/mypy-2.3.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e598c8c66401d26b150872154a286e6d484cf2789c3bb28a7556806298423021", size = 15545627, upload-time = "2026-08-15T03:03:05.132Z" }, + { url = "https://files.pythonhosted.org/packages/7e/fc/092bdf77ad280eaf501422f0f3b966012b528076cc13e41a774861c907d1/mypy-2.3.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eda22fd4efa9dcd39331d1dede9b5b8b8a7fd69af07592e778433da98610d29e", size = 16764157, upload-time = "2026-08-15T03:02:23.958Z" }, + { url = "https://files.pythonhosted.org/packages/94/5c/c94c4d62d909b07f552d0d9356d7acc943825558e602a64822ffa2231536/mypy-2.3.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:2a0ba2e57847849fb0d1fcdabb32786d223095ed8bc121dfe322bcdb3d9c46bc", size = 17073258, upload-time = "2026-08-15T03:02:14.573Z" }, + { url = "https://files.pythonhosted.org/packages/c0/f7/511a88b89e478053c02d22039bb8f3ce4183efe8fd7a4f0a5910a8bb0a32/mypy-2.3.1-cp315-cp315t-win_amd64.whl", hash = "sha256:3f7e865dd51f235f60a2dbcd8728a1c095f5ca28f095d48a725b84cd935735c4", size = 12135505, upload-time = "2026-08-15T03:02:16.714Z" }, + { url = "https://files.pythonhosted.org/packages/71/bf/02573b56964ecb0f7c644f915f53c325ae15c3faec521c5adf11599a32df/mypy-2.3.1-cp315-cp315t-win_arm64.whl", hash = "sha256:8ad80807dc3ab8ea978b1b2b6e4a657194ace1d4ef03e0e731aff1abd517da29", size = 10962647, upload-time = "2026-08-15T03:01:43.712Z" }, + { url = "https://files.pythonhosted.org/packages/8e/41/9675c7a1e78edecfba0b79e587a52594c56e189368261dc7b3a7fffb9527/mypy-2.3.1-py3-none-any.whl", hash = "sha256:6ed5c7e3419083268e5c9258bd1c1ef91af44a9e89374dbcaf37b775716e72eb", size = 2754338, upload-time = "2026-08-15T03:02:53.4Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "packageurl-python" +version = "0.17.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, +] + +[[package]] +name = "pip" +version = "26.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, +] + +[[package]] +name = "pip-api" +version = "0.0.34" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pip" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, +] + +[[package]] +name = "pip-audit" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachecontrol", extra = ["filecache"] }, + { name = "cyclonedx-python-lib" }, + { name = "packaging" }, + { name = "pip-api" }, + { name = "pip-requirements-parser" }, + { name = "platformdirs" }, + { name = "requests" }, + { name = "rich" }, + { name = "tomli" }, + { name = "tomli-w" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, +] + +[[package]] +name = "pip-requirements-parser" +version = "32.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pyparsing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/b7/802a56eca9f2fac455b8bab5375a2647b0f0e14a2cd63ef077de3c4a7658/platformdirs-4.11.7.tar.gz", hash = "sha256:4f41487eeeeeb07f3a6625e61d9bc0ae6809f92d3386dbd74392fbb76108104d", size = 35127, upload-time = "2026-09-01T13:35:10.502Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/6e/80993e10a0482f630cef528635789233224f36b1ffd11592aa15d13ff9ce/platformdirs-4.11.7-py3-none-any.whl", hash = "sha256:8a02cb259042c79d1cd0450facc2fe6dc9d303ae7901afbe33bf8ea0b188cef6", size = 23938, upload-time = "2026-09-01T13:35:09.02Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + +[[package]] +name = "psutil" +version = "7.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/c6/d1ddf4abb55e93cebc4f2ed8b5d6dbad109ecb8d63748dd2b20ab5e57ebe/psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372", size = 493740, upload-time = "2026-01-28T18:14:54.428Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/08/510cbdb69c25a96f4ae523f733cdc963ae654904e8db864c07585ef99875/psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b", size = 130595, upload-time = "2026-01-28T18:14:57.293Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f5/97baea3fe7a5a9af7436301f85490905379b1c6f2dd51fe3ecf24b4c5fbf/psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea", size = 131082, upload-time = "2026-01-28T18:14:59.732Z" }, + { url = "https://files.pythonhosted.org/packages/37/d6/246513fbf9fa174af531f28412297dd05241d97a75911ac8febefa1a53c6/psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63", size = 181476, upload-time = "2026-01-28T18:15:01.884Z" }, + { url = "https://files.pythonhosted.org/packages/b8/b5/9182c9af3836cca61696dabe4fd1304e17bc56cb62f17439e1154f225dd3/psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312", size = 184062, upload-time = "2026-01-28T18:15:04.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/ba/0756dca669f5a9300d0cbcbfae9a4c30e446dfc7440ffe43ded5724bfd93/psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b", size = 139893, upload-time = "2026-01-28T18:15:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/1c/61/8fa0e26f33623b49949346de05ec1ddaad02ed8ba64af45f40a147dbfa97/psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9", size = 135589, upload-time = "2026-01-28T18:15:08.03Z" }, + { url = "https://files.pythonhosted.org/packages/81/69/ef179ab5ca24f32acc1dac0c247fd6a13b501fd5534dbae0e05a1c48b66d/psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00", size = 130664, upload-time = "2026-01-28T18:15:09.469Z" }, + { url = "https://files.pythonhosted.org/packages/7b/64/665248b557a236d3fa9efc378d60d95ef56dd0a490c2cd37dafc7660d4a9/psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9", size = 131087, upload-time = "2026-01-28T18:15:11.724Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2e/e6782744700d6759ebce3043dcfa661fb61e2fb752b91cdeae9af12c2178/psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a", size = 182383, upload-time = "2026-01-28T18:15:13.445Z" }, + { url = "https://files.pythonhosted.org/packages/57/49/0a41cefd10cb7505cdc04dab3eacf24c0c2cb158a998b8c7b1d27ee2c1f5/psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf", size = 185210, upload-time = "2026-01-28T18:15:16.002Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/ff9bfb544f283ba5f83ba725a3c5fec6d6b10b8f27ac1dc641c473dc390d/psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1", size = 141228, upload-time = "2026-01-28T18:15:18.385Z" }, + { url = "https://files.pythonhosted.org/packages/f2/fc/f8d9c31db14fcec13748d373e668bc3bed94d9077dbc17fb0eebc073233c/psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841", size = 136284, upload-time = "2026-01-28T18:15:19.912Z" }, + { url = "https://files.pythonhosted.org/packages/e7/36/5ee6e05c9bd427237b11b3937ad82bb8ad2752d72c6969314590dd0c2f6e/psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486", size = 129090, upload-time = "2026-01-28T18:15:22.168Z" }, + { url = "https://files.pythonhosted.org/packages/80/c4/f5af4c1ca8c1eeb2e92ccca14ce8effdeec651d5ab6053c589b074eda6e1/psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979", size = 129859, upload-time = "2026-01-28T18:15:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/b5/70/5d8df3b09e25bce090399cf48e452d25c935ab72dad19406c77f4e828045/psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9", size = 155560, upload-time = "2026-01-28T18:15:25.976Z" }, + { url = "https://files.pythonhosted.org/packages/63/65/37648c0c158dc222aba51c089eb3bdfa238e621674dc42d48706e639204f/psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e", size = 156997, upload-time = "2026-01-28T18:15:27.794Z" }, + { url = "https://files.pythonhosted.org/packages/8e/13/125093eadae863ce03c6ffdbae9929430d116a246ef69866dad94da3bfbc/psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8", size = 148972, upload-time = "2026-01-28T18:15:29.342Z" }, + { url = "https://files.pythonhosted.org/packages/04/78/0acd37ca84ce3ddffaa92ef0f571e073faa6d8ff1f0559ab1272188ea2be/psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc", size = 148266, upload-time = "2026-01-28T18:15:31.597Z" }, + { url = "https://files.pythonhosted.org/packages/b4/90/e2159492b5426be0c1fef7acba807a03511f97c5f86b3caeda6ad92351a7/psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988", size = 137737, upload-time = "2026-01-28T18:15:33.849Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, +] + +[[package]] +name = "py-serializable" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, +] + +[[package]] +name = "pyaes" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/44/66/2c17bae31c906613795711fc78045c285048168919ace2220daa372c7d72/pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f", size = 28536, upload-time = "2017-09-20T21:17:54.23Z" } + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pymediainfo-pyrofork" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/43/ebfd048e84bb264bb133d545312e35b49638bcd7d5ad973c023e0026a36b/pymediainfo_pyrofork-6.0.2.tar.gz", hash = "sha256:fce9402edfd1fa09aba7b3cac4c41ba7fcf6820e561b4db4f9c1a1a68c487c36", size = 446514, upload-time = "2024-10-08T14:31:39.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/16/7c2b2f969e84e5f196809c10da6c847c505fb722b9d636bf6f6bf8f2e919/pymediainfo_pyrofork-6.0.2-py2.py3-none-any.whl", hash = "sha256:674fa8e53de861635b9dc4f77c2ad712306a798bf28864952503bf328210c4c3", size = 9356, upload-time = "2024-10-08T14:31:37.054Z" }, +] + +[[package]] +name = "pymongo" +version = "4.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/0a/d4daac76f66c466c20f00215064d43f4eb4b0aba8f2d8ecabdcc13102bc5/pymongo-4.18.0.tar.gz", hash = "sha256:6f33cd2033e8ea216d3069b5ebea79ded74ef2a93f69a93b26cf310082f9b5b9", size = 2741743, upload-time = "2026-09-03T16:01:06.074Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b1/6602487d94f6aef7677b18fe149bb0693fa209acf3b339a44ad8bc4060b2/pymongo-4.18.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:29c60dce70a300a09c0af39c2dbebd1a0199db9eb4d3ef03a4eeb587e7675668", size = 818600, upload-time = "2026-09-03T15:59:43.753Z" }, + { url = "https://files.pythonhosted.org/packages/ca/eb/c5beaf94967ef5c9aaf9db65fbe1d82337f7cf9c6b6df6c0e8d4c70f9e13/pymongo-4.18.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0d821ca5865e88c2dc29e259a191d770a53093c6749c6b228f18b078cb598faf", size = 818942, upload-time = "2026-09-03T15:59:45.515Z" }, + { url = "https://files.pythonhosted.org/packages/59/18/3a19ece4d253e6be1d5e293701c2948330927a7e91d19372e9c4a04e2139/pymongo-4.18.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a61148df54b254b4157aaba2139cb8fc97b8232281d783e0b7b2d58cb000fcb", size = 1039154, upload-time = "2026-09-03T15:59:47.222Z" }, + { url = "https://files.pythonhosted.org/packages/50/95/944159a0c4bd68c40f3a96c617d3d1815768e23cd0cb0b730827c7a9115b/pymongo-4.18.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eda647930ecf0419fb3cecc448c40020e3871adbae523b302d15d2c49d22c866", size = 1050185, upload-time = "2026-09-03T15:59:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/3f/90/70faae774a27545f6e9d085877a1acc884e48b0e7afadf51d2f17eab15ae/pymongo-4.18.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04766d0930bc06dc99e3274a800fb81e85f2629f27ba7bc326e2d4d13ea449d5", size = 1074933, upload-time = "2026-09-03T15:59:51.246Z" }, + { url = "https://files.pythonhosted.org/packages/08/e1/fb0807668b8ffc0ffbe92786cb0ed6c10e70c32be5123fd49ff4ff2006e2/pymongo-4.18.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:368cd67dd1d3ead5836c20457b84d674256f27b3e99c332d085081680c8465b9", size = 1067686, upload-time = "2026-09-03T15:59:52.951Z" }, + { url = "https://files.pythonhosted.org/packages/bd/dc/7c620995d4dff3eb1fdf62236844f3cb67d3042ed1a6d4f4059ef5dc79a6/pymongo-4.18.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9b8e25f672f5b2b30c6b834e162454e721cf1f782e753f0e9ea3bca225900f3", size = 1049459, upload-time = "2026-09-03T15:59:54.958Z" }, + { url = "https://files.pythonhosted.org/packages/e7/25/a317ab554f604ce30fa778af82264627a7784c4a750c3d8459589f751cf4/pymongo-4.18.0-cp313-cp313-win32.whl", hash = "sha256:f15feb666fa56a43e4b318471eff7fba6facbfc3b2555185a0fef871f6b9ccf1", size = 814841, upload-time = "2026-09-03T15:59:56.657Z" }, + { url = "https://files.pythonhosted.org/packages/6e/58/a2a4b209a3066db88d82c9b44e5b75286aa5a00172dbca357af20bb8b1e4/pymongo-4.18.0-cp313-cp313-win_amd64.whl", hash = "sha256:a2d96ad52eca16939564cbae9c91ffa92a9705ad46cb8d5de26b95dba0d40793", size = 820431, upload-time = "2026-09-03T15:59:58.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/fa/8cd86494c9001f8e5331f51384b187962696bac3188fcbe05f7edf8dceed/pymongo-4.18.0-cp313-cp313-win_arm64.whl", hash = "sha256:b230196ea62fc4542d9d6b78ddd9dbf0c9437ac2fde5810f7305377ae50fed11", size = 815328, upload-time = "2026-09-03T16:00:01.029Z" }, + { url = "https://files.pythonhosted.org/packages/db/86/6c79e21ddad4a9165f5023c723220f23c68012949efaa89a02d13a684bff/pymongo-4.18.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:4343c06f00fafdd8c8d73521ed8b4c468aa74e1b8f65cd757fb5f6ac3df685b3", size = 818495, upload-time = "2026-09-03T16:00:02.951Z" }, + { url = "https://files.pythonhosted.org/packages/62/25/5b783fa8d6cb08d9590e56969accab39194e801f35a174c77d494dcefb94/pymongo-4.18.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:277e61864ad6a064d75d7efbf5ce0e57378c420e48af6b7bad63e8356dcb22e1", size = 819063, upload-time = "2026-09-03T16:00:04.866Z" }, + { url = "https://files.pythonhosted.org/packages/ad/19/f671bdb2533ef47ba49bd1cd2a65c16c2e5540abd9f48f0c8661a7243c56/pymongo-4.18.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b43545a785e4054db2f712ae7d2a640874500a2352e705dbe49c8a1b90de87f2", size = 1040923, upload-time = "2026-09-03T16:00:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/b0/36/3c4d10c334f76dcd135313ca21d3a902f13a3e46438b36bbea9413ab7fbf/pymongo-4.18.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34f3c9adcc26dbfdc50cbb27da5c369588e6862241ea5cf96d33139f57fd9e0b", size = 1050899, upload-time = "2026-09-03T16:00:08.976Z" }, + { url = "https://files.pythonhosted.org/packages/76/80/f7f686185a7e386dd1d9ad4bde0353a5150b7a1129957beed764bcf900fa/pymongo-4.18.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cd7577d4f28c882b42b356f86f7cf7566698beb347b75dcd742ffe5aac3c3462", size = 1074920, upload-time = "2026-09-03T16:00:11.099Z" }, + { url = "https://files.pythonhosted.org/packages/19/5e/072839ec02e1a11518120bcc81e59a60f2e3d3629e760f05e0a1c3b7344a/pymongo-4.18.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3c50998113e17737fc2048e0184eda8c3aef465acc5a554fb76e2b0266805278", size = 1064362, upload-time = "2026-09-03T16:00:13.05Z" }, + { url = "https://files.pythonhosted.org/packages/d4/24/6e56a573977a60047fb8bf3646eb5eb121c689678fce4eb2f6ddd5cfbe2b/pymongo-4.18.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b88f2dfa33e1680e8990b45036c7cdb79c5c5e0b1f08b2e5c942893d76853eb6", size = 1049153, upload-time = "2026-09-03T16:00:15.047Z" }, + { url = "https://files.pythonhosted.org/packages/8f/6b/bf0bc7eab0df3e7ef0669ceadbf25528f41fe304979335e49d79c4012515/pymongo-4.18.0-cp314-cp314-win32.whl", hash = "sha256:13b1ad2110fbc8ec151e996ae8ea22727db8b2bd48515141d76d4fb54bd07da3", size = 815986, upload-time = "2026-09-03T16:00:16.919Z" }, + { url = "https://files.pythonhosted.org/packages/14/9f/630977caf0b8022c8a6b2a55c242c7d264ce519bb5a68d8736de338866ce/pymongo-4.18.0-cp314-cp314-win_amd64.whl", hash = "sha256:e11e86c9a0f81d23cdd0d0a42a9312c888139d7d330ddd744973d79fec87630c", size = 821904, upload-time = "2026-09-03T16:00:18.921Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8a/8a6de9d310207a070ebbf78e93bcda6e1cf9cf137368b73efc2d2b83c704/pymongo-4.18.0-cp314-cp314-win_arm64.whl", hash = "sha256:4c6f85e33ef338148cd70bc682fc73bfd202a1cde554057d807905d2310767ad", size = 816438, upload-time = "2026-09-03T16:00:20.935Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/a2ae13bad8d503115ea4f900802c07e0cc5d897e60c5ab04fb6a36044afc/pymongo-4.18.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:03de6fdfbcd4ad0634313956bffcced13abc9e575b9c74dec70f69cc248b501c", size = 821472, upload-time = "2026-09-03T16:00:22.873Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ba/0600e7c3bdfc8fc9166ae25272751f2a47e436ad451e45e8d0152227e869/pymongo-4.18.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e8dd4a0c2bd52dd9f78d8808578d5f2ecf1b0fab41a4a169400c969a3e06ae65", size = 821909, upload-time = "2026-09-03T16:00:24.839Z" }, + { url = "https://files.pythonhosted.org/packages/27/4c/dc1121d8c949f50f010c15a521a424042322087c553c649b42c16ca15e6b/pymongo-4.18.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5a0e70d348d87e50406bc932f7998a40cc6cfdd4b0628dd0c00ff2470f07e3b1", size = 1105313, upload-time = "2026-09-03T16:00:26.919Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0e/37dc8e6cbb25777dfaadb478fee6d5672ebd14eec1ac74eafb8bcda8b2bd/pymongo-4.18.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee7ed67136ee8e69c53c657253dbfe19486edd9560a3d8363cc0ee3614b52297", size = 1125099, upload-time = "2026-09-03T16:00:29.079Z" }, + { url = "https://files.pythonhosted.org/packages/d7/15/0c5e21f6d257c5876632aea3b1a38440577f089d94811d757256e773d98d/pymongo-4.18.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abd0db088de3935b87aa27398d6fa98718c3a1d84e36ffbb927a353a9d76a032", size = 1144554, upload-time = "2026-09-03T16:00:31.121Z" }, + { url = "https://files.pythonhosted.org/packages/93/15/1d57e1a4f922de348ffd6595488f7855a46f7d2152a9ca991d0b878657d2/pymongo-4.18.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:721f9b1a378d5bbf1bc2b9de2b7d3eeb4466d2878c7f430d483af7e3f580c935", size = 1136309, upload-time = "2026-09-03T16:00:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/a8/37/74392c9eaeea549c25a7e0304116cdfde79b7320bd55d652a03914b53d10/pymongo-4.18.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d971ee533dbd7b836abec1e4ceefaeb9f6637c262561614482f213b8a19085be", size = 1117086, upload-time = "2026-09-03T16:00:35.314Z" }, + { url = "https://files.pythonhosted.org/packages/3b/3e/4dd06523d3784b9229059f8812cbed292ad36136adedf32db106796258db/pymongo-4.18.0-cp314-cp314t-win32.whl", hash = "sha256:996a87a5b9c048e3dddf497acde55c7955748572091c70e1424d3c4779171526", size = 818741, upload-time = "2026-09-03T16:00:37.27Z" }, + { url = "https://files.pythonhosted.org/packages/f5/d3/7f5ed7d37cd026b0d59b2504e4015b1370c79359f1e81ca5e3eb0428e8e1/pymongo-4.18.0-cp314-cp314t-win_amd64.whl", hash = "sha256:47627909a177036117b91a3378df8634dc8e93cf4ccd66b22086b0098d3c72de", size = 826081, upload-time = "2026-09-03T16:00:39.221Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b2606ddf52fa615d4ff3edb2aa05fb064337fa871f99ebb184a39e051cc8/pymongo-4.18.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4a81d166a43e8af1e5152b6854a263ba0a8831f7dd2ca1badc716f219f4f1bc0", size = 817605, upload-time = "2026-09-03T16:00:41.547Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyrofork" +version = "2.3.69" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyaes" }, + { name = "pymediainfo-pyrofork" }, + { name = "pysocks" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/86/da/c6e44522450483ca4a42130759a8dbc96e28c35e7ad041e16aca85c45756/pyrofork-2.3.69.tar.gz", hash = "sha256:945b30d50b31819a903749825e2748ac5a6af1e073bf97da8c53e510ff3ed58d", size = 506694, upload-time = "2025-12-10T18:35:57.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/ec/9395a0d3196a388a0ccd0994cf2113068b3740ab2615d8bbc1c12d762e42/pyrofork-2.3.69-py3-none-any.whl", hash = "sha256:13f7a7fbfa5ede230df6b6df10fcc2c6b33b4c3d75bf2088a7d32f41621df8e4", size = 5270720, upload-time = "2025-12-10T18:35:55.246Z" }, +] + +[[package]] +name = "pysocks" +version = "1.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/11/293dd436aea955d45fc4e8a35b6ae7270f5b8e00b53cf6c024c83b657a11/PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0", size = 284429, upload-time = "2019-09-20T02:07:35.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/59/b4572118e098ac8e46e399a1dd0f2d85403ce8bbaad9ec79373ed6badaf9/PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5", size = 16725, upload-time = "2019-09-20T02:06:22.938Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/53/ed9d74092561d4b01a2ef1349d52cdbc135e526c245f366b089cfca6de49/python_dotenv-1.2.3.tar.gz", hash = "sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35", size = 58945, upload-time = "2026-08-16T16:54:54.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "stevedore" +version = "5.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/a1/3b8ed9c1fc3aa6eebb57732d924ddaa0500ecc3b638d0454816320994383/stevedore-5.9.1.tar.gz", hash = "sha256:e97a2667923efda926e8713fde6a73616df68210a3cbc6f02b48967b676fd8bf", size = 518111, upload-time = "2026-08-20T15:25:14.754Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/97/bba6e7ec2f5498b9dcb7b1b6400086b80ae5a8ebaff4b25e8c8add75f439/stevedore-5.9.1-py3-none-any.whl", hash = "sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf", size = 54931, upload-time = "2026-08-20T15:25:13.602Z" }, +] + +[[package]] +name = "tgcrypto-pyrofork" +version = "1.2.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/cb/9b26818a3a815eb37a7839d0c8e160c3b7c4de770fdb5ee1ecce96048336/tgcrypto_pyrofork-1.2.8.tar.gz", hash = "sha256:106317b2c42cc5fcd7475a50647fee2da304076cdfcd2444f72d5254927b2afa", size = 37390, upload-time = "2025-10-25T04:05:38.457Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/59/f230684f3dca1da3a56c843de42fe64df709d41c55a33140ae9a1d3afcd4/tgcrypto_pyrofork-1.2.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f28c07ee6c0ef423b3ff14562881f3bbcb6614d5394f378526f32a109650e24", size = 62197, upload-time = "2025-10-25T03:57:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/f8/bb/0193ab5a6012172995fa96a009b79c2b786c0e63106f244ab4b7a9846bc6/tgcrypto_pyrofork-1.2.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d4886fa409c891e129c6ab439542e0b80b001b31bcefac63509340b0c691f73b", size = 60306, upload-time = "2025-10-24T09:25:31.058Z" }, + { url = "https://files.pythonhosted.org/packages/fa/3f/850ee3d6def1a07406efa5e951bf1e72a7dfd9ae3bc7bb8f1e3e44c57147/tgcrypto_pyrofork-1.2.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:36a71e5cbd14f3226803a2d05c0d3d43e0781565a895d40681ef82410398d950", size = 61711, upload-time = "2025-10-25T03:57:50.985Z" }, + { url = "https://files.pythonhosted.org/packages/53/55/100fde3d9a2215d25d469a36ae116d533a2455005d4fa83b9cb4caef49fc/tgcrypto_pyrofork-1.2.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1ff578ac8b54607e6d536f9593f78d8bea9b99f2e607ea4bd71b1b2a3a5f949c", size = 60528, upload-time = "2025-10-24T09:25:32.644Z" }, + { url = "https://files.pythonhosted.org/packages/97/bf/45480165ac318e7a230f1ea44c97f45007c1f4f60ab7121d4034f0b16ac7/tgcrypto_pyrofork-1.2.8-cp313-cp313-win32.whl", hash = "sha256:a8572c5c46c51352e294f7f68df2ed425756e25c08d0e2ef94e055ed243e3104", size = 45164, upload-time = "2025-10-24T09:25:35.927Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/15df88cdf00d71832c883cdd366ccfe7c6bf6dd7cc678ee42007b4b27bce/tgcrypto_pyrofork-1.2.8-cp313-cp313-win_amd64.whl", hash = "sha256:66792dfd71a90248cea9b855a40e9339686d19dc131134bd7ce4ec10b99a3509", size = 45920, upload-time = "2025-10-24T09:25:37.638Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b9/6de6a3c9b22a992b497287f25ca69f47886c5ff502b207f82b206ac40c11/tgcrypto_pyrofork-1.2.8-cp313-cp313-win_arm64.whl", hash = "sha256:8e1086bcf070a8bdae4e81d7732b1cc082b2f31c6fdea884336d4f98c93d7d82", size = 44762, upload-time = "2025-10-25T03:59:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/1d764432770162206bbbe916232dc3a3adc0cf2a1a9045be233b3c965471/tgcrypto_pyrofork-1.2.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:12d237eb8de98fa759df97bdb988dd6f60702c3376dbf16d2babdd2196bfb58a", size = 62377, upload-time = "2025-10-25T03:57:51.736Z" }, + { url = "https://files.pythonhosted.org/packages/5f/78/68a5af0e776b65e598f8926c0814d8aa418b424f16e2a611efdcc3ba3695/tgcrypto_pyrofork-1.2.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ae1a23ed300786e28e8d9c2024effba7efc732d99fd8a2db314c02c35355f01f", size = 60477, upload-time = "2025-10-24T09:25:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5c/8859b487bd68987d8d0b73a82d13796ef2213d428df2a2f0330605034c2c/tgcrypto_pyrofork-1.2.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9b1538e0c14d2aee1b9dc72cddfd7706d2e4ea768addecc3f12ff8d44f38d3e1", size = 61863, upload-time = "2025-10-25T03:57:52.762Z" }, + { url = "https://files.pythonhosted.org/packages/b4/65/3f26e9680e312ee4cae8639e37035944037280c9b03bb17c2204020b024f/tgcrypto_pyrofork-1.2.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fe3d75abef53bfbfa6e80dc6d25075f603f3ebe1dafa9d5c09b2780e0ea3a382", size = 60651, upload-time = "2025-10-24T09:25:35.269Z" }, + { url = "https://files.pythonhosted.org/packages/49/c3/bec17c976b0caca2ae90cab295ab50c4384036b0b3df1096c0eaa329eb06/tgcrypto_pyrofork-1.2.8-cp314-cp314-win32.whl", hash = "sha256:f17a4dd0197e0972f056242bc06f97d86e890f8e29bda69af3dbc6f25d40c33f", size = 47116, upload-time = "2025-10-24T09:25:39.203Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/a2ca8127e03c31793e9659ab02fb8aeaf4ff2481cfc77f05c1651b0f8b0c/tgcrypto_pyrofork-1.2.8-cp314-cp314-win_amd64.whl", hash = "sha256:8eaf42413eb7b2efae1122106803c26dc792f0ad6d98ed77d179950c979d0d35", size = 47880, upload-time = "2025-10-24T09:25:40.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f6/b3d0aa598f07c1ff64e55271eeca99bbfdd191ced97db652f0d279cb0294/tgcrypto_pyrofork-1.2.8-cp314-cp314-win_arm64.whl", hash = "sha256:1b202757b7711b642362baa32529c2a7896d4f259ae25df6a82d8f748a3d30b2", size = 46970, upload-time = "2025-10-25T03:59:28.711Z" }, + { url = "https://files.pythonhosted.org/packages/b0/37/596a1b5d92bd6a7f657840c76a8f4955db614b4c7fe162c9cdcb82aa67e2/tgcrypto_pyrofork-1.2.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82bd2e8f249eaef92132ce5a310c27844e9fbb43666e5bfbf6dd1872c1c2eda2", size = 62382, upload-time = "2025-10-25T03:57:53.863Z" }, + { url = "https://files.pythonhosted.org/packages/7a/44/5c3582787210840bd9e752a0e79d2c7a6b01339d4f11e243d2b79e003644/tgcrypto_pyrofork-1.2.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3441ec567f9411ffbe5182fd4fb8c49fbd12faccd71c50fc469b9784e15b04d", size = 60493, upload-time = "2025-10-24T09:25:36.657Z" }, + { url = "https://files.pythonhosted.org/packages/dc/03/59880402d13eff32ab1649d795ef6296a839c98d37e9b4e5c14f02b7de66/tgcrypto_pyrofork-1.2.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c50a8ddd8e5256528f8318bcafbe1f59f1cc1c300db0ee16ec49955baead861c", size = 61897, upload-time = "2025-10-25T03:57:54.677Z" }, + { url = "https://files.pythonhosted.org/packages/6f/50/8e944e574f2dd3155db54a17c260cb550953cd7ecbd677c4e3d128e36de3/tgcrypto_pyrofork-1.2.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:bc888db2675247a1e3d9040577e025d64b66b72702223030b0e18ed10037b99e", size = 60691, upload-time = "2025-10-24T09:25:38.274Z" }, + { url = "https://files.pythonhosted.org/packages/f8/cf/6fb83ac9e739cec63cad9700a96e6e0cffefe27a24b517bb1251b5378c20/tgcrypto_pyrofork-1.2.8-cp314-cp314t-win32.whl", hash = "sha256:cebd0cf96f27de50fedbbb836e459fb2d7d960ea1a454ac141ead0209d43bf5f", size = 47123, upload-time = "2025-10-24T09:25:41.175Z" }, + { url = "https://files.pythonhosted.org/packages/41/1b/7deb72797d4a1e5bd25fc362de16d0f1aa0e3fef5417f41fec1b6bdc40e7/tgcrypto_pyrofork-1.2.8-cp314-cp314t-win_amd64.whl", hash = "sha256:2e94273c733cba188b28b903eb10ed014eaeb454ccc9269e96c89d7f43d12ddf", size = 47884, upload-time = "2025-10-24T09:25:41.972Z" }, + { url = "https://files.pythonhosted.org/packages/35/b9/2fdf803f21c07f0efb21c316f74c4fbd1f6f32456695149e24128698967a/tgcrypto_pyrofork-1.2.8-cp314-cp314t-win_arm64.whl", hash = "sha256:ef8b75bb9516ca1990f2a0ccdf26a84f301381410cb0c63bc14959a70e895a8e", size = 46975, upload-time = "2025-10-25T03:59:29.957Z" }, +] + +[[package]] +name = "thunder-filetolink" +version = "2.2.0" +source = { virtual = "." } +dependencies = [ + { name = "aiohttp" }, + { name = "jinja2" }, + { name = "psutil" }, + { name = "pymongo" }, + { name = "pyrofork" }, + { name = "python-dotenv" }, + { name = "tgcrypto-pyrofork" }, + { name = "uvloop" }, +] + +[package.optional-dependencies] +shortener-cf = [ + { name = "curl-cffi" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "mypy" }, + { name = "pip-audit" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "vulture" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiohttp", specifier = "==3.14.3" }, + { name = "curl-cffi", marker = "extra == 'shortener-cf'", specifier = ">=0.7" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "psutil", specifier = "==7.2.2" }, + { name = "pymongo", specifier = "==4.18.0" }, + { name = "pyrofork", specifier = "==2.3.69" }, + { name = "python-dotenv", specifier = "==1.2.3" }, + { name = "tgcrypto-pyrofork", specifier = "==1.2.8" }, + { name = "uvloop", specifier = "==0.22.1" }, +] +provides-extras = ["shortener-cf"] + +[package.metadata.requires-dev] +dev = [ + { name = "bandit", specifier = ">=1.8" }, + { name = "mypy", specifier = ">=1.13" }, + { name = "pip-audit", specifier = ">=2.7" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = ">=0.8" }, + { name = "vulture", specifier = ">=2.14" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "vulture" +version = "2.16" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] From 11c75c6ae787870921d75e9b3a300e9ce6693463 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 12:23:48 +0000 Subject: [PATCH 08/49] audit: fix 30+ verified defects from 8-agent branch review CRITICAL - rate_limiter: deferred requeue busy-spun with ZERO yield points (Event.wait fast-path + uncontended Lock on py3.13) -> whole event loop froze up to 300s; now parks the pool (event clear + call_later timer), re-wakes on enqueue, timer cancelled at shutdown - quality.yml: invalid YAML (unquoted ':' in step name) killed every gate; Makefile recipes had spaces not tabs (all targets dead) HIGH - callbacks: close_panel compared clicker to the BOT's id -> every non-owner Close button rejected; now allows panel reply target / private-chat peer (+ owner) - ban gate was fail-OPEN: is_user_banned swallowed Mongo outages into None (negative-cached 5 min) -> raise_on_error=True for the flag loader; ban/unban now invalidate the flag cache - HTML injection (M7 gaps): file_name in link messages, first_name in new-user log, chat_title/source_info in 5 stream.py sites escaped - custom_dl: mid-stream FloodWait restarted from the original offset, re-sending delivered bytes (corrupted downloads); now resumes at chunk_offset + chunks_done; get_message via bounded tg_call - stream_routes: Range end >= file_size now clamps per RFC 7233 (was a hard 416, breaking resume clients) - integration tier was dead twice over: wrong testcontainers import + missing dep + unit cov threshold; testcontainers[mongodb] declared, cov flags moved out of addopts - access-log redaction hashed m.group(0)[:-len(m.group(0))] == "" (constant pseudonym for every file) - CI now installs the hash-pinned uv.lock (uv sync --frozen) and audits the locked env incl. transitives; permissions hardened MEDIUM - touches added during an in-flight flush are re-armed; failed bulk-flush merges back instead of dropping increments - canonical validation RPC errors keep the cached record instead of re-copying into BIN (vault churn) - broadcast: strong task refs, return_exceptions, finally-cleanup of workers/status/registry; completion only on success - shortener: init lock (session leak), singleflight resolves waiters on cancellation, Bitly host validation, session closed at shutdown - requeued requests no longer re-charge the user window; breaker token consumed only at exec; cancelled workers release counts - flusher lifecycle, force_channel negative-cache, token consume expiry guard, tokens -> aware UTC, update.py missing return + credential redaction + orphaned config backup recovery, startup teardown order (touch drain before db.close), umask 077, PRIVATE_MODE x TOKEN_ENABLED fail-fast, preflight unknown-gate warning, /log off-loop, /listauth batched, curl_cffi floor 0.15.0 (CVE-2026-33752) LEANNESS - dead _cache_by_message_id map + get_file_by_message_id removed; Var.LOG_LEVEL/LOG_FORMAT dead attrs removed; dead skip param and RPCError branch removed; help panel reuses build_help_text; README /speedtest residue removed; dep-count gate added to make audit Tests: 107 passed (+4 regression: Range clamp, singleflight, park x2); ruff/mypy/vulture/bandit/pip-audit/uv-lock gates all green. --- .github/dependabot.yml | 4 + .github/workflows/quality.yml | 38 ++++--- AGENTS.md | 8 +- Dockerfile | 5 +- Makefile | 31 +++--- README.md | 3 - Thunder/__main__.py | 26 ++++- Thunder/bot/plugins/admin.py | 46 +++++---- Thunder/bot/plugins/callbacks.py | 31 +++--- Thunder/bot/plugins/common.py | 8 +- Thunder/bot/plugins/stream.py | 58 +++++++---- Thunder/server/__init__.py | 4 +- Thunder/server/stream_routes.py | 9 +- Thunder/utils/bot_utils.py | 14 ++- Thunder/utils/broadcast.py | 70 ++++++++----- Thunder/utils/canonical_files.py | 41 ++++---- Thunder/utils/commands.py | 5 +- Thunder/utils/custom_dl.py | 33 ++++-- Thunder/utils/database.py | 14 ++- Thunder/utils/decorators.py | 32 +++--- Thunder/utils/flag_cache.py | 21 +++- Thunder/utils/force_channel.py | 19 +++- Thunder/utils/messages.py | 10 ++ Thunder/utils/rate_limiter.py | 89 +++++++++++++--- Thunder/utils/shortener.py | 94 ++++++++++------- Thunder/utils/tokens.py | 42 ++++---- Thunder/vars.py | 14 ++- config_sample.env | 7 ++ pyproject.toml | 12 ++- tests/integration/test_mongo.py | 8 +- tests/test_unit/test_flag_cache.py | 19 ++++ tests/test_unit/test_rate_limiter_park.py | 71 +++++++++++++ tests/test_unit/test_stream_routes.py | 8 ++ update.py | 25 ++++- uv.lock | 119 +++++++++++++++++++++- 35 files changed, 775 insertions(+), 263 deletions(-) create mode 100644 tests/test_unit/test_rate_limiter_park.py diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 563cc5c..a9c78f9 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -8,3 +8,7 @@ updates: directory: "/" schedule: interval: weekly + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 51aa63a..2d32fa2 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -4,6 +4,10 @@ on: push: branches: [main] pull_request: + workflow_dispatch: + +permissions: + contents: read jobs: quality: @@ -14,36 +18,40 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.13" - cache: pip - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pytest pytest-asyncio pytest-cov ruff mypy bandit vulture pip-audit + - name: Install uv + run: python -m pip install --upgrade uv + + # Install the hash-pinned lockfile (runtime + dev tools), so CI tests + # and audits the exact dependency graph that ships -- not a fresh + # pip resolution that can drift from uv.lock. + - name: Install dependencies (locked, hash-pinned) + run: uv sync --frozen --group dev - name: Lockfile is current with pyproject (uv.lock must never drift) run: uv lock --check - name: Ruff (lint + format check) run: | - ruff check Thunder/ update.py - ruff format --check Thunder/ update.py + uv run ruff check Thunder/ update.py + uv run ruff format --check Thunder/ update.py - - name: Mypy (blocking since the Sep 2026 quality pass: 44 errors fixed, 0 remain) - run: mypy Thunder --ignore-missing-imports + - name: Mypy (blocking) + run: uv run mypy Thunder --ignore-missing-imports - name: Unit tests - run: pytest -m unit + run: uv run pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 - - name: pip-audit - run: pip-audit -r requirements.txt + # Environment audit: includes transitive deps from the locked env, + # which `-r requirements.txt` (direct pins only) never covered. + - name: pip-audit (locked environment incl. transitives) + run: uv run pip-audit - name: Bandit (medium+ severity) - run: bandit -r Thunder -ll --skip B101 + run: uv run bandit -r Thunder -ll --skip B101 - name: Vulture (dead-code gate, whitelisted) - run: vulture Thunder whitelist.py --min-confidence 80 + run: uv run vulture Thunder whitelist.py --min-confidence 80 - name: Dependency count gate (leanness is permanent) run: | diff --git a/AGENTS.md b/AGENTS.md index b18f04f..2dad6c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,9 +41,11 @@ make audit # pip-audit + bandit + vulture + dependency-count ## Test tiers -- **Unit** (default, every PR): `pytest -m unit` β€” pure logic only. -- **Integration** (opt-in, needs Docker): `TEST_INTEGRATION=1 pytest -m integration` - β€” testcontainers MongoDB; ingest-claim locks, token-activation atomicity. +- **Unit** (default, every PR): `pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35` + β€” pure logic only. +- **Integration** (opt-in, needs Docker): `TEST_INTEGRATION=1 uv run pytest -m integration` + β€” testcontainers MongoDB (`testcontainers[mongodb]`, declared in the dev + group); ingest-claim locks, token-activation atomicity. - Characterization tests pin behavior before refactors; update them deliberately inside the PR that changes behavior. diff --git a/Dockerfile b/Dockerfile index 5a1fd30..3ebe706 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,8 +24,9 @@ COPY --chown=thunder:thunder . . # L8: run as non-root USER thunder -# L8: container health follows /health (M3) +# L8: container health follows /health (M3); PORT may come from config.env +# (dotenv does not override pre-set env vars, so the check reads both) HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ - CMD python3 -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:'+os.getenv('PORT','8080')+'/health', timeout=5)" + CMD python3 -c "import os,urllib.request; port=os.getenv('PORT','8080'); port=[l.split('=',1)[1].strip().strip(chr(34)).strip(chr(39)) for l in (open('config.env').read().splitlines() if os.path.exists('config.env') else []) if l.strip().startswith('PORT') and '=' in l] or [port]; urllib.request.urlopen('http://127.0.0.1:'+port[0]+'/health', timeout=5)" CMD ["bash", "thunder.sh"] diff --git a/Makefile b/Makefile index ebb20dc..43535b9 100644 --- a/Makefile +++ b/Makefile @@ -1,28 +1,35 @@ -.PHONY: format lint test audit coverage run clean +.PHONY: format lint test coverage audit run clean # L8: developer entry points (see CONTRIBUTING.md) +# NOTE: recipes MUST be indented with hard TABs, not spaces. format: - ruff check Thunder/ update.py --fix - ruff format Thunder/ update.py + ruff check Thunder/ update.py --fix + ruff format Thunder/ update.py lint: - ruff check Thunder/ update.py - mypy Thunder --ignore-missing-imports + ruff check Thunder/ update.py + mypy Thunder --ignore-missing-imports test: - pytest -m unit + pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 coverage: - pytest -m unit --cov=Thunder --cov-report=html + pytest -m unit --cov=Thunder --cov-report=html audit: - pip-audit -r requirements.txt - bandit -r Thunder -ll --skip B101 - vulture Thunder whitelist.py --min-confidence 80 + pip-audit -r requirements.txt + bandit -r Thunder -ll --skip B101 + vulture Thunder whitelist.py --min-confidence 80 + @count=$$(grep -cE '^[a-zA-Z0-9_-]+==' requirements.txt); \ + echo "Direct runtime deps: $$count"; \ + if [ "$$count" -gt 8 ]; then \ + echo "ERROR: dependency count increased beyond 8; justify or remove."; \ + exit 1; \ + fi run: - python3 -m Thunder + python3 -m Thunder clean: - rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ + rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ diff --git a/README.md b/README.md index cfd8d98..29fb40d 100644 --- a/README.md +++ b/README.md @@ -190,7 +190,6 @@ Copy `config_sample.env` to `config.env` and fill in your values. | `/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. | @@ -218,7 +217,6 @@ 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 ``` @@ -272,7 +270,6 @@ Thunder implements a sophisticated multi-tier rate limiting system designed for Monitor server performance with built-in speed testing: ```bash -/speedtest ``` Features include download/upload speeds, latency measurements, and shareable result images for performance monitoring. diff --git a/Thunder/__main__.py b/Thunder/__main__.py index 66d60e4..79ea329 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -3,6 +3,7 @@ import asyncio import glob import importlib.util +import os import sys from datetime import datetime from pathlib import Path @@ -37,6 +38,7 @@ from Thunder.utils.messages import MSG_ADMIN_RESTART_DONE from Thunder.utils.rate_limiter import rate_limiter, start_executors from Thunder.utils.safe_call import tg_call +from Thunder.utils.shortener import close_shortener from Thunder.utils.tokens import cleanup_expired_tokens from Thunder.vars import Var @@ -194,12 +196,23 @@ async def start_services(): except Exception as e: logger.error(f" βœ– Failed to start Web Server: {e}", exc_info=True) - for task in locals().get("executor_tasks", []): - task.cancel() + tasks_to_cancel: list[asyncio.Task] = [] + tasks_to_cancel.extend(locals().get("executor_tasks", []) or []) + for name in ("limiter_sweeper_task", "flag_sweeper_task"): + t = locals().get(name) + if t is not None: + tasks_to_cancel.append(t) + for t in tasks_to_cancel: + t.cancel() + if tasks_to_cancel: + await asyncio.gather(*tasks_to_cancel, return_exceptions=True) + # mirror shutdown_services ordering: the touch buffer must flush + # BEFORE db.close, or _bulk_flush runs against a closed client and + # silently discards every pending increment await _safe_teardown_step(rate_limiter.shutdown, "rate limiter") + await _safe_teardown_step(drain_background_touch_tasks, "touch buffer") await _safe_teardown_step(cleanup_clients, "clients") await _safe_teardown_step(db.close, "database") - await _safe_teardown_step(drain_background_touch_tasks, "touch buffer") return elapsed_time = (datetime.now() - start_time).total_seconds() @@ -269,6 +282,7 @@ async def shutdown_services(background_tasks, app_runner) -> None: # 3. ordered teardown await _safe_teardown_step(rate_limiter.shutdown, "rate limiter", errors) await _safe_teardown_step(drain_background_touch_tasks, "touch buffer", errors) + await _safe_teardown_step(close_shortener, "shortener", errors) await _safe_teardown_step(cleanup_clients, "clients", errors) if app_runner is not None: @@ -320,9 +334,11 @@ async def schedule_limiter_sweep(): if __name__ == "__main__": + # L5: session files carry bearer-equivalent auth keys -- a restrictive + # umask covers the window between file creation and _harden_session_files + os.umask(0o077) try: - loop = asyncio.get_event_loop() - loop.run_until_complete(start_services()) + asyncio.run(start_services()) except KeyboardInterrupt: print("╔═══════════════════════════════════════════════════════════╗") print("β•‘ Bot stopped by user (CTRL+C) β•‘") diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py index d12b951..875bc12 100644 --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -6,17 +6,18 @@ import shutil import time from io import BytesIO +from typing import Any import psutil from pyrogram import filters from pyrogram.client import Client from pyrogram.enums import ParseMode from pyrogram.errors import MessageNotModified -from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message +from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, User from Thunder import StartTime, __version__ from Thunder.bot import StreamBot, multi_clients, work_loads -from Thunder.utils.bot_utils import get_user, reply +from Thunder.utils.bot_utils import reply from Thunder.utils.broadcast import broadcast_message from Thunder.utils.database import db from Thunder.utils.flag_cache import flags @@ -243,11 +244,15 @@ async def send_logs(client: Client, message: Message): try: # H10: never upload raw logs -- stream the (capped) tail through the # shared redaction regexes so bot tokens / Mongo URIs cannot leak. - with open(LOG_FILE, "rb") as f: - f.seek(0, os.SEEK_END) - size = f.tell() - f.seek(max(0, size - _LOG_TAIL_BYTES)) - payload = redact_secrets(f.read().decode("utf-8", errors="replace")) + # File IO + regex over megabytes must not run on the event loop (H8). + def _read_redacted_tail() -> str: + with open(LOG_FILE, "rb") as f: + f.seek(0, os.SEEK_END) + size = f.tell() + f.seek(max(0, size - _LOG_TAIL_BYTES)) + return redact_secrets(f.read().decode("utf-8", errors="replace")) + + payload = await asyncio.to_thread(_read_redacted_tail) doc = BytesIO(payload.encode("utf-8")) doc.name = "bot_redacted.txt" @@ -313,19 +318,26 @@ async def list_authorized_command(client: Client, message: Message): if not users: return await reply(message, text=MSG_NO_AUTH_USERS) - # M7: HTML + html.escape for user-controlled display names + # M7: HTML + html.escape for user-controlled display names. + # One batched get_users RPC instead of one per row (N+1 FloodWait risk). + id_to_user: dict[int, Any] = {} + try: + tg_users = await tg_call(client.get_users, [u["user_id"] for u in users], retries=1) + if isinstance(tg_users, User): + tg_users = [tg_users] + id_to_user = {u.id: u for u in (tg_users or []) if u} + except Exception: + logger.error("Failed to batch-fetch tg_users for /listauth", exc_info=True) + text = MSG_ADMIN_AUTH_LIST_HEADER for i, user in enumerate(users, 1): display_name = "Unknown" - try: - tg_user = await get_user(client, user["user_id"]) - if tg_user is not None: - raw_display_name = ( - f"@{tg_user.username}" if tg_user.username else tg_user.first_name or "Unknown" - ) - display_name = html.escape(raw_display_name) - except Exception: - logger.error("Failed to fetch tg_user for user_id=%s", user["user_id"], exc_info=True) + tg_user = id_to_user.get(user["user_id"]) + if tg_user is not None: + raw_display_name = ( + f"@{tg_user.username}" if tg_user.username else tg_user.first_name or "Unknown" + ) + display_name = html.escape(raw_display_name) text += MSG_AUTH_USER_INFO.format( i=i, diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py index 39233de..11a1c7b 100644 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -3,12 +3,12 @@ import functools import secrets -from pyrogram import Client, filters +from pyrogram import Client, enums, filters from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup from Thunder.bot import StreamBot -from Thunder.bot.registry import help_command_rows from Thunder.utils.broadcast import broadcast_ids +from Thunder.utils.commands import build_help_text from Thunder.utils.decorators import owner_only from Thunder.utils.logger import logger from Thunder.utils.messages import ( @@ -23,9 +23,6 @@ MSG_ERROR_BROADCAST_RESTART, MSG_ERROR_CALLBACK_UNSUPPORTED, MSG_ERROR_CLOSE_NOT_ALLOWED, - MSG_HELP_COMMANDS_HEADER, - MSG_HELP_INTRO, - MSG_HELP_TIPS, ) from Thunder.utils.safe_call import answer_safe, edit_safe, tg_call from Thunder.vars import Var @@ -99,12 +96,8 @@ async def help_callback(client: Client, callback_query: CallbackQuery): if force_button: buttons.append(force_button) buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) - help_text = ( - MSG_HELP_INTRO.format(max_files=Var.MAX_BATCH_FILES) - + MSG_HELP_COMMANDS_HEADER - + help_command_rows() - + MSG_HELP_TIPS - ) + # keep /help command and the help panel on one implementation (M1) + help_text = build_help_text(Var.MAX_BATCH_FILES) try: await edit_safe( callback_query.message, @@ -168,9 +161,21 @@ async def close_panel_callback(client: Client, callback_query: CallbackQuery): # button could trigger deletion attempts. closer_id = callback_query.from_user.id if callback_query.from_user else None message = callback_query.message - owner_id = getattr(message.from_user, "id", None) if message and message.from_user else None - is_allowed = closer_id == Var.OWNER_ID or (owner_id is not None and closer_id == owner_id) + is_allowed = False + if closer_id is not None and message is not None: + if closer_id == Var.OWNER_ID: + is_allowed = True + else: + # Panels are bot-sent, so message.from_user is the BOT -- comparing + # against it locked every non-owner out of their own Close button. + # The person who triggered the panel is its reply target (the + # command/queue message) or, in private chats, the chat peer. + if message.reply_to_message and message.reply_to_message.from_user: + is_allowed = closer_id == message.reply_to_message.from_user.id + if not is_allowed and message.chat and message.chat.type == enums.ChatType.PRIVATE: + is_allowed = closer_id == message.chat.id + if not is_allowed: await answer_safe(callback_query, MSG_ERROR_CLOSE_NOT_ALLOWED, show_alert=True) return diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py index 5bebb45..852d428 100644 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -96,7 +96,9 @@ async def start_command(bot: Client, msg: Message): return await reply_safe(msg, text=MSG_TOKEN_ACTIVATED.format(duration_hours=hours)) return await reply_safe(msg, text=MSG_TOKEN_INVALID) - txt = MSG_WELCOME.format(user_name=html.escape(user.first_name if user else "Unknown")) + txt = MSG_WELCOME.format( + user_name=html.escape(user.first_name or "Unknown") if user else "Unknown" + ) link, title = await get_force_info(bot) if link: txt += "\n\n" + MSG_COMMUNITY_CHANNEL.format(channel_title=html.escape(title or "Channel")) @@ -222,7 +224,9 @@ async def send_file_dc(msg: Message, file_msg: Message): @StreamBot.on_message(filters.command("dc")) async def dc_command(bot: Client, msg: Message): - # M12: full preflight chain for /dc (banned -> private -> token -> force-sub) + # Gate chain for /dc (banned -> private-mode -> force-sub). The token + # gate is intentionally NOT applied: /dc is informational, and applying + # it here would lock token-gated users out of diagnostics. if not await check_banned(bot, msg): return from Thunder.utils.decorators import check_private_mode diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index c2f094d..e112a5c 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -1,6 +1,7 @@ # Thunder/bot/plugins/stream.py import asyncio +import html import secrets import time from typing import Any @@ -119,7 +120,9 @@ async def send_channel_links( reply_to_message_id: int | None = None, ): text = MSG_NEW_FILE_REQUEST.format( - source_info=source_info, + # source_info (display name / chat title) is user-controlled and the + # template renders as HTML under pyrofork's DEFAULT parse mode (M7) + source_info=html.escape(source_info), id_=source_id, online_link=links["online_link"], stream_link=links["stream_link"], @@ -162,7 +165,9 @@ async def safe_delete_message(message: Message): async def send_dm_links(bot: Client, user_id: int, links: dict[str, Any], chat_title: str): try: dm_text = ( - MSG_DM_SINGLE_PREFIX.format(chat_title=chat_title) + "\n" + format_link_message(links) + MSG_DM_SINGLE_PREFIX.format(chat_title=html.escape(chat_title)) + + "\n" + + format_link_message(links) ) await send_safe( bot, @@ -372,7 +377,7 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha await edit_safe( notification_msg, MSG_NEW_FILE_REQUEST.format( - source_info=source_info, + source_info=html.escape(source_info), id_=message.chat.id, online_link=links["online_link"], stream_link=links["stream_link"], @@ -545,7 +550,7 @@ async def process_batch( * the whole batch runs under a ``30 + 2n`` second deadline; * progress edits are throttled to every 5 completions. """ - total_started = time.time() + total_started = time.monotonic() deadline = total_started + _BATCH_DEADLINE_BASE + 2 * count worker_count = max(1, int(getattr(Var, "BATCH_WORKERS", 5))) @@ -556,8 +561,9 @@ async def process_batch( # ---- pre-fetch phase (chunked, same as the historical behavior) ---- fetched: dict[int, Message | None] = {} + fetch_failed: set[int] = set() for chunk_start in range(0, count, BATCH_SIZE): - if time.time() > deadline: + if time.monotonic() > deadline: break chunk_ids = ids[chunk_start : chunk_start + BATCH_SIZE] try: @@ -569,7 +575,10 @@ async def process_batch( else: messages = list(fetched_msgs) except Exception as e: + # a failed chunk is a FAILED fetch, not a benign skip: counting it + # as skipped made whole-chunk outages invisible in the summary logger.error(f"Error getting messages in batch: {e}", exc_info=True) + fetch_failed.update(chunk_ids) messages = [] for mid, m in zip(chunk_ids, messages, strict=False): fetched[mid] = m if (m is not None and getattr(m, "media", None)) else None @@ -598,21 +607,20 @@ async def progress_edit(): async def worker(): nonlocal skipped while True: - try: - mid = queue.get_nowait() - except asyncio.QueueEmpty: - await asyncio.sleep(0.05) - continue + mid = await queue.get() try: if mid is None: return - if time.time() > deadline: + if time.monotonic() > deadline: results[mid] = None skipped += 1 counters["done"] += 1 continue m = fetched.get(mid) - if m is not None: + if mid in fetch_failed: + results[mid] = None + counters["failed"] += 1 + elif m is not None: links = await process_single( bot, msg, m, None, shortener_val, original_request_msg=msg ) @@ -628,15 +636,19 @@ async def worker(): finally: queue.task_done() - # initial status - await edit_safe( - status_msg, - MSG_PROCESSING_BATCH.format( - batch_number=1, - total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, - file_count=count, - ), - ) + # initial status (guarded: a deleted/undeletable status message must not + # abort the whole batch before it starts) + try: + await edit_safe( + status_msg, + MSG_PROCESSING_BATCH.format( + batch_number=1, + total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, + file_count=count, + ), + ) + except Exception as e: + logger.debug(f"Could not update batch status message: {e}") workers = [asyncio.create_task(worker(), name=f"batch_worker_{i}") for i in range(worker_count)] await asyncio.gather(*workers) @@ -662,7 +674,9 @@ async def worker(): await send_safe( bot, msg.from_user.id, - text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + text=MSG_DM_BATCH_PREFIX.format( + chat_title=html.escape(msg.chat.title or "the chat") + ) + "\n" + chunk_text, disable_web_page_preview=True, diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py index b8c9e58..ce37d8c 100644 --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -24,7 +24,9 @@ def _redact_path(path: str) -> str: # legacy: /watch/<6-char-hash>/ -> hash part path = re.sub( r"(?<=/watch/)[a-zA-Z0-9_-]{6}\d+", - lambda m: _hash_token(m.group(0)[: -len(m.group(0))]) + "…", + # hash the match itself -- the previous `m.group(0)[:-len(m.group(0))]` + # slice always evaluated to "" so every file logged the same pseudonym + lambda m: _hash_token(m.group(0)) + "…", path, ) return path diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index 1703815..dcb794c 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -154,6 +154,11 @@ def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: if start_str: start = int(start_str) end = int(end_str) if end_str else file_size - 1 + # RFC 7233 Β§2.1: a last-byte-pos >= length means "rest of the + # representation" -- clamp instead of rejecting. Download managers + # commonly send a fixed-chunk end computed without knowing the size; + # a hard 416 broke resume/seeking for exactly those clients. + end = min(end, file_size - 1) else: if not end_str: raise web.HTTPBadRequest(text=f"Invalid range header: {range_header}") @@ -165,7 +170,7 @@ def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: start = max(file_size - suffix_len, 0) end = file_size - 1 - if start < 0 or end >= file_size or start > end: + if start < 0 or start >= file_size or start > end: # L6: 416 discipline with Content-Range raise web.HTTPRequestRangeNotSatisfiable(headers={"Content-Range": f"bytes */{file_size}"}) @@ -310,7 +315,7 @@ async def status_endpoint(request): "uptime": get_readable_time(uptime), }, "telegram_bot": { - "username": f"@{StreamBot.username}", + "username": f"@{StreamBot.username or 'unknown'}", "active_clients": len(multi_clients), "dc_id": dc_id, }, diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py index 21702f1..b1e8f91 100644 --- a/Thunder/utils/bot_utils.py +++ b/Thunder/utils/bot_utils.py @@ -1,6 +1,7 @@ # Thunder/utils/bot_utils.py import asyncio +import html from typing import Any from urllib.parse import quote @@ -31,9 +32,13 @@ def quote_media_name(file_name: str) -> str: def format_link_message(links: dict[str, str]) -> str: - """Render the MSG_LINKS template, appending the TTL expiry note (L2).""" + """Render the MSG_LINKS template, appending the TTL expiry note (L2). + + ``media_name`` is user-controlled and MSG_LINKS is HTML -- escape it + (M7) so a crafted file name cannot inject markup into the link message. + """ text = MSG_LINKS.format( - file_name=links["media_name"], + file_name=html.escape(str(links["media_name"])), file_size=links["media_size"], download_link=links["online_link"], stream_link=links["stream_link"], @@ -131,7 +136,10 @@ async def log_newusr(cli: Client, uid: int, fname: str): if isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0: try: await send_safe( - cli, Var.BIN_CHANNEL, text=MSG_NEW_USER.format(first_name=fname, user_id=uid) + cli, + Var.BIN_CHANNEL, + # MSG_NEW_USER is HTML; first_name is user-controlled (M7) + text=MSG_NEW_USER.format(first_name=html.escape(str(fname or "")), user_id=uid), ) except Exception as e: logger.warning(f"Could not log new user {uid}: {e}") diff --git a/Thunder/utils/broadcast.py b/Thunder/utils/broadcast.py index 45af0f2..3234262 100644 --- a/Thunder/utils/broadcast.py +++ b/Thunder/utils/broadcast.py @@ -46,6 +46,10 @@ _BROADCAST_PACE_SECONDS = 0.2 _PROGRESS_EVERY = 25 +# strong references to in-flight broadcast tasks (CPython only weakly +# references tasks; unreferenced ones can be garbage-collected mid-sweep) +_BROADCAST_TASKS: set[asyncio.Task] = set() + async def broadcast_message(client: Client, message: Message, mode: str = "all"): if not message.reply_to_message: @@ -153,34 +157,50 @@ async def worker(): ] producer_task = asyncio.create_task(producer(), name="broadcast_producer") - await producer_task - await asyncio.gather(*workers) - - try: - await status_msg.delete() - 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 - + completed_normally = False try: - await reply_safe(message, completion_msg, parse_mode=ParseMode.MARKDOWN) - except Exception as e: - logger.error(f"Failed to send broadcast completion message: {e}", exc_info=True) + await producer_task + results = await asyncio.gather(*workers, return_exceptions=True) + for r in results: + if isinstance(r, BaseException) and not isinstance(r, asyncio.CancelledError): + logger.error(f"Broadcast worker failed: {r!r}") + completed_normally = True + finally: + # no worker/producer/status/registry leakage on ANY exit path + for t in workers: + if not t.done(): + t.cancel() + if not producer_task.done(): + producer_task.cancel() + try: + await status_msg.delete() + except Exception: + pass + broadcast_ids.pop(broadcast_id, None) + + if completed_normally: + 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 broadcast_id in broadcast_ids: - del broadcast_ids[broadcast_id] + if stats["cancelled"]: + completion_msg = "πŸ›‘ **Broadcast Cancelled**\n\n" + completion_msg - asyncio.create_task(do_broadcast()) + try: + await reply_safe(message, completion_msg, parse_mode=ParseMode.MARKDOWN) + except Exception as e: + logger.error(f"Failed to send broadcast completion message: {e}", exc_info=True) + + task = asyncio.create_task(do_broadcast()) + # hold a reference -- CPython only weakly references tasks, so an + # unreferenced broadcast can be garbage-collected mid-sweep -- and let + # the module-level set keep it alive until done + _BROADCAST_TASKS.add(task) + task.add_done_callback(_BROADCAST_TASKS.discard) async def _send_one(client: Client, message: Message, user_id: int, stats: dict) -> None: diff --git a/Thunder/utils/canonical_files.py b/Thunder/utils/canonical_files.py index 299893d..3f53a4d 100644 --- a/Thunder/utils/canonical_files.py +++ b/Thunder/utils/canonical_files.py @@ -36,7 +36,6 @@ _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] = {} @@ -121,7 +120,6 @@ def _remember(record: dict[str, Any]) -> dict[str, Any]: 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 @@ -136,25 +134,17 @@ def _remember(record: dict[str, Any]) -> dict[str, Any]: _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) -> dict[str, Any] | None: @@ -175,13 +165,6 @@ async def get_file_by_hash( return _remember(record) if record else None -async def get_file_by_message_id(canonical_message_id: int) -> dict[str, Any] | None: - cached = _cache_get(_cache_by_message_id, canonical_message_id) - if cached: - return cached - return None - - async def forget_stale_record(record: dict[str, Any]) -> bool: """Self-healing (M10): drop a corrupted/stale record from cache + DB so the next upload re-ingests cleanly instead of erroring forever.""" @@ -196,20 +179,26 @@ async def forget_stale_record(record: dict[str, Any]) -> bool: async def _flush_pending_touches() -> None: global _flush_task, _dropped_touches + cancelled = False flushed = False try: await asyncio.sleep(_FLUSH_DELAY_SECONDS) await _bulk_flush() flushed = True except asyncio.CancelledError: - pass + cancelled = True finally: - if not flushed and _pending_touches: + if not flushed and not cancelled and _pending_touches: try: await _bulk_flush() except Exception as e: logger.error(f"Touch flush failed on cancel path: {e}", exc_info=True) _flush_task = None + if _pending_touches and not cancelled: + # touches added while this flush was in flight are invisible to it + # (it snapshotted before they arrived) -- re-arm or they sit + # unflushed until the next schedule or process exit + _flush_task = asyncio.create_task(_flush_pending_touches()) async def _bulk_flush() -> None: @@ -220,7 +209,11 @@ async def _bulk_flush() -> None: try: await db.bulk_touch_file_records([(h, reused) for h, (_, reused) in items]) except Exception as e: + # merge the batch back so the next flush retries -- clearing before + # the write succeeded silently discarded every pending increment logger.error(f"Failed to bulk-flush {len(items)} touches: {e}", exc_info=True) + for h, payload in items: + _pending_touches.setdefault(h, payload) def schedule_touch_file_record(record: dict[str, Any], *, reused: bool = False) -> None: @@ -332,11 +325,17 @@ async def _get_reusable_canonical_record( try: is_valid = await _is_canonical_record_valid(existing, file_unique_id, client) except Exception as e: + # RPC failure (FloodWait-exhausted, timeout, network) is NOT proof the + # vault message is gone -- treating it as stale made every Telegram + # hiccup re-copy the file into BIN and orphan the old vault message. + # Keep the cached record and serve it; only a definitive None / + # unique-id mismatch (checked inside _is_canonical_record_valid) + # declares staleness. logger.warning( - f"Falling back to BIN re-copy for {file_unique_id} after canonical validation failed: {e}", + f"Canonical validation errored for {file_unique_id}; keeping cached record: {e}", exc_info=True, ) - is_valid = False + return existing, None if is_valid: return existing, None diff --git a/Thunder/utils/commands.py b/Thunder/utils/commands.py index e99c70f..bd7417d 100644 --- a/Thunder/utils/commands.py +++ b/Thunder/utils/commands.py @@ -1,4 +1,3 @@ -from Thunder.bot import StreamBot from Thunder.bot.registry import bot_commands, help_command_rows from Thunder.utils.logger import logger from Thunder.utils.messages import MSG_HELP_COMMANDS_HEADER, MSG_HELP_TIPS @@ -22,6 +21,10 @@ async def set_commands(): try: commands = bot_commands() if commands: + # lazy import (M12 layering: utils must not import bot at + # module scope -- same pattern as canonical_files/render_template) + from Thunder.bot import StreamBot + 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/custom_dl.py b/Thunder/utils/custom_dl.py index 092799f..3e5bc75 100644 --- a/Thunder/utils/custom_dl.py +++ b/Thunder/utils/custom_dl.py @@ -12,6 +12,7 @@ from Thunder.utils.file_properties import get_media from Thunder.utils.logger import logger from Thunder.utils.media_types import ext_and_mime_for_class +from Thunder.utils.safe_call import tg_call from Thunder.vars import Var @@ -23,16 +24,18 @@ def __init__(self, client: Client) -> None: self.chat_id = int(Var.BIN_CHANNEL) async def get_message(self, message_id: int) -> Message: - while True: - try: - message = await self.client.get_messages(self.chat_id, message_id) - break - except FloodWait as e: - logger.debug(f"FloodWait: get_message, sleep {e.value}s") - await asyncio.sleep(e.value) - except Exception as e: - logger.debug(f"Error fetching message {message_id}: {e}", exc_info=True) - raise FileNotFound(f"Message {message_id} not found") from e + # H4b/H8: bounded FloodWait handling via tg_call -- the previous + # open-ended sleep loop could pin an HTTP handler (and its stream + # slot) indefinitely on a sustained Telegram flood. + try: + message = await tg_call( + self.client.get_messages, self.chat_id, message_id, retries=2, timeout=60 + ) + except FloodWait as e: + raise FileNotFound(f"Message {message_id} unavailable (FloodWait {e.value}s)") from e + except Exception as e: + logger.debug(f"Error fetching message {message_id}: {e}", exc_info=True) + raise FileNotFound(f"Message {message_id} not found") from e if isinstance(message, list): # defensive: pyrogram returns a list for list inputs if not message: @@ -56,6 +59,7 @@ async def stream_file( # H4b: the historical fallback-message plumbing was dead (the # fallback id always equalled the primary ref, so the fallback ref # was never appended) -- removed. + chunks_done = 0 while True: try: target = ( @@ -67,8 +71,17 @@ async def stream_file( target, offset=chunk_offset, limit=chunk_limit ): yield chunk + chunks_done += 1 return except FloodWait as e: + # resume from where the CONSUMER actually is: restarting from + # the original offset re-yields bytes already sent, corrupting + # the download (duplicated middle, truncated tail). + if chunks_done: + chunk_offset += chunks_done + if chunk_limit: + chunk_limit = max(chunk_limit - chunks_done, 0) + chunks_done = 0 logger.debug(f"FloodWait: stream_file, sleep {e.value}s") await asyncio.sleep(e.value) except Exception as e: diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py index b193764..007a7de 100644 --- a/Thunder/utils/database.py +++ b/Thunder/utils/database.py @@ -7,6 +7,7 @@ from pymongo.asynchronous.collection import AsyncCollection from pymongo.errors import DuplicateKeyError +from Thunder.utils.flag_cache import flags from Thunder.utils.logger import logger from Thunder.vars import Var @@ -186,6 +187,9 @@ async def add_banned_user( await self.banned_users_col.update_one( {"user_id": user_id}, {"$set": ban_data}, upsert=True ) + # the ban gate is flag-cached (H7): without this invalidation a + # fresh ban would not take effect until the 5-min TTL expired + flags.invalidate(("banned_user", user_id)) 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) @@ -195,6 +199,7 @@ 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: + flags.invalidate(("banned_user", user_id)) logger.debug(f"Removed banned user {user_id}.") return True return False @@ -202,10 +207,17 @@ async def remove_banned_user(self, user_id: int) -> bool: 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) -> dict[str, Any] | None: + async def is_user_banned( + self, user_id: int, *, raise_on_error: bool = False + ) -> dict[str, Any] | None: + """Fetch a ban record. With ``raise_on_error=True`` a Mongo failure + raises so fail-closed callers (the ban gate, H7) can deny instead of + silently treating the outage as "not banned".""" try: return await self.banned_users_col.find_one({"user_id": user_id}) except Exception as e: + if raise_on_error: + raise logger.error(f"Error in is_user_banned for user {user_id}: {e}", exc_info=True) return None diff --git a/Thunder/utils/decorators.py b/Thunder/utils/decorators.py index 5392b58..7e779e1 100644 --- a/Thunder/utils/decorators.py +++ b/Thunder/utils/decorators.py @@ -24,7 +24,9 @@ from Thunder.utils.messages import ( MSG_DECORATOR_BANNED, MSG_ERROR_TEMP, + MSG_ERROR_TOKEN_LINK_FAILED, MSG_ERROR_UNAUTHORIZED, + MSG_ERROR_UNEXPECTED, MSG_PRIVATE_MODE_DENIED, MSG_TOKEN_INVALID, ) @@ -46,7 +48,10 @@ async def check_banned(client, message: Message) -> bool: try: ban_details = await flags.get_or_load( ("banned_user", user_id), - lambda: db.is_user_banned(user_id), + # raise_on_error=True: the DB method otherwise swallows Mongo + # outages into None, which the cache would treat as + # "not banned" (negative-cached for 5 min) -- fail-open. + lambda: db.is_user_banned(user_id, raise_on_error=True), ) except Exception as e: # fail-closed: a Mongo outage must not un-ban everybody @@ -141,10 +146,7 @@ async def require_token(client, message: Message) -> bool: f"Failed to generate temporary token for user {user_id}: {e}", exc_info=True ) try: - await reply_safe( - message, - "Sorry, could not generate an access token link. Please try again later.", - ) + await reply_safe(message, MSG_ERROR_TOKEN_LINK_FAILED) except Exception: pass return False @@ -154,10 +156,7 @@ async def require_token(client, message: Message) -> bool: f"Temporary token generation returned empty for user {user_id}.", exc_info=True ) try: - await reply_safe( - message, - "Sorry, could not generate an access token link. Please try again later.", - ) + await reply_safe(message, MSG_ERROR_TOKEN_LINK_FAILED) except Exception: pass return False @@ -167,18 +166,14 @@ async def require_token(client, message: Message) -> bool: except Exception as e: logger.error(f"Failed to get bot info for user {user_id}: {e}", exc_info=True) try: - await reply_safe( - message, "Sorry, an unexpected error occurred. Please try again later." - ) + await reply_safe(message, MSG_ERROR_UNEXPECTED) except Exception: pass return False if not me: logger.error(f"get_me returned nothing for user {user_id}.", exc_info=True) try: - await reply_safe( - message, "Sorry, an unexpected error occurred. Please try again later." - ) + await reply_safe(message, MSG_ERROR_UNEXPECTED) except Exception: pass return False @@ -244,7 +239,7 @@ async def get_shortener_status(client, message: Message) -> bool: # -------------------------------------------------------------------------- #: gate registry -- order is the documented contract; adding a new gate is a -#: one-place change here (asserted by tests/test_preflight.py). +#: one-place change here (gate chain asserted by tests/test_unit/test_registry.py). PREFLIGHT_GATES = { "banned": check_banned, "private_mode": check_private_mode, @@ -257,7 +252,6 @@ async def preflight( message: Message, *, gates: tuple = ("banned", "private_mode", "token"), - skip: tuple = (), ) -> bool | None: """Run the standard gate chain in order. @@ -265,10 +259,10 @@ async def preflight( ``None`` when any gate rejects the request. """ for name in gates: - if name in skip: - continue gate = PREFLIGHT_GATES.get(name) if gate is None: + # a typo'd gate id must never silently disable a security check + logger.warning(f"preflight: unknown gate {name!r} skipped -- fix the gate id") continue if not await gate(client, message): return None diff --git a/Thunder/utils/flag_cache.py b/Thunder/utils/flag_cache.py index 67704ea..dc1285c 100644 --- a/Thunder/utils/flag_cache.py +++ b/Thunder/utils/flag_cache.py @@ -36,6 +36,7 @@ def __init__( self.max_items = max_items self.name = name self._data: OrderedDict[Hashable, tuple[Any, float]] = OrderedDict() + self._inflight: dict[Hashable, asyncio.Task] = {} def _prune_expired(self, now: float) -> None: expired = [key for key, (_, ts) in self._data.items() if now - ts > self.ttl_seconds] @@ -55,8 +56,24 @@ async def get_or_load( return value self._data.pop(key, None) - value = await loader() - self._data[key] = (value, now) + # single-flight: concurrent callers of a cold/expired key share one + # loader task instead of stampeding the backend with N identical reads + task = self._inflight.get(key) + if task is None: + task = asyncio.create_task(self._load_and_store(key, loader)) + self._inflight[key] = task + return await asyncio.shield(task) + + async def _load_and_store( + self, + key: Hashable, + loader: Callable[[], Awaitable[Any]], + ) -> Any: + try: + value = await loader() + finally: + self._inflight.pop(key, None) + self._data[key] = (value, time.monotonic()) self._data.move_to_end(key) while len(self._data) > self.max_items: self._data.popitem(last=False) diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py index 687266b..2ea51f8 100644 --- a/Thunder/utils/force_channel.py +++ b/Thunder/utils/force_channel.py @@ -1,5 +1,7 @@ # Thunder/utils/force_channel.py +import time + from pyrogram import Client from pyrogram.errors import UserNotParticipant from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message @@ -11,16 +13,24 @@ _force_link = None _force_title = None +_force_resolved = False +_negative_until = 0.0 +_NEGATIVE_TTL_SECONDS = 60.0 async def get_force_info(bot: Client): - global _force_link, _force_title + global _force_link, _force_title, _force_resolved, _negative_until if not Var.FORCE_CHANNEL_ID: return None, None - if _force_link is not None and _force_title is not None: + # resolved-once (a numeric channel's invite link/title does not change + # between messages) -- the old guard re-fetched get_chat on EVERY + # message whenever the channel had no link, the bot's busiest path + if _force_resolved: return _force_link, _force_title + if time.monotonic() < _negative_until: + return None, None try: chat = await tg_call(bot.get_chat, Var.FORCE_CHANNEL_ID, retries=1) @@ -32,8 +42,13 @@ async def get_force_info(bot: Client): f"https://t.me/{chat.username}" if chat.username else None # type: ignore[union-attr] ) _force_title = chat.title or "Channel" + # cache even the no-link outcome, or it re-resolves per message + _force_resolved = True return _force_link, _force_title except Exception as e: + # transient RPC failure: short negative cache so the gate path does + # not hammer get_chat on every message during a Telegram brownout + _negative_until = time.monotonic() + _NEGATIVE_TTL_SECONDS logger.error(f"Force channel error: {e}", exc_info=True) return None, None diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py index 9866532..487ad2e 100644 --- a/Thunder/utils/messages.py +++ b/Thunder/utils/messages.py @@ -348,6 +348,16 @@ "> πŸ’‘ **Tip:** Try again later when system load decreases" ) +MSG_RATE_LIMIT_DROPPED = ( + "⚠️ Service is busy and your request could not be completed. Please try again in a few minutes." +) + +# H7/M12 decorator gate failures (user-facing, shared by all entry points) +MSG_ERROR_TOKEN_LINK_FAILED = ( + "Sorry, could not generate an access token link. Please try again later." +) +MSG_ERROR_UNEXPECTED = "Sorry, an unexpected error occurred. Please try again later." + # ===================================================================================== # ====== FILE TYPE DESCRIPTIONS ====== diff --git a/Thunder/utils/rate_limiter.py b/Thunder/utils/rate_limiter.py index 9d45ae0..cea2292 100644 --- a/Thunder/utils/rate_limiter.py +++ b/Thunder/utils/rate_limiter.py @@ -28,6 +28,7 @@ from Thunder.utils.logger import logger from Thunder.utils.messages import ( + MSG_RATE_LIMIT_DROPPED, MSG_RATE_LIMIT_QUEUE_FULL, MSG_RATE_LIMIT_QUEUE_PRIORITY, MSG_RATE_LIMIT_QUEUE_REGULAR, @@ -79,6 +80,11 @@ def retry_after(self) -> float: return 0.0 return (1.0 - self._tokens) / self.rate + def available(self) -> float: + """Current token count (for /stats occupancy), without consuming.""" + self._refill() + return max(self._tokens, 0.0) + class RateLimiter: def __init__(self): @@ -91,6 +97,7 @@ def __init__(self): self.user_requests: dict[int, deque[float]] = {} self.global_requests: deque[float] = deque() + self._deferred_timer: asyncio.TimerHandle | None = None self.processing_times: deque[float] = deque(maxlen=100) self.file_processing_times: dict[str, deque[float]] = {} @@ -215,11 +222,15 @@ async def sweep(self) -> dict[str, int]: self.user_requests.pop(user_id, None) dropped_users += 1 if len(self.user_requests) > MAX_TRACKED_USERS: - for user_id, stamps in list(self.user_requests.items()): + # evict least-recently-active first -- dict-order eviction could + # wipe an active user's window and grant an instant quota burst + by_recency = sorted( + self.user_requests.items(), + key=lambda kv: kv[1][-1] if kv[1] else 0.0, + ) + for user_id, _ in by_recency: if len(self.user_requests) <= MAX_TRACKED_USERS: break - if not stamps: - continue self.user_requests.pop(user_id, None) dropped_global = 0 @@ -248,13 +259,13 @@ async def sweep(self) -> dict[str, int]: "stale_counts": dropped_counts, } - def occupancy(self) -> dict[str, int]: + def occupancy(self) -> dict[str, float | int]: """Limiter occupancy for /stats (plan PR-13).""" return { "queued": len(self.request_queue) + len(self.priority_queue), "tracked_users": len(self.user_requests), "global_window": len(self.global_requests), - "breaker_tokens": round(max(self.breaker._tokens, 0.0), 2), + "breaker_tokens": round(self.breaker.available(), 2), } # ---------------- queueing ---------------- @@ -268,6 +279,10 @@ async def _requeue_request(self, request_data: dict, queue_type: str, delay: flo else: self.request_queue.appendleft(request_data) self.request_event.set() + # A pure requeue (nothing else runnable) must re-park the pool, + # or the workers spin on the deferred item until not_before. + if delay > 0: + self._park_if_all_deferred() logger.debug( f"Re-queued request for user {request_data['user_id']} to {queue_type} queue (delay={delay:.2f}s)." ) @@ -331,19 +346,25 @@ async def _process_one(self) -> bool: # deferred: rotate to the right so other requests can proceed async with self.request_lock: queue.append(request_data) + self._park_if_all_deferred() return True user_id = request_data["user_id"] # charge-at-exec: the sliding window is charged exactly once, here. + # Retries (FloodWait/breaker requeues) must not re-charge, or one + # upload can burn a user's entire window on server-side failures. if not self.is_owner(user_id): - if not await self.check_limits(user_id, record=True): + record = not request_data.get("charged") + if not await self.check_limits(user_id, record=record): wait = self._calculate_user_rate_limit_wait(user_id, now) if self.global_rate_limit_enabled: wait = max(wait, self._calculate_global_rate_limit_wait(now)) wait = min(max(wait, 1.0), self.rate_limit_period_seconds) await self._requeue_request(request_data, queue_type, delay=wait) return True + if record: + request_data["charged"] = True if self.global_rate_limit_enabled and not self.breaker.allow(): retry = max(self.breaker.retry_after(), 0.5) await self._requeue_request(request_data, queue_type, delay=retry) @@ -384,6 +405,15 @@ async def _process_one(self) -> bool: else: logger.warning(f"FloodWait for user {user_id}, requeueing (attempt {attempts}).") await self._requeue_request(request_data, queue_type, delay=min(e.value, 300.0)) + except asyncio.CancelledError: + # Shutdown/cancellation: release the queue slot so the user's + # count does not leak, then propagate. + async with self.request_lock: + if user_id in self.user_queue_counts: + self.user_queue_counts[user_id] -= 1 + if self.user_queue_counts[user_id] <= 0: + self.user_queue_counts.pop(user_id, None) + raise except Exception as e: logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) processed = True @@ -401,15 +431,44 @@ async def _notify_drop(self, request_data: dict) -> None: if notification_msg is None: return try: - await edit_safe( - notification_msg, - "⚠️ Service is busy and your request could not be completed. Please try again in a few minutes.", - ) + await edit_safe(notification_msg, MSG_RATE_LIMIT_DROPPED) except Exception: logger.debug("Could not notify user about dropped request", exc_info=True) + def _park_if_all_deferred(self) -> None: + """Stop the busy-spin when every queued request is deferred. + + On Python 3.13, ``Event.wait()`` on a set event and an uncontended + ``Lock.acquire()`` return WITHOUT yielding. Workers rotating only + deferred items therefore had zero yield points and froze the whole + event loop (all handlers, streams, sweepers) until the earliest + ``not_before`` passed -- up to 300s at 100% CPU. Parking clears the + wakeup event and arms a timer for the earliest deferred request; + any new enqueue re-sets the event and wakes the pool immediately. + Caller must hold ``request_lock``. + """ + now = time.time() + earliest: float | None = None + for q in (self.priority_queue, self.request_queue): + for item in q: + nb = item.get("not_before", 0.0) + if nb <= now: + # something is runnable -- (re-)wake the pool and keep going + self.request_event.set() + return + if earliest is None or nb < earliest: + earliest = nb + if earliest is None: + return + self.request_event.clear() + if self._deferred_timer is not None: + self._deferred_timer.cancel() + self._deferred_timer = asyncio.get_running_loop().call_later( + min(earliest - now, 300.0), self.request_event.set + ) + async def request_executor(self): - """One consumer; start :data:`Var.EXUTOR_WORKERS` -- see start_executors().""" + """One consumer; start :data:`Var.EXECUTOR_WORKERS` -- see start_executors().""" logger.debug("Request executor worker started.") while True: try: @@ -431,6 +490,9 @@ async def shutdown(self): self.priority_queue.clear() self.user_queue_counts.clear() self.request_event.clear() + if self._deferred_timer is not None: + self._deferred_timer.cancel() + self._deferred_timer = None logger.debug("Rate limiter queues cleared.") # ---------------- estimates (protected UX) ---------------- @@ -559,8 +621,9 @@ async def handle_rate_limited_request( await handler(bot, message, *args, **kwargs) return - # H6c: global RPS breaker sheds bursts before they hit Telegram FLOOD_WAIT. - if rate_limiter.global_rate_limit_enabled and not rate_limiter.breaker.allow(): + # H6c: probe without consuming -- the exec path below is the single + # consumption point; charging here too halved throughput for queued traffic. + if rate_limiter.global_rate_limit_enabled and rate_limiter.breaker.retry_after() > 0: logger.warning(f"Global RPS breaker engaged; shedding request for user {user_id}.") if not (rl_user_id is not None and rl_user_id < 0): await send_queue_full_message(bot, message, file_identifier) diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py index 18c9918..df11bf5 100644 --- a/Thunder/utils/shortener.py +++ b/Thunder/utils/shortener.py @@ -6,10 +6,12 @@ requests/urllib3 transitive tree is gone). ``curl_cffi`` remains an optional escape hatch for Cloudflare-protected providers -- declared as the ``shortener-cf`` extra, never a hard dependency. -* M5 hardening: LRU cache + per-URL singleflight, API key moved to an - ``Authorization: Bearer`` header (never the query string), https-only +* M5 hardening: LRU cache + per-URL singleflight, https-only endpoints, redirects never followed, and the returned short URL's host must match the configured site's host (anti redirect-to-attacker). + API-key placement is provider-mandated: Bitly takes a Bearer header; + path/query-key providers (ouo.io, generic) keep their documented + schemes. * The plugin registry and the offline Linkvertise builder are preserved. """ @@ -93,7 +95,10 @@ async def shorten( ) as resp: if resp.status == 200: data = await resp.json() - return data.get("link", url) + short = data.get("link") + # same anti-substitution guard as every other HTTP plugin (M5) + if short and short != url and self._validate_short_url(short, domain): + return short return url @@ -168,6 +173,7 @@ def __init__(self): self.ready = False self._cache: OrderedDict[str, str] = OrderedDict() self._inflight: dict[str, asyncio.Future] = {} + self._init_lock = asyncio.Lock() def _get_plugin_class(self, domain: str): for plugin_class in ShortenerPlugin.__subclasses__(): @@ -176,38 +182,41 @@ def _get_plugin_class(self, domain: str): return GenericShortenerPlugin async def initialize(self) -> bool: - if self.ready: - return True - - if not ( - getattr(Var, "SHORTEN_ENABLED", False) or getattr(Var, "SHORTEN_MEDIA_LINKS", False) - ): - return False - - site = getattr(Var, "URL_SHORTENER_SITE", "") - api_key = getattr(Var, "URL_SHORTENER_API_KEY", "") - - if not (site and api_key): - return False - - try: - timeout = aiohttp.ClientTimeout(total=SHORTEN_TIMEOUT_SECONDS) - self.session = aiohttp.ClientSession( - timeout=timeout, - headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) FileToLink/shortener"}, - ) - # NOTE: redirects are disabled per-request (aiohttp does not accept - # ``allow_redirects`` on the session constructor -- passing it there - # raises TypeError at runtime and silently disabled the shortener). - self.domain = site - plugin_class = self._get_plugin_class(site) - self.plugin = plugin_class() - self.ready = True - logger.info(f"Shortener ready (plugin={type(plugin_class).__name__}, site={site})") - return True - except Exception as e: - logger.error(f"Failed to initialize ShortenerSystem: {e}", exc_info=True) - return False + # lock: the first concurrent use (e.g. two shorten() calls in one + # gather) would otherwise build two sessions and leak one + async with self._init_lock: + if self.ready: + return True + + if not ( + getattr(Var, "SHORTEN_ENABLED", False) or getattr(Var, "SHORTEN_MEDIA_LINKS", False) + ): + return False + + site = getattr(Var, "URL_SHORTENER_SITE", "") + api_key = getattr(Var, "URL_SHORTENER_API_KEY", "") + + if not (site and api_key): + return False + + try: + timeout = aiohttp.ClientTimeout(total=SHORTEN_TIMEOUT_SECONDS) + self.session = aiohttp.ClientSession( + timeout=timeout, + headers={"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) FileToLink/shortener"}, + ) + # NOTE: redirects are disabled per-request (aiohttp does not accept + # ``allow_redirects`` on the session constructor -- passing it there + # raises TypeError at runtime and silently disabled the shortener). + self.domain = site + plugin_class = self._get_plugin_class(site) + self.plugin = plugin_class() + self.ready = True + logger.info(f"Shortener ready (plugin={type(plugin_class).__name__}, site={site})") + return True + except Exception as e: + logger.error(f"Failed to initialize ShortenerSystem: {e}", exc_info=True) + return False async def _shorten_uncached(self, url: str) -> str: if self.session is None or self.plugin is None: @@ -247,9 +256,15 @@ async def short_url(self, url: str) -> str: if not future.done(): future.set_result(result) return result - except Exception as e: + except BaseException as e: + # CancelledError is BaseException: without this, a cancelled + # runner never resolves the future and every waiter hangs forever if not future.done(): - future.set_exception(e) + future.set_exception( + e + if isinstance(e, Exception) + else RuntimeError(f"shortening of {url!r} aborted: {e!r}") + ) raise finally: self._inflight.pop(url, None) @@ -262,6 +277,11 @@ async def close(self) -> None: _system = ShortenerSystem() +async def close_shortener() -> None: + """Shutdown hook: close the shared aiohttp session (H5b lifecycle).""" + await _system.close() + + async def shorten(url: str) -> str: if not _system.ready: await _system.initialize() diff --git a/Thunder/utils/tokens.py b/Thunder/utils/tokens.py index e496a73..612f270 100644 --- a/Thunder/utils/tokens.py +++ b/Thunder/utils/tokens.py @@ -1,11 +1,11 @@ # Thunder/utils/tokens.py +import asyncio +import random import secrets -from datetime import datetime, timedelta +from datetime import UTC, datetime, timedelta from typing import Any -import pyrogram.errors - from Thunder.utils.database import db from Thunder.utils.flag_cache import flags from Thunder.utils.logger import logger @@ -39,7 +39,7 @@ async def _load_token_ok(user_id: int) -> bool: """Loader for the activated-token flag. Raises on DB failure so the caller can apply its fail-closed policy.""" token_result = await db.token_col.find_one( - {"user_id": user_id, "expires_at": {"$gt": datetime.utcnow()}, "activated": True}, + {"user_id": user_id, "expires_at": {"$gt": datetime.now(UTC)}, "activated": True}, {"_id": 1}, ) return bool(token_result) @@ -49,7 +49,11 @@ async def generate(user_id: int) -> str: try: logger.debug(f"Token generation started for user: {user_id}") existing_token_doc = await db.token_col.find_one( - {"user_id": user_id, "activated": False, "expires_at": {"$gt": datetime.utcnow()}}, + { + "user_id": user_id, + "activated": False, + "expires_at": {"$gt": datetime.now(UTC)}, + }, {"token": 1}, ) if existing_token_doc: @@ -61,7 +65,7 @@ async def generate(user_id: int) -> str: for attempt in range(max_retries): try: ttl_hours = getattr(Var, "TOKEN_TTL_HOURS", 24) - created_at = datetime.utcnow() + created_at = datetime.now(UTC) expires_at = created_at + timedelta(hours=ttl_hours) await db.save_main_token( user_id=user_id, @@ -72,17 +76,8 @@ async def generate(user_id: int) -> str: ) logger.debug(f"New token generated and saved successfully for user: {user_id}") return token_str - except pyrogram.errors.RPCError as e: - logger.error( - f"Telegram API error while generating new token for user {user_id}: {e}", - exc_info=True, - ) - raise except Exception as e: if attempt < max_retries - 1: - import asyncio - import random - delay = base_delay * (2**attempt) + random.uniform(0, 0.1) logger.warning( f"Database error (attempt {attempt + 1}/{max_retries}) while saving new token: {e}. Retrying in {delay:.2f} seconds.", @@ -111,7 +106,7 @@ async def consume(token: str, user_id: int) -> tuple[str, float]: Returns ``(status, hours_valid)`` with status one of ``"ok" | "already" | "wrong_user" | "invalid"``. """ - now = datetime.utcnow() + now = datetime.now(UTC) try: doc = await db.token_col.find_one({"token": token}) if not doc: @@ -120,10 +115,19 @@ async def consume(token: str, user_id: int) -> tuple[str, float]: return "wrong_user", 0.0 if doc.get("activated"): return "already", 0.0 + if doc.get("expires_at") and doc["expires_at"] <= now: + # expired unactivated tokens must not be activatable via a stale + # deep-link that outraced the TTL monitor/cleanup + return "invalid", 0.0 expires_at = now + timedelta(hours=Var.TOKEN_TTL_HOURS) activated_doc = await db.token_col.find_one_and_update( - {"token": token, "user_id": user_id, "activated": {"$ne": True}}, + { + "token": token, + "user_id": user_id, + "activated": {"$ne": True}, + "expires_at": {"$gt": now}, + }, { "$set": { "activated": True, @@ -164,7 +168,7 @@ async def authorize(user_id: int, authorized_by: int) -> bool: auth_data = { "user_id": user_id, "authorized_by": authorized_by, - "authorized_at": datetime.utcnow(), + "authorized_at": datetime.now(UTC), } await db.authorized_users_col.update_one( {"user_id": user_id}, {"$set": auth_data}, upsert=True @@ -199,7 +203,7 @@ async def list_allowed() -> list[dict[str, Any]]: async def cleanup_expired_tokens() -> int: try: - current_time = datetime.utcnow() + current_time = datetime.now(UTC) logger.debug("Cleaning up expired tokens") result = await db.token_col.delete_many({"expires_at": {"$lte": current_time}}) logger.debug(f"Cleaned up {result.deleted_count} expired tokens") diff --git a/Thunder/vars.py b/Thunder/vars.py index fd80e6f..492808e 100644 --- a/Thunder/vars.py +++ b/Thunder/vars.py @@ -25,7 +25,8 @@ def str_to_bool(val: str) -> bool: - return val.lower() in ("true", "1", "t", "y", "yes") + # strip: raw env values can carry trailing whitespace/CR + return str(val).strip().lower() in ("true", "1", "t", "y", "yes") def str_to_int_set(val: str) -> set[int]: @@ -188,10 +189,15 @@ class Var: # H8: default wall-clock budget for lightweight Telegram RPCs. TG_RPC_TIMEOUT_SECONDS: float = _get_float("TG_RPC_TIMEOUT_SECONDS", "30", min_val=0) - # H10: logging. - LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper() - LOG_FORMAT: str = os.getenv("LOG_FORMAT", "plain").lower() +# Cross-flag validation (M6): with both gates on, a non-allowlisted user's +# activation deep-link is rejected by the private-mode gate BEFORE the token +# consume path in /start can run -- token-gated access becomes unreachable. +if Var.PRIVATE_MODE and Var.TOKEN_ENABLED: + _config_errors.append( + "PRIVATE_MODE and TOKEN_ENABLED cannot both be enabled: token " + "activation links are unreachable behind the private-mode allowlist." + ) if _config_errors: logger.critical(f"Invalid configuration -- {len(_config_errors)} problem(s) found:") diff --git a/config_sample.env b/config_sample.env index 034611e..d68a3f7 100644 --- a/config_sample.env +++ b/config_sample.env @@ -188,3 +188,10 @@ WORKERS=8 # Number of worker processes # Web server configuration BIND_ADDRESS="0.0.0.0" # Listen on all network interfaces PING_INTERVAL=840 # Ping interval in seconds (health check based) + +# Keepalive probe target override (optional; defaults to the bind address). +# Only set when the health endpoint lives on a different host/interface. +#KEEPALIVE_HOST="127.0.0.1" + +# Build-time version stamp (optional; injected by CI, safe to leave unset). +#APP_VERSION="2.2.0" diff --git a/pyproject.toml b/pyproject.toml index 6cfb1c2..327bc2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ dependencies = [ "aiohttp==3.14.3", # OSV-clean; 3 Jan-2026 CVEs fixed (H1) "pyrofork==2.3.69", "tgcrypto-pyrofork==1.2.8", # H1: maintained crypto backend (pyrofork[speedup]) - "pymongo==4.18.0", # async AsyncMongoClient; >= 4.9 for timeoutMS + "pymongo==4.18.0", # async AsyncMongoClient; timeoutMS since 4.2 "Jinja2==3.1.6", "python-dotenv==1.2.3", "psutil==7.2.2", @@ -17,7 +17,8 @@ dependencies = [ [project.optional-dependencies] # H5b/R8: escape hatch for Cloudflare-protected shortener providers. # Never installed by default; shortener falls back to plain aiohttp. -shortener-cf = ["curl_cffi>=0.7"] +# Floor 0.15.0: GHSA-qw2m-4pqf-rmpp (CVE-2026-33752, redirect-based SSRF). +shortener-cf = ["curl_cffi>=0.15.0"] [dependency-groups] dev = [ @@ -29,6 +30,8 @@ dev = [ "bandit>=1.8", "vulture>=2.14", "pip-audit>=2.7", + # Integration tier only (tests/integration): real MongoDB via Docker. + "testcontainers[mongodb]>=4.13", ] [tool.ruff] @@ -60,7 +63,10 @@ markers = [ "unit: fast, hermetic tests (no network, no Mongo)", "integration: testcontainers-backed tests (opt-in, see tests/integration)", ] -addopts = "-m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35" +addopts = "-m unit" +# Coverage flags are passed explicitly by `make test` / CI so that the +# opt-in integration tier (`pytest -m integration`) can run without the +# unit tier's --cov-fail-under threshold breaking it. [tool.coverage.run] source = ["Thunder"] diff --git a/tests/integration/test_mongo.py b/tests/integration/test_mongo.py index 0761e90..3bc265e 100644 --- a/tests/integration/test_mongo.py +++ b/tests/integration/test_mongo.py @@ -16,7 +16,7 @@ mongo_uri = None try: # pragma: no cover - environment-dependent - from testcontainers.mongo import MongoContainer + from testcontainers.community.mongodb import MongoDbContainer as MongoContainer docker_unavailable = False except ImportError: @@ -43,7 +43,7 @@ async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover # M8: atomic activation -- two concurrent consume() calls, one winner import asyncio - from datetime import datetime, timedelta + from datetime import datetime, timedelta, timezone from Thunder.utils.tokens import consume @@ -53,8 +53,8 @@ async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover "token": token, "user_id": 424242, "activated": False, - "created_at": datetime.utcnow(), - "expires_at": datetime.utcnow() + timedelta(hours=1), + "created_at": datetime.now(timezone.utc), + "expires_at": datetime.now(timezone.utc) + timedelta(hours=1), } ) results = await asyncio.gather(consume(token, 424242), consume(token, 424242)) diff --git a/tests/test_unit/test_flag_cache.py b/tests/test_unit/test_flag_cache.py index e020461..0a94917 100644 --- a/tests/test_unit/test_flag_cache.py +++ b/tests/test_unit/test_flag_cache.py @@ -72,3 +72,22 @@ async def loader(): dropped = await cache.sweep() assert dropped == 1 assert cache.occupancy() == 0 + + +@pytest.mark.unit +async def test_concurrent_loaders_single_flight(): + """Cold/expired keys must share ONE loader task, not stampede the backend.""" + import asyncio + + calls = {"n": 0} + + async def loader(): + calls["n"] += 1 + await asyncio.sleep(0.02) # widen the race window + return "v" + + cache = FlagCache(ttl_seconds=60) + results = await asyncio.gather(*(cache.get_or_load("k", loader) for _ in range(10))) + assert results == ["v"] * 10 + assert calls["n"] == 1 + assert cache._inflight == {} # bookkeeping cleaned up diff --git a/tests/test_unit/test_rate_limiter_park.py b/tests/test_unit/test_rate_limiter_park.py new file mode 100644 index 0000000..4907fae --- /dev/null +++ b/tests/test_unit/test_rate_limiter_park.py @@ -0,0 +1,71 @@ +"""Regression: deferred requeue must park the worker pool, not busy-spin.""" +import asyncio +import time + +import pytest + +from Thunder.utils.rate_limiter import RateLimiter + + +@pytest.mark.unit +async def test_all_deferred_parks_pool(): + rl = RateLimiter() + rl.request_event.set() + + async def noop(*a, **k): + pass + + request = { + "func": noop, + "user_id": 123, + "args": (), + "kwargs": {}, + "not_before": time.time() + 60, + } + await rl._requeue_request(request, "regular", delay=60) + + # worker pops the deferred item, rotates it -- pool must park + handled = await rl._process_one() + assert handled is True + assert rl.request_event.is_set() is False # parked: no busy-spin + assert rl._deferred_timer is not None + assert len(rl.request_queue) == 1 # item still queued + + # a new enqueue wakes the pool immediately (timer + event both work) + rl.request_event.set() + rl._deferred_timer.cancel() + rl._deferred_timer = None + await rl.shutdown() + + +@pytest.mark.unit +async def test_runnable_item_prevents_parking(): + rl = RateLimiter() + rl.request_event.set() + + async def noop(*a, **k): + pass + + deferred = { + "func": noop, + "user_id": 1, + "args": (), + "kwargs": {}, + "not_before": time.time() + 60, + } + await rl._requeue_request(deferred, "regular", delay=60) + + runnable = { + "func": noop, + "user_id": 2, + "args": (), + "kwargs": {}, + "not_before": 0.0, + } + async with rl.request_lock: + rl.request_queue.append(runnable) + + handled = await rl._process_one() # pops deferred -> rotates + assert handled is True + assert rl.request_event.is_set() is True # NOT parked: runnable item exists + await rl.shutdown() diff --git a/tests/test_unit/test_stream_routes.py b/tests/test_unit/test_stream_routes.py index c76624d..7e2c49d 100644 --- a/tests/test_unit/test_stream_routes.py +++ b/tests/test_unit/test_stream_routes.py @@ -80,6 +80,14 @@ def test_open_ended(self): def test_closed_range(self): assert parse_range_header("bytes=0-49", 100) == (0, 49) + @pytest.mark.unit + def test_end_beyond_eof_clamps_instead_of_416(self): + # RFC 7233 Β§2.1: last-byte-pos >= length means "rest of the file". + # Download managers send fixed-chunk ends computed without the size; + # a hard 416 broke resume/seeking for exactly those clients. + assert parse_range_header("bytes=0-99999999", 100) == (0, 99) + assert parse_range_header("bytes=50-1000", 100) == (50, 99) + @pytest.mark.unit def test_suffix_range(self): assert parse_range_header("bytes=-10", 100) == (90, 99) diff --git a/update.py b/update.py index c4983c8..fab0b82 100644 --- a/update.py +++ b/update.py @@ -19,6 +19,7 @@ """ import os +import re import shutil import subprocess @@ -35,6 +36,20 @@ _CONFIG_BACKUP = "../config.env.tmp" +def _recover_config_backup() -> None: + """A crash between _backup_config and _restore_config would otherwise + leave the app permanently without config.env (next boot hard-fails). + Restore any orphaned backup before doing anything else.""" + if not os.path.exists(_CONFIG_BACKUP) and not os.path.exists("config.env"): + return + if os.path.exists(_CONFIG_BACKUP) and not os.path.exists("config.env"): + try: + os.replace(_CONFIG_BACKUP, "config.env") + logger.info("Recovered config.env from orphaned backup.") + except OSError as e: + logger.error(f"Could not recover config.env backup: {e}") + + def _backup_config() -> bool: try: if os.path.exists("config.env"): @@ -53,14 +68,22 @@ def _restore_config(backed_up: bool) -> None: logger.error(f"Could not restore config.env: {e}") +def _redact_credentials(text: str) -> str: + """git echoes remote URLs on failure; strip embedded tokens (user:pass + and user@host forms) before the output reaches the logs.""" + return re.sub(r"(?<=//)[^@/\s]+@", "@", text) + + def main() -> None: if not UPSTREAM_REPO: return + _recover_config_backup() if shutil.which("git") is None: logger.info("git not available; skipping self-update (image without git).") return if not os.path.isdir(".git"): logger.info("Not a git repository; skipping self-update.") + return backed_up = _backup_config() try: @@ -78,7 +101,7 @@ def main() -> None: # keep running the old code; never hard-fail the boot logger.error( "Self-update failed (non-destructive, keeping current code): " - f"{(result.stderr or result.stdout or '').strip()[:500]}" + f"{_redact_credentials((result.stderr or result.stdout or '').strip()[:500])}" ) except subprocess.TimeoutExpired: logger.error("Self-update timed out; keeping current code.") diff --git a/uv.lock b/uv.lock index 98173a0..2ef8fe8 100644 --- a/uv.lock +++ b/uv.lock @@ -579,6 +579,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, ] +[[package]] +name = "docker" +version = "7.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "requests" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/7f/731ff914b0255d3d065f45fd4e626d4b8c95dbcbaada049f337a6ac16410/docker-7.2.0.tar.gz", hash = "sha256:cebb93773d334f778e023a7ee352a8d6e13ab1bd3b863a4d4a59dec897df43ac", size = 118731, upload-time = "2026-07-09T14:53:46.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/23/529140fe1aab80fc6992f93a706deec709140a6397439139a054e1515c45/docker-7.2.0-py3-none-any.whl", hash = "sha256:a3f45fdeb9165e2d25d9a1d02ddf3bc70fb572cf5ebbf9b58558c22caf29b71f", size = 148775, upload-time = "2026-07-09T14:53:45.224Z" }, +] + [[package]] name = "filelock" version = "3.32.5" @@ -1437,6 +1451,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0d/17/c5c6b53ddc18f297992099b3d9ec16c855c0ccc83263a21fe4d1c625ec6c/python_dotenv-1.2.3-py3-none-any.whl", hash = "sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9", size = 22780, upload-time = "2026-08-16T16:54:52.473Z" }, ] +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1544,6 +1574,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c5/97/bba6e7ec2f5498b9dcb7b1b6400086b80ae5a8ebaff4b25e8c8add75f439/stevedore-5.9.1-py3-none-any.whl", hash = "sha256:5c8ff3a9f336cc1a06ac0f597bc79d11a2f950bfd32e290ca56b5a301fafafbf", size = 54931, upload-time = "2026-08-20T15:25:13.602Z" }, ] +[[package]] +name = "testcontainers" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "docker" }, + { name = "python-dotenv" }, + { name = "typing-extensions" }, + { name = "urllib3" }, + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/13/2cc466bddf26d0085f30a2b2bd56b7f8708b54a54db833eec97c5c69129b/testcontainers-4.15.0.tar.gz", hash = "sha256:085cde086337632e19002719460b7b80bbab2bdd51bb3ea04f77d0de96504706", size = 95340, upload-time = "2026-07-24T23:08:01.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl", hash = "sha256:8796c14e76604031ad39cf0ed3b8e9806283a1fbf5270965c2b1c594caa31b74", size = 160771, upload-time = "2026-07-24T23:08:00.13Z" }, +] + +[package.optional-dependencies] +mongodb = [ + { name = "pymongo" }, +] + [[package]] name = "tgcrypto-pyrofork" version = "1.2.8" @@ -1602,13 +1653,14 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "ruff" }, + { name = "testcontainers", extra = ["mongodb"] }, { name = "vulture" }, ] [package.metadata] requires-dist = [ { name = "aiohttp", specifier = "==3.14.3" }, - { name = "curl-cffi", marker = "extra == 'shortener-cf'", specifier = ">=0.7" }, + { name = "curl-cffi", marker = "extra == 'shortener-cf'", specifier = ">=0.15.0" }, { name = "jinja2", specifier = "==3.1.6" }, { name = "psutil", specifier = "==7.2.2" }, { name = "pymongo", specifier = "==4.18.0" }, @@ -1628,6 +1680,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pytest-cov", specifier = ">=5.0" }, { name = "ruff", specifier = ">=0.8" }, + { name = "testcontainers", extras = ["mongodb"], specifier = ">=4.13" }, { name = "vulture", specifier = ">=2.14" }, ] @@ -1729,6 +1782,70 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, ] +[[package]] +name = "wrapt" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ba/8dc25478ed234dacc7d83c671634f347d0bdfb65bf0502f41879cf2f15a9/wrapt-2.4.0.tar.gz", hash = "sha256:7082fc1f94b020ac275870c4af71b09cff22876fe6e9c4c0ad01ea21d217b288", size = 161179, upload-time = "2026-08-30T04:41:51.424Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/86/f9de4e11582ff96ad2199eeeceaa17faa27bbdc599243f520070c4f3de07/wrapt-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5c5c4c728cd22a36e4b8bb5df4a7d3bccaa865d27725b36eeb3b6f18fb2e1bc2", size = 96041, upload-time = "2026-08-30T04:39:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/c3/ab/1dbf50802bea3b46192fd0dc39bb0eb2e77a064c813b2bbd88d2888ad49f/wrapt-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7de5b8d94417e55c02be50cc226e0ae1209bbc73813bf691dff3979c94438115", size = 96269, upload-time = "2026-08-30T04:40:01.182Z" }, + { url = "https://files.pythonhosted.org/packages/cb/a3/a3b5cde1cd06e04b6e95134eb3187a0a7da607a530e7795b221d4e4fa819/wrapt-2.4.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6436e2bda993a3eb69a1b317fc831c8ebcafb5704c390859ebd49f81218c4bbb", size = 225787, upload-time = "2026-08-30T04:40:02.715Z" }, + { url = "https://files.pythonhosted.org/packages/cb/f7/d100f6c348b7669f19119cf890dcd4764623e2233af065586d110e0cd99e/wrapt-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e084558fbd112d2e1e34b0f5c71e45a3405bdad51a17150368a959bcf6697964", size = 226649, upload-time = "2026-08-30T04:40:04.647Z" }, + { url = "https://files.pythonhosted.org/packages/52/c6/3af8df515d5d7e92306957536f3468c6bdfecbe3659f99dbf09a468c2c4c/wrapt-2.4.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e78c947e18fadfd690c9420c30a96d221feeb93fc8f1cc00509b370ac16c3114", size = 206760, upload-time = "2026-08-30T04:40:06.332Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c5/40d355552bd3eb6c5186e26051c19b573d24d7896de42caa7937d6b5ca9f/wrapt-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:08d8378c4514ac8dcc0ace76044cf87a873e6a52b5e6109834c8fb9037f4441b", size = 223467, upload-time = "2026-08-30T04:40:07.829Z" }, + { url = "https://files.pythonhosted.org/packages/40/ab/d198eebdb39f0d7e182e771e590a36673489cd58cebdad8aa273dcf28e04/wrapt-2.4.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:93180c2199784dd6a1075b33f9ed636bd0966821edbece6b3d5379b1c4f0bb7d", size = 205358, upload-time = "2026-08-30T04:40:09.344Z" }, + { url = "https://files.pythonhosted.org/packages/1c/0e/974a60672ad507d39a3d8a1c6351ef37fe65b07240d000ceba5d2b83e9e9/wrapt-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d5e5eb76fb87e62752af751d2dcd9d1cd986b12037d2e1363d109ba716029e8", size = 214654, upload-time = "2026-08-30T04:40:10.923Z" }, + { url = "https://files.pythonhosted.org/packages/cc/5a/8b2db70206db0a4246758e0472ce344cb9636217113ef70640fc8d2ce874/wrapt-2.4.0-cp313-cp313-win32.whl", hash = "sha256:49bb5a572469e0e18163a8ec2aa972135a0929899ecbe627665f274506e1b5b4", size = 91171, upload-time = "2026-08-30T04:40:12.895Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1e/e782b511c680dbe7369c92e7d981484aacca0cda584da1f28a84cd9a8e1a/wrapt-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:b1737f46b1e4a81eb93500a7f2854319e1c7a86e8863fb050b7b4daadd5a4178", size = 96178, upload-time = "2026-08-30T04:40:14.336Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/095ba31123fa5dd482d6183c05200b061314aabbd5442c010aba4b03ff1c/wrapt-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:f1e9e088094f4895f84ab043e7d59401df137d663efbf1e80c82144882960830", size = 92949, upload-time = "2026-08-30T04:40:15.935Z" }, + { url = "https://files.pythonhosted.org/packages/1f/dd/1f269e4daf0c992f675e1ca2de6b1683b761c6d0aeb6c7b4b412486823ea/wrapt-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:788e473d1a6786d29d577b1e2bd95e214c09cdafde84907c522c31069c9acfac", size = 96386, upload-time = "2026-08-30T04:40:17.584Z" }, + { url = "https://files.pythonhosted.org/packages/be/42/7ecef06d33c0121c68d66a8a695efe67ebaa57218c1c61c585eca2a6117a/wrapt-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:947bd4b3438167b3638bf5477cb83a068a586ffb6d331ac427f39839c2b93b3c", size = 96532, upload-time = "2026-08-30T04:40:19.116Z" }, + { url = "https://files.pythonhosted.org/packages/c2/e3/8fdc9eba0e6cbbfe8303e1e807d734691309a27970b2ea458d099f1a46b0/wrapt-2.4.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3a69161cae7f0dca44c89c1d14146b4a0508a0c3cad98b3f2db1f4e9016c94ba", size = 228775, upload-time = "2026-08-30T04:40:20.604Z" }, + { url = "https://files.pythonhosted.org/packages/f4/77/4ac5882abfb29bf9821c5fa5cf9f30241a194e0f47faa2682b9b29765278/wrapt-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0536f5d85ff6a157ebe7e0fe08c5479943742cf1ce59569075a66159efcbc495", size = 229029, upload-time = "2026-08-30T04:40:22.186Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c5/8a3608311a02faf3e5c072da38d06a7c623150fc258e29f18fe377d91703/wrapt-2.4.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5f041ed6a4d571010944bd6cfad9072db463e1851877b6d3227467a44af37456", size = 210436, upload-time = "2026-08-30T04:40:23.953Z" }, + { url = "https://files.pythonhosted.org/packages/de/90/e0cbc43f435fd39df25460e9f173e7b96f3dac5c7f66be41c7227166f021/wrapt-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f7fed45dbadf5d98a52bfff9624d3cca00affeb9543d493c9632b7a53cdd35c9", size = 226586, upload-time = "2026-08-30T04:40:25.507Z" }, + { url = "https://files.pythonhosted.org/packages/81/6c/7e5f2143228635ec139ef6df733dc477049f7d96a0c49deb23944a73ed6a/wrapt-2.4.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5cc2e7c7b6032e11a2b367a9baadaf0c5241feff2d8205260d87f1aa6dbdf84b", size = 208880, upload-time = "2026-08-30T04:40:27.128Z" }, + { url = "https://files.pythonhosted.org/packages/10/16/1de84402bb7a0916e10739bf6586e031244172b299e87c8cff2a04baf9ff/wrapt-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:72826910a1cf5a081234720fd43011304b899acfee219af49148155b4d795533", size = 216689, upload-time = "2026-08-30T04:40:28.844Z" }, + { url = "https://files.pythonhosted.org/packages/20/19/cd6bd5050381a541b44be97c4e0994eed60c5f439f4314f95eb5777d6c1a/wrapt-2.4.0-cp314-cp314-win32.whl", hash = "sha256:0eca69c9e93518240abe8801fb9b2726116a6e48172e4564c2651a2e14521747", size = 91581, upload-time = "2026-08-30T04:40:30.592Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f8/b642f3184619adde676ad449030bcbeae6cc78ea07a92f0b5fddeec4c4e6/wrapt-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:63b94f401d7ae3a9a3027472fd3a3ff38afd2ed293b2f0b3b84a6d133a9f99a3", size = 96510, upload-time = "2026-08-30T04:40:32.1Z" }, + { url = "https://files.pythonhosted.org/packages/4d/3b/3415a18b91221261eeac85bf8ee23dfb0e2a39d76b9703a797efca177439/wrapt-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:6b3e082d43f592fcd381aee46354a11ce887a813ce5bbcedd9766fd681723c09", size = 93648, upload-time = "2026-08-30T04:40:33.563Z" }, + { url = "https://files.pythonhosted.org/packages/ac/90/80cf6a09e9599a11249775928df9bb790b82471e4312b847a861ffb2c2ed/wrapt-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09064c7be688c38c3ff125ce86bc26b69b5d78dd56062c3ddd9c814b2a25f1e1", size = 99615, upload-time = "2026-08-30T04:40:35.134Z" }, + { url = "https://files.pythonhosted.org/packages/b2/da/c1d3245abb911a42584f8f7e9781995bdc41345c7affba75cf7e376c85ac/wrapt-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4f8ddff4bbb75916be36da5169b8b9d475b59a1bd24acdb7551bb2c71be9aaac", size = 100031, upload-time = "2026-08-30T04:40:36.641Z" }, + { url = "https://files.pythonhosted.org/packages/84/46/8ec4941d0abbb010df7caf0a34840ca0128177389843b0f5ef2f9ee48ac5/wrapt-2.4.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e9f8017443595870aa31f46125553a5c55ce95a26a267b96261baee6ba566d83", size = 269389, upload-time = "2026-08-30T04:40:38.212Z" }, + { url = "https://files.pythonhosted.org/packages/14/b5/a0ae1b431cc1f49a545d32b8b678a5788c50583ecf0ecb85dc0c7f95b4f6/wrapt-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:328eb2d978ca3a6ae25f8d8fe560bf8f4bc9778b5932e7b142664eef05b92e8f", size = 281081, upload-time = "2026-08-30T04:40:40.045Z" }, + { url = "https://files.pythonhosted.org/packages/c7/24/dfaf53dd3bdb0703524a9367b48e2a64ea86433fcc854b5f14be6a8e0e39/wrapt-2.4.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a057d376d994da6bd1bbf955ecfda699aa7353826f98847f5605e1801abdfd4", size = 249637, upload-time = "2026-08-30T04:40:41.657Z" }, + { url = "https://files.pythonhosted.org/packages/3e/27/bdd82044d7503c2bfa78afcc89881f82a1b82b5d2013aabab853d339ce2a/wrapt-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3367a5212212c9393e0d3ca6ae029b3a8fa40c5896e4a985d43fe8a4b8322f0d", size = 275322, upload-time = "2026-08-30T04:40:43.408Z" }, + { url = "https://files.pythonhosted.org/packages/c4/82/04f4228eb3fb348d660dd1ea7225e53665b1809df2273ff4861d4d33b741/wrapt-2.4.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c4fca1e63af6675af3df7cdfcd5a0c878b5e655c7e48611ced9dc8d62183a11d", size = 247292, upload-time = "2026-08-30T04:40:45.457Z" }, + { url = "https://files.pythonhosted.org/packages/a2/20/67b2968fa9200458446c51b36a435adb6906083428b70fafb4caf92d4dc2/wrapt-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:694005fdc3002ade0f21641408c588028abde03c85961f3ba7727d8bead3ed6b", size = 264586, upload-time = "2026-08-30T04:40:47.079Z" }, + { url = "https://files.pythonhosted.org/packages/d7/fd/0db9ba03e08a7663f52455e95520c723f567bc037bffc6699950fcc456c4/wrapt-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:332d9bad7e9b718974bb2a576504c4956f45b4a0fcd7b3bb7827279167550464", size = 93752, upload-time = "2026-08-30T04:40:48.81Z" }, + { url = "https://files.pythonhosted.org/packages/3f/87/ced171220935c696b157207385fa6be5675558a74655479f071d95a00f1d/wrapt-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d57264c9dfcf37d2bf0b0fbec68d0f6184fc5617267619ada04d03e8b0231f3", size = 99890, upload-time = "2026-08-30T04:40:50.407Z" }, + { url = "https://files.pythonhosted.org/packages/a3/af/4a10c9a6d3b7ae41f830978c28d33a59ceb29537bd6875d2abfe78db4b41/wrapt-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f43af38a642c3d6062e9740d8f5cc0feb5dbe0da516702df892147393b8cb14d", size = 96033, upload-time = "2026-08-30T04:40:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/a0/df/3a0b6225ab88bd47090df70391c059a3308057638f8fc0ae32e8ac9d1886/wrapt-2.4.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:430fde1a116df3ceb5c29035de1da6609b70e680d9b8ce3ee624422f3fe0978c", size = 96389, upload-time = "2026-08-30T04:40:53.555Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6f/803b0d0e14de11781f0e938e6f7d6e29e79652139fe70d7513460357ac78/wrapt-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7d28f8f35a02d49f75f57fa4e755db4ba33f65841c0de64cd65b253916f5bf06", size = 96557, upload-time = "2026-08-30T04:40:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e8/46571e1218d0494604a7aadc4c898c738c4b179052327ee1e57e278cebd6/wrapt-2.4.0-cp315-cp315-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efd9a4be6785295e471f71efdf5682bd11d5b822b9665e6e1b4844917cf2f7ac", size = 229230, upload-time = "2026-08-30T04:40:56.703Z" }, + { url = "https://files.pythonhosted.org/packages/78/2e/0cab15fcaec56096a5734feace3620bc01edc885653be04bd756f84a6784/wrapt-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75529a2fb569a671cf162f762c1b576f569f571b55ec7f3481258ca842ba507f", size = 229444, upload-time = "2026-08-30T04:40:58.51Z" }, + { url = "https://files.pythonhosted.org/packages/e7/9e/a92c049371a2675f98a0381ab2951f984866d1ba4de0e0771d6a31fdaa2b/wrapt-2.4.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66e7512c0d324cc37bba1def2be1fc365cbb685d3aa393a8f6f4d2d00202881d", size = 212482, upload-time = "2026-08-30T04:41:00.224Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3b/8b5b57d0ff24edcd3421dbaeb4e94c89be3616824e47708f4e13f25ae3d7/wrapt-2.4.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:5f3bdfc35c83b562fcaebc0f24593045e5ed9f3b633adafd35222718a0ec38fa", size = 227017, upload-time = "2026-08-30T04:41:01.918Z" }, + { url = "https://files.pythonhosted.org/packages/0e/20/124b40bfd9585848db5a5aa6741d0c8dbf378dd995c6c2d95f090d9cf540/wrapt-2.4.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:d5f45bead708e2c0014be5e98531ce7202916b098a208c7be83c6ceb0a2559fa", size = 210498, upload-time = "2026-08-30T04:41:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/4b/bf/89db9d5a80a9f2af52b24bdfdb5392be80bc0f0fd39fc39d1aab72afd0bd/wrapt-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d294576fddac636589e4deccfe782e8f429da10f167c1985c4d51071de3672b7", size = 217046, upload-time = "2026-08-30T04:41:05.473Z" }, + { url = "https://files.pythonhosted.org/packages/3b/0b/021c9d6ce64c639894bffdaa7a895ddd4187abfefb2873ce55e536cd9d56/wrapt-2.4.0-cp315-cp315-win32.whl", hash = "sha256:0191d717dfbb8e519e7bfd4775e5b9bd57e359b3a09ab5db1ea47f6025b4d845", size = 91591, upload-time = "2026-08-30T04:41:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6ebd944041cea0ac4a108a4739510ed2dc891a3f3216e4f7bf0650f5b5a6/wrapt-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:e8df31a126a0a247c1aa379e30873839de03912dea09ca360c680f3625d815df", size = 96517, upload-time = "2026-08-30T04:41:08.671Z" }, + { url = "https://files.pythonhosted.org/packages/96/84/7c5e52e450f80ba76fd0282dccf7c79cd004ebd8ccabd0903064d3d2c56e/wrapt-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:e9e7e94472f0e3f1447caf27e1939eb384d0e87972a35a05f5c2e0968e9c01af", size = 93652, upload-time = "2026-08-30T04:41:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/35/89/f08ff45d7646de29750932805cc3b1e86b6ac3128015b293ed45fa8efe86/wrapt-2.4.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:8828369b7d3e93c547cc8ad931b5a57b4e8d174035c82762fb1091e7d05ac9f5", size = 99610, upload-time = "2026-08-30T04:41:11.933Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c2/f9a3c40901a36c6bb7ecaff8e1e54af78fa7fa0b95a0e54d13d3a24c8a0a/wrapt-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:413e757dce7a43fcda8bb8441994b1127492ffac6a5803af777d44516df8c6e2", size = 100064, upload-time = "2026-08-30T04:41:13.492Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e1/e2437f17f2a1ec292056e2fcafe1248269ebc39502f2ffe79424bf86f8a6/wrapt-2.4.0-cp315-cp315t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:75944792cf6b99262d649d55710bf5901f7013fbb212c7a1d736b97a20517607", size = 269421, upload-time = "2026-08-30T04:41:15.238Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d0/c98d6548dc4c7d12ab9baa192234ca1a57e141afd283252b448faddbd9ef/wrapt-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:648d1d4f94e8a0a1656675c755f40d2f0ee5fe92c449ab45326f4ecc2738cbe8", size = 281452, upload-time = "2026-08-30T04:41:16.939Z" }, + { url = "https://files.pythonhosted.org/packages/a3/57/673168e00aa03725148ce621ed201b75df4e787a57acd48fecefd2725600/wrapt-2.4.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a112a1bfdd2621e4344cb0a32dbaab80636b32dac1b055d03fbb2a67d806d1db", size = 250358, upload-time = "2026-08-30T04:41:18.716Z" }, + { url = "https://files.pythonhosted.org/packages/78/0b/f2e576de5bf53ef5b578470104ea93f33e273a704c825131bc1719fffc42/wrapt-2.4.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:0972cd025f4c86fa2d8abd953d9f875779935343af58b4ce019ff89573fc65bd", size = 275654, upload-time = "2026-08-30T04:41:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/9347b2e236346b1ba4cb28b82b205b8a377bb2da9417cb81bbe3d25816d7/wrapt-2.4.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:c246aaed719dcdb62eeb7b8d9306a6237777226ef3baad35919c4ae134c91ce7", size = 248662, upload-time = "2026-08-30T04:41:22.371Z" }, + { url = "https://files.pythonhosted.org/packages/a5/36/3b84d9e1ac8393bf2c94272760a2d361dc394ac30301e6d6dbd6583ade2d/wrapt-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1656de3835f760781c9b974bce07d8c04edb9c9ad7ad67264aee69cd68a1db09", size = 264813, upload-time = "2026-08-30T04:41:24.116Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a2/de7b1de1702667b4a048318e301e26887268c17b07c8b9797cea06b10aee/wrapt-2.4.0-cp315-cp315t-win32.whl", hash = "sha256:d8e6e1e5dc684dfce7c33fc8b67a08ba2af94f3a45cfc70d5c1d6a839d2caf97", size = 93753, upload-time = "2026-08-30T04:41:25.793Z" }, + { url = "https://files.pythonhosted.org/packages/09/50/4e7ef58c4eb058861ceddc0d1f94a6ed87f62e1cb27783c60b2897ef7e58/wrapt-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:85ed3c67fd39e8d9a36c224758cb6f2f4eb277d07ea677930caa0008c18ec002", size = 99888, upload-time = "2026-08-30T04:41:27.305Z" }, + { url = "https://files.pythonhosted.org/packages/68/64/d15740c763dd0ddea2338ad42e3bd4a84f8702e16083e7ff61674c504a13/wrapt-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:36b56a4fba13b34ed8ff307557325fff215de0a58b5dbaef2c50e4d8aa39dbd1", size = 96039, upload-time = "2026-08-30T04:41:29.062Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/fafe0002f572ced999c792cfe8b05d39269c63d8193d15d25bd828bcad7a/wrapt-2.4.0-py3-none-any.whl", hash = "sha256:18aabd9301d06026f5900538051773d6f87f65ae02cdc60de482df978513dc0a", size = 73713, upload-time = "2026-08-30T04:41:49.805Z" }, +] + [[package]] name = "yarl" version = "1.24.5" From 2dcda9419a181ca842dc41e4b7d6f96456d36d5e Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 14:00:07 +0000 Subject: [PATCH 09/49] fix(security): resolve CodeQL alerts on PR diff - shortener: replace substring host matching with parsed-hostname exact/suffix match (_host_matches) across all 4 HTTP plugins; lookalikes like 'bitly.com.evil.com' or 'evil.com/bitly.com' no longer select a provider plugin (CodeQL high: py/incomplete-url-substring-sanitization) - /activate: strict shape check (_is_activation_token) on the token before building the t.me redirect -- tokens are token_urlsafe(32), 43 URL-safe chars; malformed input now 400s without a DB roundtrip (CodeQL medium: py/url-redirection) - tests: +21 regression tests (host matching incl. adversarial lookalikes, activation token shape incl. injection payloads) --- Thunder/server/stream_routes.py | 16 +++++++++++ Thunder/utils/shortener.py | 24 ++++++++++++++--- tests/test_unit/test_shortener.py | 39 +++++++++++++++++++++++++++ tests/test_unit/test_stream_routes.py | 20 ++++++++++++++ 4 files changed, 95 insertions(+), 4 deletions(-) diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index dcb794c..ec1654c 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -287,6 +287,20 @@ async def health_endpoint(request): ) +_ACTIVATION_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{43}\Z") + + +def _is_activation_token(token: str) -> bool: + """Strict shape check for tokens issued by tokens.generate(). + + Activation tokens are ``secrets.token_urlsafe(32)`` -- exactly 43 + URL-safe base64 chars. Validating the shape before interpolation into + the t.me redirect keeps untrusted input out of the Location header + (CodeQL py/url-redirection) and skips a DB roundtrip for garbage input. + """ + return bool(_ACTIVATION_TOKEN_RE.fullmatch(token)) + + @routes.get("/activate/{token}") async def activate_endpoint(request: web.Request): """M8: web entry for activation -- shorteners can produce real URLs.""" @@ -294,6 +308,8 @@ async def activate_endpoint(request: web.Request): username = getattr(StreamBot, "username", None) if not token: raise web.HTTPBadRequest(text="Missing activation token") + if not _is_activation_token(token): + raise web.HTTPBadRequest(text="Malformed activation token") if not username: raise web.HTTPServiceUnavailable(text="Bot is still starting; try again shortly.") raise web.HTTPFound(f"https://t.me/{username}?start={token}") diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py index df11bf5..f941059 100644 --- a/Thunder/utils/shortener.py +++ b/Thunder/utils/shortener.py @@ -57,13 +57,29 @@ def _validate_short_url(short_url: str, domain: str) -> bool: except ValueError: return False + @staticmethod + def _host_matches(domain: str, *bases: str) -> bool: + """Exact-host or subdomain match against the provider's hostnames. + + Substring checks (``"bitly.com" in domain``) accept lookalikes such + as ``evil.com/bitly.com`` or ``bitly.com.evil.com`` (CodeQL + py/incomplete-url-substring-sanitization); parsing the hostname + closes those. A trailing root dot (FQDN form) is tolerated. + """ + try: + host = urlparse(f"https://{domain}").hostname or "" + except ValueError: + return False + host = host.removesuffix(".") + return any(host == base or host.endswith(f".{base}") for base in bases) + class LinkvertisePlugin(ShortenerPlugin): """Offline constructor: no HTTP call involved, host check not needed.""" @classmethod def matches(cls, domain: str) -> bool: - return "linkvertise" in domain + return cls._host_matches(domain, "linkvertise.com") async def shorten( self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str @@ -82,7 +98,7 @@ async def shorten( class BitlyPlugin(ShortenerPlugin): @classmethod def matches(cls, domain: str) -> bool: - return "bitly.com" in domain or "bit.ly" in domain + return cls._host_matches(domain, "bitly.com", "bit.ly") async def shorten( self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str @@ -105,7 +121,7 @@ async def shorten( class OuoIoPlugin(ShortenerPlugin): @classmethod def matches(cls, domain: str) -> bool: - return "ouo.io" in domain + return cls._host_matches(domain, "ouo.io") async def shorten( self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str @@ -123,7 +139,7 @@ async def shorten( class CuttLyPlugin(ShortenerPlugin): @classmethod def matches(cls, domain: str) -> bool: - return "cutt.ly" in domain + return cls._host_matches(domain, "cutt.ly") async def shorten( self, session: aiohttp.ClientSession, url: str, api_key: str, domain: str diff --git a/tests/test_unit/test_shortener.py b/tests/test_unit/test_shortener.py index ea7b662..fc401e1 100644 --- a/tests/test_unit/test_shortener.py +++ b/tests/test_unit/test_shortener.py @@ -5,8 +5,10 @@ from Thunder.utils.shortener import ( BitlyPlugin, + CuttLyPlugin, GenericShortenerPlugin, LinkvertisePlugin, + OuoIoPlugin, ShortenerSystem, ) @@ -64,3 +66,40 @@ async def test_cache_hit_is_returned_without_http(): system.ready = True system._cache["https://long.example/a"] = "https://shrinkme.dev/xyz" assert await system.short_url("https://long.example/a") == "https://shrinkme.dev/xyz" + + +@pytest.mark.unit +@pytest.mark.parametrize( + "domain,plugin,expected", + [ + # legit hosts match (exact, subdomain, FQDN trailing dot) + ("bitly.com", BitlyPlugin, True), + ("bit.ly", BitlyPlugin, True), + ("www.bit.ly", BitlyPlugin, True), + ("bit.ly.", BitlyPlugin, True), + ("linkvertise.com", LinkvertisePlugin, True), + ("sub.linkvertise.com", LinkvertisePlugin, True), + ("ouo.io", OuoIoPlugin, True), + ("cutt.ly", CuttLyPlugin, True), + # lookalikes that substring matching used to accept must not match + ("evil.com/bitly.com", BitlyPlugin, False), + ("bitly.com.evil.com", BitlyPlugin, False), + ("notbitly.com", BitlyPlugin, False), + ("bit.ly.evil.io", BitlyPlugin, False), + ("evillinkvertise.com", LinkvertisePlugin, False), + ("linkvertise.com.evil.net", LinkvertisePlugin, False), + ("ouo.io.evil.dev", OuoIoPlugin, False), + ("cutt.ly.evil.org", CuttLyPlugin, False), + # cross-provider isolation + ("ouo.io", BitlyPlugin, False), + ("bit.ly", OuoIoPlugin, False), + ], +) +def test_plugin_host_matching(domain, plugin, expected): + assert plugin.matches(domain) is expected + + +@pytest.mark.unit +def test_registry_lookup_rejects_lookalike_host(): + system = ShortenerSystem() + assert system._get_plugin_class("bitly.com.evil.com") is GenericShortenerPlugin diff --git a/tests/test_unit/test_stream_routes.py b/tests/test_unit/test_stream_routes.py index 7e2c49d..85cedfc 100644 --- a/tests/test_unit/test_stream_routes.py +++ b/tests/test_unit/test_stream_routes.py @@ -5,6 +5,7 @@ from aiohttp.web import HTTPBadRequest, HTTPRequestRangeNotSatisfiable from Thunder.server.stream_routes import ( + _is_activation_token, build_content_disposition, parse_media_request, parse_range_header, @@ -117,3 +118,22 @@ def test_non_latin_gets_ascii_fallback(self): assert header.startswith("attachment;") assert 'filename="' in header and "视钑" not in header.split("filename*")[0] assert "%E8%A7%86%E9%A2%91" in header # encoded filename* + + +class TestActivationTokenShape: + @pytest.mark.unit + def test_accepts_real_token_urlsafe_shape(self): + import secrets + + assert _is_activation_token(secrets.token_urlsafe(32)) + + @pytest.mark.unit + def test_rejects_wrong_length_and_chars(self): + assert not _is_activation_token("short") + assert not _is_activation_token("a" * 42) + assert not _is_activation_token("a" * 44) + assert not _is_activation_token("a" * 42 + "$$") + # redirect / header injection payloads must fail the shape check + assert not _is_activation_token("../../evil.com?") + assert not _is_activation_token("x\r\nLocation: https://evil.com") + assert not _is_activation_token("a" * 20 + "/" + "b" * 22) From 5c5d311b1a1c368471faac7e626f86bfabab59c6 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 14:05:52 +0000 Subject: [PATCH 10/49] fix(security): percent-encode activation token in t.me redirect CodeQL py/url-redirection still tracked the shape-validated token into the Location header; build the deep link via quote_plus(safe='') so the token can only occupy the query-value slot. No-op for valid tokens (already URL-safe); +2 tests. --- Thunder/server/stream_routes.py | 14 ++++++++++++-- tests/test_unit/test_stream_routes.py | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index ec1654c..f58dd7b 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -4,7 +4,7 @@ import secrets import time from collections.abc import Mapping -from urllib.parse import quote, unquote +from urllib.parse import quote, quote_plus, unquote from aiohttp import web @@ -301,6 +301,16 @@ def _is_activation_token(token: str) -> bool: return bool(_ACTIVATION_TOKEN_RE.fullmatch(token)) +def _telegram_activate_url(username: str, token: str) -> str: + """Build the t.me deep link with the token percent-encoded. + + ``quote_plus`` guarantees the token can only ever occupy the query + value slot (no ``&``/``#``/CR/LF can reshape the URL) -- a no-op for + shape-valid tokens, which are already URL-safe. + """ + return f"https://t.me/{username}?start={quote_plus(token, safe='')}" + + @routes.get("/activate/{token}") async def activate_endpoint(request: web.Request): """M8: web entry for activation -- shorteners can produce real URLs.""" @@ -312,7 +322,7 @@ async def activate_endpoint(request: web.Request): raise web.HTTPBadRequest(text="Malformed activation token") if not username: raise web.HTTPServiceUnavailable(text="Bot is still starting; try again shortly.") - raise web.HTTPFound(f"https://t.me/{username}?start={token}") + raise web.HTTPFound(_telegram_activate_url(username, token)) @routes.get("/status", allow_head=True) diff --git a/tests/test_unit/test_stream_routes.py b/tests/test_unit/test_stream_routes.py index 85cedfc..8e91080 100644 --- a/tests/test_unit/test_stream_routes.py +++ b/tests/test_unit/test_stream_routes.py @@ -6,6 +6,7 @@ from Thunder.server.stream_routes import ( _is_activation_token, + _telegram_activate_url, build_content_disposition, parse_media_request, parse_range_header, @@ -137,3 +138,16 @@ def test_rejects_wrong_length_and_chars(self): assert not _is_activation_token("../../evil.com?") assert not _is_activation_token("x\r\nLocation: https://evil.com") assert not _is_activation_token("a" * 20 + "/" + "b" * 22) + + +class TestTelegramActivateUrl: + @pytest.mark.unit + def test_valid_token_produces_expected_deep_link(self): + token = "a" * 43 + assert _telegram_activate_url("MyBot", token) == f"https://t.me/MyBot?start={token}" + + @pytest.mark.unit + def test_hostile_token_cannot_reshape_url(self): + url = _telegram_activate_url("MyBot", "x&start=evil#frag") + assert url.startswith("https://t.me/MyBot?start=") + assert "&" not in url[24:] and "#" not in url[24:] From 8f9fea71e247d1d7916e717701bbca90b3f3c636 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 14:15:48 +0000 Subject: [PATCH 11/49] fix(security): constant-prefix concat for activate redirect CodeQL py/url-redirection models only '+ right-operand' as a sanitizer (f-strings and quote_plus are not barriers -- verified against UrlRedirectCustomizations.qll on github/codeql@main). Build the deep link so the constant 'https://t.me/' prefix is provably outside user control; output is byte-identical for valid tokens. --- Thunder/server/stream_routes.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index f58dd7b..df185e2 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -304,11 +304,17 @@ def _is_activation_token(token: str) -> bool: def _telegram_activate_url(username: str, token: str) -> str: """Build the t.me deep link with the token percent-encoded. - ``quote_plus`` guarantees the token can only ever occupy the query - value slot (no ``&``/``#``/CR/LF can reshape the URL) -- a no-op for - shape-valid tokens, which are already URL-safe. + ``quote_plus(safe='')`` guarantees the token can only ever occupy the + query-value slot (no ``&``/``#``/CR/LF can reshape the URL) -- a no-op + for shape-valid tokens, which are already URL-safe. + + Built with ``+`` concatenation, not an f-string: the redirect target is + then provably constant-prefixed ("https://t.me/"), which is exactly the + safety property CodeQL's py/url-redirection sanitizer model recognizes + (right-operand-of-concat sanitizer; formatting is not modeled). Do not + "modernize" this back to an f-string -- it would re-flag the alert. """ - return f"https://t.me/{username}?start={quote_plus(token, safe='')}" + return "https://t.me/" + username + "?start=" + quote_plus(token, safe="") @routes.get("/activate/{token}") From 2db0c3b047e26b4b8941987ff495acbae2c9be38 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 15:42:11 +0000 Subject: [PATCH 12/49] =?UTF-8?q?audit:=208-agent=20second-pass=20fixes=20?= =?UTF-8?q?=E2=80=94=20verified=20defects=20+=20leanness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Behavioral (verified by repro/code-reading): - caption commands: /link & /dc parsed msg.text (None for captions under pyrofork's caption-matching filter) -> AttributeError dead air; now text-or-caption - channel-ban gate: per-post DB find_one, no cache, no invalidation; now flag-cached with add/remove invalidation (fail-open kept deliberately: leave_chat on a Mongo outage would be destructive) - legacy route error masking: get_file_info swallowed every exception into an unread 'error' key -> transient outages surfaced as 404; now propagates (route ladder maps 404/500 correctly) - markup injection (pyrofork DEFAULT = markdown-then-HTML, so html.escape alone is insufficient): /dc templates converted to HTML + escaped + ParseMode.HTML pinned; force-sub prompt escaped; ban reason escaped; file-request sends pin ParseMode.HTML - /restart: os.execv skipped the M13 touch-buffer drain, losing view counts; now drains before execv - access-log middleware logged nothing for non-HTTP 500s (response unbound NameError swallowed); response now initialized to None - integration fixture: env+reload never rebound the frozen Var/db copies -> tier could not pass; now binds Database directly and rebinds module handles, restoring state after - Docker HEALTHCHECK: naive PORT parse broke on inline comments in the shipped sample config; now comment-stripping - GLOBAL_RPS_LIMIT dead without GLOBAL_RATE_LIMIT: boot warning (M6) - token_col: compound (user_id, activated, expires_at) index added - MSG_TOKEN_FAILED: error_id kwarg was silently dropped (no slot) - update.py: rejects leading-dash UPSTREAM_REPO/BRANCH (argv hygiene) - _validate_short_url: trailing-dot FQDN now tolerated (parity with _host_matches); import_plugins glob sorted; get_user deduped; force-channel button reuses cached get_force_info; render_template reuses quote_media_name; breaker comment states real invariant Leanness (dead code, vulture-verified): - deleted whitelist.py (entirely stale; vulture passes without), ShortenerError, module-level request_executor, duplicate status_options handler, _REDACT_SEGMENTS, production-dead mime_for Infra: - Dockerfile: drop build-essential/libssl-dev (all wheels, ~150MB) - image tags: + SHA tag for rollback; .dockerignore: cache dirs - pre-commit ruff rev v0.8.4 -> v0.16.6 (matches lock); Makefile audit now audits the locked env (CI parity); dependabot uv.lock note; README: dead speedtest section removed, docker run now mounts config.env (documented flow was broken) Gates: ruff/format clean, mypy 0 (35 files), 130 tests PASS, vulture (whitelist-free) clean, bandit clean, uv lock --check PASS --- .dockerignore | 3 ++ .github/dependabot.yml | 3 ++ .github/workflows/quality.yml | 8 ++++-- .pre-commit-config.yaml | 2 +- Dockerfile | 4 +-- Makefile | 34 +++++++++++------------ README.md | 14 ++-------- Thunder/__main__.py | 2 +- Thunder/bot/plugins/admin.py | 6 ++++ Thunder/bot/plugins/callbacks.py | 26 ++++++++--------- Thunder/bot/plugins/common.py | 13 +++++++-- Thunder/bot/plugins/stream.py | 28 +++++++++++++++++-- Thunder/server/__init__.py | 7 +++-- Thunder/server/stream_routes.py | 5 ---- Thunder/utils/bot_utils.py | 20 ++++++------- Thunder/utils/custom_dl.py | 11 ++++---- Thunder/utils/database.py | 17 +++++++++++- Thunder/utils/decorators.py | 7 ++++- Thunder/utils/force_channel.py | 9 +++++- Thunder/utils/media_types.py | 5 ---- Thunder/utils/messages.py | 19 +++++++------ Thunder/utils/rate_limiter.py | 19 +++++++++---- Thunder/utils/render_template.py | 5 ++-- Thunder/utils/shortener.py | 9 ++---- pyproject.toml | 2 +- tests/integration/test_mongo.py | 30 +++++++++++++------- tests/test_unit/test_media_types.py | 2 -- tests/test_unit/test_rate_limiter_park.py | 2 +- update.py | 5 ++++ whitelist.py | 16 ----------- 30 files changed, 191 insertions(+), 142 deletions(-) delete mode 100644 whitelist.py diff --git a/.dockerignore b/.dockerignore index c3ad3ef..a686fc2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,6 +6,9 @@ .vscode __pycache__ *.py[cod] +.pytest_cache +.ruff_cache +.mypy_cache *.session* logs/ tests/ diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a9c78f9..8ad62c7 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,6 @@ +# NOTE: Dependabot cannot regenerate uv.lock. When a pip PR arrives, +# run `uv lock` locally, commit the updated lockfile, and push it to +# the PR branch -- CI's `uv lock --check` gate fails otherwise. version: 2 updates: - package-ecosystem: pip diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 2d32fa2..4b6d6a8 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -50,8 +50,8 @@ jobs: - name: Bandit (medium+ severity) run: uv run bandit -r Thunder -ll --skip B101 - - name: Vulture (dead-code gate, whitelisted) - run: uv run vulture Thunder whitelist.py --min-confidence 80 + - name: Vulture (dead-code gate) + run: uv run vulture Thunder --min-confidence 80 - name: Dependency count gate (leanness is permanent) run: | @@ -82,4 +82,6 @@ jobs: with: context: . push: true - tags: fyaz05/thunder:latest + tags: | + fyaz05/thunder:latest + fyaz05/thunder:${{ github.sha }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3f6f8a8..9cd76a6 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.8.4 + rev: v0.16.6 hooks: - id: ruff args: [--fix] diff --git a/Dockerfile b/Dockerfile index 3ebe706..e49654f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,8 +8,6 @@ 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/* \ && useradd --create-home --shell /bin/bash thunder @@ -27,6 +25,6 @@ USER thunder # L8: container health follows /health (M3); PORT may come from config.env # (dotenv does not override pre-set env vars, so the check reads both) HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \ - CMD python3 -c "import os,urllib.request; port=os.getenv('PORT','8080'); port=[l.split('=',1)[1].strip().strip(chr(34)).strip(chr(39)) for l in (open('config.env').read().splitlines() if os.path.exists('config.env') else []) if l.strip().startswith('PORT') and '=' in l] or [port]; urllib.request.urlopen('http://127.0.0.1:'+port[0]+'/health', timeout=5)" + CMD python3 -c "import os,urllib.request; port=os.getenv('PORT','8080'); port=[l.split('=',1)[1].split('#',1)[0].strip().strip(chr(34)).strip(chr(39)) for l in (open('config.env').read().splitlines() if os.path.exists('config.env') else []) if l.strip().startswith('PORT') and '=' in l] or [port]; urllib.request.urlopen('http://127.0.0.1:'+port[0]+'/health', timeout=5)" CMD ["bash", "thunder.sh"] diff --git a/Makefile b/Makefile index 43535b9..9e144fb 100644 --- a/Makefile +++ b/Makefile @@ -4,32 +4,32 @@ # NOTE: recipes MUST be indented with hard TABs, not spaces. format: - ruff check Thunder/ update.py --fix - ruff format Thunder/ update.py + ruff check Thunder/ update.py --fix + ruff format Thunder/ update.py lint: - ruff check Thunder/ update.py - mypy Thunder --ignore-missing-imports + ruff check Thunder/ update.py + mypy Thunder --ignore-missing-imports test: - pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 + pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 coverage: - pytest -m unit --cov=Thunder --cov-report=html + pytest -m unit --cov=Thunder --cov-report=html audit: - pip-audit -r requirements.txt - bandit -r Thunder -ll --skip B101 - vulture Thunder whitelist.py --min-confidence 80 - @count=$$(grep -cE '^[a-zA-Z0-9_-]+==' requirements.txt); \ - echo "Direct runtime deps: $$count"; \ - if [ "$$count" -gt 8 ]; then \ - echo "ERROR: dependency count increased beyond 8; justify or remove."; \ - exit 1; \ - fi + uv run pip-audit + bandit -r Thunder -ll --skip B101 + vulture Thunder --min-confidence 80 + @count=$$(grep -cE '^[a-zA-Z0-9_-]+==' requirements.txt); \ + echo "Direct runtime deps: $$count"; \ + if [ "$$count" -gt 8 ]; then \ + echo "ERROR: dependency count increased beyond 8; justify or remove."; \ + exit 1; \ + fi run: - python3 -m Thunder + python3 -m Thunder clean: - rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ + rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ diff --git a/README.md b/README.md index 29fb40d..a18f316 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ - [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) @@ -265,15 +264,6 @@ Thunder implements a sophisticated multi-tier rate limiting system designed for - **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 -``` - -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. @@ -304,7 +294,9 @@ nano config.env # Edit your settings # 3. Build and run docker build -t thunder . -docker run -d --name thunder -p 8080:8080 thunder +# config.env is excluded from the build context; mount it at runtime +docker run -d --name thunder -p 8080:8080 \ + -v $(pwd)/config.env:/app/config.env:ro thunder ```
diff --git a/Thunder/__main__.py b/Thunder/__main__.py index 79ea329..4c9e93d 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -83,7 +83,7 @@ def _log_index_failure(done_task: asyncio.Task) -> None: async def import_plugins(): print("╠════════════════════ IMPORTING PLUGINS ════════════════════╣") - plugins = glob.glob(PLUGIN_PATH) + plugins = sorted(glob.glob(PLUGIN_PATH)) # deterministic registration order if not plugins: print(" β–Ά No plugins found to import!") return 0 diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py index 875bc12..475d96e 100644 --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -229,6 +229,12 @@ async def show_stats(client: Client, message: Message): async def restart_bot(client: Client, message: Message): msg = await reply(message, text=MSG_RESTARTING) await db.add_restart_message(msg.id, message.chat.id) + # mirror __main__ teardown ordering (M13): the touch buffer batches view + # counts for up to a few seconds -- execv skips every finally block, so + # drain it here or the restart loses those increments + from Thunder.utils.canonical_files import drain_background_touch_tasks + + await drain_background_touch_tasks() os.execv("/bin/bash", ["bash", "thunder.sh"]) diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py index 11a1c7b..c09a7d5 100644 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -10,6 +10,7 @@ from Thunder.utils.broadcast import broadcast_ids from Thunder.utils.commands import build_help_text from Thunder.utils.decorators import owner_only +from Thunder.utils.force_channel import get_force_info from Thunder.utils.logger import logger from Thunder.utils.messages import ( MSG_ABOUT, @@ -24,7 +25,7 @@ MSG_ERROR_CALLBACK_UNSUPPORTED, MSG_ERROR_CLOSE_NOT_ALLOWED, ) -from Thunder.utils.safe_call import answer_safe, edit_safe, tg_call +from Thunder.utils.safe_call import answer_safe, edit_safe from Thunder.vars import Var @@ -69,19 +70,16 @@ async def get_force_channel_button(client: Client): if not Var.FORCE_CHANNEL_ID: return None try: - chat = await tg_call(client.get_chat, Var.FORCE_CHANNEL_ID, retries=1) - if chat: - # numeric channel id always resolves a full Chat (see force_channel.py) - invite_link = chat.invite_link or ( # type: ignore[union-attr] - f"https://t.me/{chat.username}" if chat.username else None # type: ignore[union-attr] - ) - if invite_link: - return [ - InlineKeyboardButton( - MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title or "Channel"), - url=invite_link, - ) - ] + # reuse the resolved-once cache in force_channel.get_force_info + # instead of a fresh get_chat RPC on every help-panel render + link, title = await get_force_info(client) + if link: + return [ + InlineKeyboardButton( + MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title or "Channel"), + url=link, + ) + ] except Exception as e: logger.error(f"Error getting force channel button: {e}", exc_info=True) return None diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py index 852d428..7a74652 100644 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -4,6 +4,7 @@ import time from pyrogram import Client, filters +from pyrogram.enums import ParseMode from pyrogram.errors import MessageNotModified from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message, User @@ -178,6 +179,9 @@ async def send_user_dc(msg: Message, user: User): await reply_safe( msg, text=txt, + # DC templates are HTML (M7): pin the parse mode so pyrofork's + # DEFAULT markdown pre-pass cannot reinterpret user data + parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns), # type: ignore[arg-type] ) @@ -207,13 +211,18 @@ async def send_file_dc(msg: Message, file_msg: Message): dc_id = fid.dc_id txt = MSG_DC_FILE_INFO.format( - file_name=fname, file_size=fsize, file_type=type_display, dc_id=dc_id + # file_name is attacker-controlled; template is HTML (M7) + file_name=html.escape(fname, quote=False), + file_size=fsize, + file_type=type_display, + dc_id=dc_id, ) btns = [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]] await reply_safe( msg, text=txt, + parse_mode=ParseMode.HTML, # template is HTML (M7); skip md pre-pass reply_markup=InlineKeyboardMarkup(btns), # type: ignore[arg-type] ) @@ -238,7 +247,7 @@ async def dc_command(bot: Client, msg: Message): if not msg.from_user and not msg.reply_to_message: return await reply_user_err(msg, MSG_DC_ANON_ERROR) - args = msg.text.strip().split(maxsplit=1) + args = (msg.text or msg.caption or "").strip().split(maxsplit=1) if len(args) > 1: user = await get_user(bot, args[1].strip()) if user: diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index e112a5c..224b264 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -23,6 +23,7 @@ from Thunder.utils.canonical_files import get_or_create_canonical_file from Thunder.utils.database import db from Thunder.utils.decorators import preflight +from Thunder.utils.flag_cache import flags from Thunder.utils.logger import logger from Thunder.utils.messages import ( MSG_BATCH_LINKS_READY, @@ -129,7 +130,13 @@ async def send_channel_links( ) try: if target_msg: - await tg_call(target_msg.reply_text, text, disable_web_page_preview=True, quote=True) + await tg_call( + target_msg.reply_text, + text, + disable_web_page_preview=True, + quote=True, + parse_mode=enums.ParseMode.HTML, # M7 template is HTML + ) else: await send_safe( StreamBot, @@ -137,6 +144,7 @@ async def send_channel_links( text=text, disable_web_page_preview=True, reply_to_message_id=reply_to_message_id, + parse_mode=enums.ParseMode.HTML, # M7 template is HTML ) except Exception as e: logger.error(f"Error sending channel links: {e}", exc_info=True) @@ -230,7 +238,9 @@ async def _actual_link_handler(client: Client, message: Message, **handler_kwarg notification_msg = handler_kwargs.get("notification_msg") - parts = message.text.split() + # filters.command also matches captions, where .text is None -- + # parse from the caption too or a captioned /link crashes with dead air + parts = (message.text or message.caption or "").split() num_files = 1 if len(parts) > 1: try: @@ -326,7 +336,18 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha is_banned_statically = ( hasattr(Var, "BANNED_CHANNELS") and message.chat.id in Var.BANNED_CHANNELS ) - is_banned_dynamically = await db.is_channel_banned(message.chat.id) is not None + # flag-cached (one DB hit per channel per TTL instead of per post). + # Fail-open is DELIBERATE here: the action on a hit is leave_chat, + # which is destructive and irreversible -- a Mongo outage must not + # make the bot leave every channel it serves (H7's fail-closed + # applies to the user-ban gate, where denial is cheap and safe). + is_banned_dynamically = ( + await flags.get_or_load( + ("banned_channel", message.chat.id), + lambda: db.is_channel_banned(message.chat.id), + ) + is not None + ) if is_banned_statically or is_banned_dynamically: try: @@ -383,6 +404,7 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha stream_link=links["stream_link"], ), disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML, # M7 template is HTML ) except Exception as e: logger.error( diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py index ce37d8c..1782d04 100644 --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -1,5 +1,6 @@ # Thunder/server/__init__.py +import re import time from aiohttp import web @@ -9,12 +10,9 @@ # H10: access log middleware -- logs method, redacted path (file tokens are # replaced by their sha256 prefix), status, bytes and duration. # Modeled on ThunderGo's http/server.go logMiddleware + redactPath. -_REDACT_SEGMENTS = ("f/", "watch/") def _redact_path(path: str) -> str: - import re - # canonical: /f/<32-hex>/ or /watch/f/<32-hex>/ path = re.sub( r"(?<=/f/)[0-9a-f]{20,32}", @@ -41,6 +39,7 @@ def _hash_token(token: str) -> str: @web.middleware async def access_log_middleware(request: web.Request, handler): start = time.perf_counter() + response: web.Response | None = None try: response = await handler(request) except web.HTTPException as e: @@ -51,6 +50,8 @@ async def access_log_middleware(request: web.Request, handler): try: from Thunder.utils.logger import logger + # response stays None when the handler raised a non-HTTP + # exception; getattr(None, ...) then falls back to 500 status = getattr(response, "status", 500) size = getattr(response, "content_length", None) logger.info( diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index df185e2..0cf7a56 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -366,11 +366,6 @@ async def status_endpoint(request): ) -@routes.options("/status") -async def status_options(request: web.Request): - return web.Response(headers={**CORS_HEADERS, "Access-Control-Max-Age": "86400"}) - - @routes.options(r"/{path:.+}") async def media_options(request: web.Request): return web.Response(headers={**CORS_HEADERS, "Access-Control-Max-Age": "86400"}) diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py index b1e8f91..a71f001 100644 --- a/Thunder/utils/bot_utils.py +++ b/Thunder/utils/bot_utils.py @@ -169,24 +169,20 @@ async def gen_links(fwd_msg: Message, shortener: bool = True) -> dict[str, str]: 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 + # user_name lands in an HTML label; escape it (attacker-controlled) return MSG_DC_USER_INFO.format( - user_name=usr.first_name or "User", user_id=usr.id, dc_id=dc_id_val + user_name=html.escape(usr.first_name or "User", quote=False), + user_id=usr.id, + dc_id=dc_id_val, ) async def get_user(cli: Client, qry: Any) -> User | None: - if isinstance(qry, str) and qry.startswith("@"): - try: - result = await tg_call(cli.get_users, qry) - except Exception as e: - logger.debug(f"get_users failed for {qry}: {e}") - return None - if isinstance(result, list): # defensive: pyrogram returns a list for list inputs - return result[0] if result else None - return result - if isinstance(qry, str) and qry.isdigit(): + # @username stays a str; numeric strings become ints -- then one shared + # lookup path (the two blocks below used to be copy-pasted verbatim) + if isinstance(qry, str) and not qry.startswith("@") and qry.isdigit(): qry = int(qry) - if isinstance(qry, int): + if isinstance(qry, (str, int)): try: result = await tg_call(cli.get_users, qry) except Exception as e: diff --git a/Thunder/utils/custom_dl.py b/Thunder/utils/custom_dl.py index 3e5bc75..5721206 100644 --- a/Thunder/utils/custom_dl.py +++ b/Thunder/utils/custom_dl.py @@ -114,9 +114,8 @@ def get_file_info_sync(self, message: Message) -> dict[str, Any]: } async def get_file_info(self, message_id: int) -> dict[str, Any]: - try: - message = await self.get_message(message_id) - return self.get_file_info_sync(message) - except Exception as e: - logger.debug(f"Error getting file info for {message_id}: {e}", exc_info=True) - return {"message_id": message_id, "error": str(e)} + # no blanket swallow: the legacy route's error ladder already maps + # FileNotFound -> 404 and everything else -> 500 with an error_id; + # masking them here turned transient outages into misleading 404s + message = await self.get_message(message_id) + return self.get_file_info_sync(message) diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py index 007a7de..a3bf949 100644 --- a/Thunder/utils/database.py +++ b/Thunder/utils/database.py @@ -68,6 +68,11 @@ async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool: 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) + # /start + generate() look tokens up by user; without this the + # per-user scans walk the whole collection (H8-adjacent gap) + await self.token_col.create_index( + [("user_id", 1), ("activated", 1), ("expires_at", -1)] + ) await self.authorized_users_col.create_index("user_id", unique=True) try: await self.col.create_index("id", unique=True) @@ -234,6 +239,7 @@ async def add_banned_channel( await self.banned_channels_col.update_one( {"channel_id": channel_id}, {"$set": ban_data}, upsert=True ) + flags.invalidate(("banned_channel", channel_id)) logger.debug(f"Added/Updated banned channel {channel_id}. Reason: {reason}") except Exception as e: logger.error( @@ -244,6 +250,7 @@ async def add_banned_channel( async def remove_banned_channel(self, channel_id: int) -> bool: try: result = await self.banned_channels_col.delete_one({"channel_id": channel_id}) + flags.invalidate(("banned_channel", channel_id)) if result.deleted_count > 0: logger.debug(f"Removed banned channel {channel_id}.") return True @@ -254,10 +261,18 @@ async def remove_banned_channel(self, channel_id: int) -> bool: ) return False - async def is_channel_banned(self, channel_id: int) -> dict[str, Any] | None: + async def is_channel_banned( + self, channel_id: int, *, raise_on_error: bool = False + ) -> dict[str, Any] | None: + """Fetch a channel-ban record. ``raise_on_error`` mirrors + ``is_user_banned`` for fail-closed callers; the default (swallow β†’ + None) is what the auto-leave gate wants, since a Mongo outage must + not trigger the destructive leave_chat action.""" try: return await self.banned_channels_col.find_one({"channel_id": channel_id}) except Exception as e: + if raise_on_error: + raise logger.error(f"Error in is_channel_banned for channel {channel_id}: {e}", exc_info=True) return None diff --git a/Thunder/utils/decorators.py b/Thunder/utils/decorators.py index 7e779e1..b87ba30 100644 --- a/Thunder/utils/decorators.py +++ b/Thunder/utils/decorators.py @@ -16,6 +16,8 @@ silently letting everyone through. """ +import html + from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from Thunder.utils.database import db @@ -75,7 +77,10 @@ async def check_banned(client, message: Message) -> bool: await reply_safe( message, MSG_DECORATOR_BANNED.format( - reason=ban_details.get("reason", "Not specified"), ban_time=ban_time + # reason is owner-set free text; escape so the + # DEFAULT parse pass cannot reflow it into markup + reason=html.escape(ban_details.get("reason", "Not specified")), + ban_time=ban_time, ), ) except Exception: diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py index 2ea51f8..e2c6e33 100644 --- a/Thunder/utils/force_channel.py +++ b/Thunder/utils/force_channel.py @@ -1,8 +1,10 @@ # Thunder/utils/force_channel.py +import html import time from pyrogram import Client +from pyrogram.enums import ParseMode from pyrogram.errors import UserNotParticipant from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message @@ -80,7 +82,12 @@ async def force_channel_check(client: Client, message: Message): try: await reply_safe( message, - MSG_COMMUNITY_CHANNEL.format(channel_title=title), + MSG_COMMUNITY_CHANNEL.format( + # escaped twin of the /help panel line (common.py); + # HTML parse mode skips the markdown pre-pass + channel_title=html.escape(title or "Channel") + ), + parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Join", url=link)]]), ) except Exception as e: diff --git a/Thunder/utils/media_types.py b/Thunder/utils/media_types.py index f41a0b5..00fbf50 100644 --- a/Thunder/utils/media_types.py +++ b/Thunder/utils/media_types.py @@ -63,10 +63,6 @@ def ext_for(media_key: str) -> str: return _MEDIA_EXT_MIME.get(media_key, (DEFAULT_EXT, DEFAULT_MIME))[0] -def mime_for(media_key: str) -> str: - return _MEDIA_EXT_MIME.get(media_key, (DEFAULT_EXT, DEFAULT_MIME))[1] - - def ext_and_mime_for_class(class_name_lower: str) -> tuple[str, str]: """Direct lookup by pyrogram class name (``videonote``, ``photo``, ...).""" key = _CLASS_TO_MEDIA_TYPE.get(class_name_lower) @@ -78,7 +74,6 @@ def ext_and_mime_for_class(class_name_lower: str) -> tuple[str, str]: __all__ = [ "canonical_media_type", "ext_for", - "mime_for", "ext_and_mime_for_class", "DEFAULT_EXT", "DEFAULT_MIME", diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py index 487ad2e..9eb3282 100644 --- a/Thunder/utils/messages.py +++ b/Thunder/utils/messages.py @@ -40,6 +40,7 @@ MSG_TOKEN_FAILED = ( "⚠️ **Token Activation Failed!**\n\n" "> ❗ Reason: {reason}\n\n" + "> πŸ†” Support ID: {error_id}\n\n" "πŸ”‘ Please check your token or contact support." ) MSG_SHELL_ERROR = """**❌ Shell Command Error ❌** @@ -223,18 +224,18 @@ # ------ DC Info ------ MSG_DC_USER_INFO = ( - "πŸ“ **Information**\n" - "> πŸ‘€ **User:** [{user_name}](tg://user?id={user_id})\n" - "> πŸ†” **User ID:** `{user_id}`\n" - "> 🌍 **DC ID:** `{dc_id}`" + "πŸ“ Information\n" + 'πŸ‘€ User: {user_name}\n' + "πŸ†” User ID: {user_id}\n" + "🌍 DC ID: {dc_id}" ) MSG_DC_FILE_INFO = ( - "πŸ—‚οΈ **File Information**\n" - ">`{file_name}`\n" - "πŸ’Ύ **File Size:** `{file_size}`\n" - "πŸ“ **File Type:** `{file_type}`\n" - "🌍 **DC ID:** `{dc_id}`" + "πŸ—‚οΈ File Information\n" + "{file_name}\n" + "πŸ’Ύ File Size: {file_size}\n" + "πŸ“ File Type: {file_type}\n" + "🌍 DC ID: {dc_id}" ) MSG_DC_UNKNOWN = "Unknown" diff --git a/Thunder/utils/rate_limiter.py b/Thunder/utils/rate_limiter.py index cea2292..0621114 100644 --- a/Thunder/utils/rate_limiter.py +++ b/Thunder/utils/rate_limiter.py @@ -122,6 +122,14 @@ def _load_configuration(self): self.enabled = Var.RATE_LIMIT_ENABLED self.global_rate_limit_enabled = Var.GLOBAL_RATE_LIMIT self.max_global_requests_per_minute = Var.MAX_GLOBAL_REQUESTS_PER_MINUTE + if Var.GLOBAL_RPS_LIMIT and not self.global_rate_limit_enabled: + # M6 philosophy: surface dead knobs instead of silently + # ignoring them -- the RPS cap only bites when the breaker + # itself is enabled (see _breaker_rate) + logger.warning( + "GLOBAL_RPS_LIMIT is set but GLOBAL_RATE_LIMIT is disabled; " + "the per-second cap has no effect until the global breaker is enabled." + ) if not self._validate_configuration(): logger.warning("Rate limiter disabled due to invalid configuration.") @@ -585,10 +593,6 @@ async def estimate_wait_time(self, user_id: int, file_identifier: str | None = N rate_limiter = RateLimiter() -async def request_executor(): - await rate_limiter.request_executor() - - def start_executors() -> list[asyncio.Task]: """Start the worker pool (H6b) -- callers keep the tasks for shutdown.""" workers: list[asyncio.Task] = [] @@ -621,8 +625,11 @@ async def handle_rate_limited_request( await handler(bot, message, *args, **kwargs) return - # H6c: probe without consuming -- the exec path below is the single - # consumption point; charging here too halved throughput for queued traffic. + # H6c: probe without consuming -- the queued exec path below is where + # breaker tokens are consumed. The immediate path is gated by the 60s + # user window only (charging here == charging at exec); sub-second + # breaker throttling therefore applies to queued traffic, not bursts of + # within-window users. if rate_limiter.global_rate_limit_enabled and rate_limiter.breaker.retry_after() > 0: logger.warning(f"Global RPS breaker engaged; shedding request for user {user_id}.") if not (rl_user_id is not None and rl_user_id < 0): diff --git a/Thunder/utils/render_template.py b/Thunder/utils/render_template.py index 0d30de7..fba7cc5 100644 --- a/Thunder/utils/render_template.py +++ b/Thunder/utils/render_template.py @@ -7,6 +7,7 @@ from jinja2 import Environment, FileSystemLoader, select_autoescape +from Thunder.utils.bot_utils import quote_media_name from Thunder.utils.file_properties import get_fname, get_uniqid from Thunder.utils.logger import logger from Thunder.utils.safe_call import tg_call @@ -106,7 +107,7 @@ async def render_page( cached = _legacy_cache_get(key) if cached is not None: file_name, _ = cached - quoted_filename = urllib.parse.quote(file_name.replace("/", "_"), safe="") + quoted_filename = quote_media_name(file_name) src = urllib.parse.urljoin(Var.URL, f"{secure_hash}{message_id}/{quoted_filename}") return await render_media_page(file_name, src, requested_action) @@ -137,7 +138,7 @@ async def render_page( _legacy_cache_put(key, file_name, file_unique_id) - quoted_filename = urllib.parse.quote(file_name.replace("/", "_"), safe="") + quoted_filename = quote_media_name(file_name) src = urllib.parse.urljoin(Var.URL, f"{secure_hash}{message_id}/{quoted_filename}") return await render_media_page(file_name, src, requested_action) except Exception as e: diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py index f941059..eb65ec9 100644 --- a/Thunder/utils/shortener.py +++ b/Thunder/utils/shortener.py @@ -31,10 +31,6 @@ CACHE_MAX_ITEMS = 10_000 -class ShortenerError(Exception): - pass - - class ShortenerPlugin(ABC): @classmethod @abstractmethod @@ -51,8 +47,9 @@ async def shorten( def _validate_short_url(short_url: str, domain: str) -> bool: """The response host must match the configured site (M5).""" try: - result_host = urlparse(short_url).hostname or "" - site_host = urlparse(f"https://{domain}").hostname or "" + # trailing-dot tolerant, matching _host_matches semantics + result_host = (urlparse(short_url).hostname or "").removesuffix(".") + site_host = (urlparse(f"https://{domain}").hostname or "").removesuffix(".") return result_host == site_host except ValueError: return False diff --git a/pyproject.toml b/pyproject.toml index 327bc2e..1224f0a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,7 +74,7 @@ omit = ["Thunder/bot/plugins/*", "Thunder/__main__.py"] [tool.vulture] min_confidence = 80 -paths = ["Thunder", "whitelist.py"] +paths = ["Thunder"] [tool.bandit] exclude_dirs = ["tests", ".venv"] diff --git a/tests/integration/test_mongo.py b/tests/integration/test_mongo.py index 3bc265e..19c8c64 100644 --- a/tests/integration/test_mongo.py +++ b/tests/integration/test_mongo.py @@ -7,6 +7,7 @@ """ import os +from datetime import UTC import pytest @@ -28,14 +29,23 @@ def db(): if docker_unavailable or os.getenv("TEST_INTEGRATION") != "1": pytest.skip("integration tier disabled (set TEST_INTEGRATION=1 with Docker)") with MongoContainer("mongo:7") as mongo: - os.environ["DATABASE_URL"] = mongo.get_connection_url() - # re-import a fresh Database bound to the container URI - import importlib - import Thunder.utils.database as database_module - - importlib.reload(database_module) - yield database_module.db + import Thunder.utils.tokens as tokens_module + + # Bind a fresh Database directly to the container URI. Rebinding is + # required because Thunder.vars is cached in sys.modules by the unit + # tier's imports (Var.DATABASE_URL still points at the platform + # config), and `from ... import db` copies froze the old instance in + # every consumer module. Reload-based approaches never worked. + fresh = database_module.Database(mongo.get_connection_url(), "thunder_test") + original = database_module.db + database_module.db = fresh + tokens_module.db = fresh + try: + yield fresh + finally: + database_module.db = original + tokens_module.db = original async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover @@ -43,7 +53,7 @@ async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover # M8: atomic activation -- two concurrent consume() calls, one winner import asyncio - from datetime import datetime, timedelta, timezone + from datetime import datetime, timedelta from Thunder.utils.tokens import consume @@ -53,8 +63,8 @@ async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover "token": token, "user_id": 424242, "activated": False, - "created_at": datetime.now(timezone.utc), - "expires_at": datetime.now(timezone.utc) + timedelta(hours=1), + "created_at": datetime.now(UTC), + "expires_at": datetime.now(UTC) + timedelta(hours=1), } ) results = await asyncio.gather(consume(token, 424242), consume(token, 424242)) diff --git a/tests/test_unit/test_media_types.py b/tests/test_unit/test_media_types.py index 1ac45d8..831cd79 100644 --- a/tests/test_unit/test_media_types.py +++ b/tests/test_unit/test_media_types.py @@ -5,7 +5,6 @@ canonical_media_type, ext_and_mime_for_class, ext_for, - mime_for, ) @@ -42,5 +41,4 @@ def test_attr_resolution(): @pytest.mark.unit def test_direct_helpers(): assert ext_for("photo") == "jpg" - assert mime_for("voice") == "audio/ogg" assert ext_for("nope") == "bin" diff --git a/tests/test_unit/test_rate_limiter_park.py b/tests/test_unit/test_rate_limiter_park.py index 4907fae..93afd72 100644 --- a/tests/test_unit/test_rate_limiter_park.py +++ b/tests/test_unit/test_rate_limiter_park.py @@ -1,5 +1,5 @@ """Regression: deferred requeue must park the worker pool, not busy-spin.""" -import asyncio + import time import pytest diff --git a/update.py b/update.py index fab0b82..def6353 100644 --- a/update.py +++ b/update.py @@ -77,6 +77,11 @@ def _redact_credentials(text: str) -> str: def main() -> None: if not UPSTREAM_REPO: return + if UPSTREAM_REPO.startswith("-") or UPSTREAM_BRANCH.startswith("-"): + # git would treat leading-dash values as its own options; these are + # operator-supplied env vars, but stay on the safe side of argv + logger.info("UPSTREAM_REPO/UPSTREAM_BRANCH must not start with '-'; skipping self-update.") + return _recover_config_backup() if shutil.which("git") is None: logger.info("git not available; skipping self-update (image without git).") diff --git a/whitelist.py b/whitelist.py deleted file mode 100644 index 8421df6..0000000 --- a/whitelist.py +++ /dev/null @@ -1,16 +0,0 @@ -# Dead-code whitelist for the Vulture CI gate (H4b: make leanness permanent). -# Names referenced dynamically (pyrogram handlers, Jinja templates, etc.). -reply_safe -send_safe -edit_safe -delete_safe -answer_safe -tg_call -build_help_text -help_command_rows -bot_commands -touch_buffer_stats -occupancy -run_sweeper -shortener-cf -cls From 88815ffbbda872d9b40f9261339f9674cdcaaef2 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 18:21:23 +0000 Subject: [PATCH 13/49] fix(critical): naive/aware datetime crash on token activation + malformed dev extra - C-1: AsyncMongoClient now uses tz_aware=True; pymongo's default returns naive UTC datetimes, and comparing them against datetime.now(UTC) in tokens.consume() raised TypeError on EVERY real activation -- the flagship M8 /start path was dead at runtime - lost CAS races now distinguish 'already' (winner activated) from 'invalid' (expired / missing expires_at) instead of always saying 'already' - pyproject: 'testcontainersongodb]>=4.13' -> 'testcontainers[mongodb]>=4.13' (invalid PEP 508; fresh 'uv lock' hard-failed on it) - generate() no longer returns '' on an unreachable path (honest raise) --- Thunder/utils/database.py | 79 ++++++++++++++++++++++++--------------- Thunder/utils/tokens.py | 19 +++++++--- 2 files changed, 63 insertions(+), 35 deletions(-) diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py index a3bf949..f164be5 100644 --- a/Thunder/utils/database.py +++ b/Thunder/utils/database.py @@ -1,11 +1,12 @@ # Thunder/utils/database.py import datetime +import uuid from typing import Any from pymongo import AsyncMongoClient, UpdateOne from pymongo.asynchronous.collection import AsyncCollection -from pymongo.errors import DuplicateKeyError +from pymongo.errors import DuplicateKeyError, OperationFailure from Thunder.utils.flag_cache import flags from Thunder.utils.logger import logger @@ -17,8 +18,11 @@ class Database: - def __init__(self, uri: str, database_name: str, *args, **kwargs): - self._client = AsyncMongoClient(uri, *args, timeoutMS=MONGO_TIMEOUT_MS, **kwargs) + def __init__(self, uri: str, database_name: str, **kwargs): + # tz_aware=True: pymongo's default returns naive UTC datetimes, and + # comparing them against the aware datetime.now(UTC) used across the + # codebase raises TypeError (this broke token activation at runtime). + self._client = AsyncMongoClient(uri, timeoutMS=MONGO_TIMEOUT_MS, tz_aware=True, **kwargs) self.db = self._client[database_name] self.col: AsyncCollection = self.db.users self.banned_users_col: AsyncCollection = self.db.banned_users @@ -51,13 +55,34 @@ async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool: # L2: optional file TTL -- backfill first so pre-existing rows do # not vanish the moment the index is created (default off). if Var.FILE_TTL_DAYS > 0: + # Backfill before the TTL index exists so pre-existing rows + # do not vanish the moment it is created (default off). The + # client-level 5s timeoutMS would abort this COLLSCAN on any + # sizeable vault, so this one-off migration gets its own + # generous budget. await self.files_col.update_many( {"last_seen_at": {"$exists": False}}, {"$set": {"last_seen_at": datetime.datetime.now(datetime.UTC)}}, + timeoutMS=120_000, # type: ignore[call-arg] # CSOT per-op budget (stub lag) ) - await self.files_col.create_index( - "last_seen_at", expireAfterSeconds=Var.FILE_TTL_DAYS * 86400 - ) + try: + await self.files_col.create_index( + "last_seen_at", expireAfterSeconds=Var.FILE_TTL_DAYS * 86400 + ) + except OperationFailure as e: + # Mongo cannot change a TTL value via createIndexes; an + # operator changing FILE_TTL_DAYS between boots must not + # abort the remaining (unique-index) ensures below. + logger.warning( + f"FILE_TTL_DAYS changed between boots; recreating file TTL index: {e}" + ) + try: + await self.files_col.drop_index("last_seen_at_1") + except Exception: + pass + await self.files_col.create_index( + "last_seen_at", expireAfterSeconds=Var.FILE_TTL_DAYS * 86400 + ) logger.info(f"File TTL index active: {Var.FILE_TTL_DAYS} days") else: try: @@ -378,24 +403,6 @@ async def replace_file_record(self, file_record: dict[str, Any]) -> None: ) 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.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 bulk_touch_file_records( self, items: list[tuple[str, bool]], *, raise_on_error: bool = False ) -> bool: @@ -453,15 +460,24 @@ async def update_file_id( async def acquire_file_ingest_claim( self, file_unique_id: str, *, ttl_seconds: int = 60 - ) -> bool: + ) -> str | None: + """Acquire the ingest claim; returns an opaque owner token, or + ``None`` when another worker holds a live claim. + + The owner token makes the matching ``release_file_ingest_claim`` + refuse to delete a newer worker's claim after this worker's TTL + expired mid-copy (which previously caused a redundant third copy). + """ now = datetime.datetime.now(datetime.UTC) + owner = uuid.uuid4().hex claim_fields = { + "owner": owner, "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 + return owner except DuplicateKeyError: try: result = await self.file_ingest_locks_col.find_one_and_update( @@ -472,7 +488,7 @@ async def acquire_file_ingest_claim( {"$set": claim_fields}, return_document=False, ) - return bool(result) + return owner if result else None except Exception as e: logger.error( f"Error updating ingest claim for {file_unique_id}: {e}", exc_info=True @@ -482,10 +498,13 @@ async def acquire_file_ingest_claim( 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: + async def release_file_ingest_claim(self, file_unique_id: str, owner: str) -> bool: + """Release the claim only if we still own it (owner-checked).""" try: - await self.file_ingest_locks_col.delete_one({"_id": file_unique_id}) - return True + result = await self.file_ingest_locks_col.delete_one( + {"_id": file_unique_id, "owner": owner} + ) + return result.deleted_count > 0 except Exception as e: logger.error(f"Error releasing ingest claim for {file_unique_id}: {e}", exc_info=True) return False diff --git a/Thunder/utils/tokens.py b/Thunder/utils/tokens.py index 612f270..c63910a 100644 --- a/Thunder/utils/tokens.py +++ b/Thunder/utils/tokens.py @@ -19,7 +19,7 @@ def _invalidate_user_flags(user_id: int) -> None: async def check(user_id: int) -> bool: """Token/authorization gate (H7: cached, fail-closed).""" try: - if not getattr(Var, "TOKEN_ENABLED", False): + if not Var.TOKEN_ENABLED: return True if user_id == Var.OWNER_ID: return True @@ -64,7 +64,7 @@ async def generate(user_id: int) -> str: base_delay = 0.5 for attempt in range(max_retries): try: - ttl_hours = getattr(Var, "TOKEN_TTL_HOURS", 24) + ttl_hours = Var.TOKEN_TTL_HOURS created_at = datetime.now(UTC) expires_at = created_at + timedelta(hours=ttl_hours) await db.save_main_token( @@ -90,7 +90,10 @@ async def generate(user_id: int) -> str: exc_info=True, ) raise - return "" + # The loop always returns or raises on its final attempt; this is a + # honest crash instead of returning "" (which callers would treat as + # a real token value). + raise RuntimeError("token save retry loop exited without success") except Exception as e: logger.error(f"Error in generate for user {user_id}: {e}", exc_info=True) raise @@ -139,8 +142,14 @@ async def consume(token: str, user_id: int) -> tuple[str, float]: return_document=True, ) if activated_doc is None: - # lost the race to a concurrent activation - return "already", 0.0 + # Lost the CAS race. Distinguish a concurrent activation (the + # winner activated the doc) from a doc that expired -- or never + # had a usable expires_at -- between our pre-check and the CAS; + # both used to surface as the misleading "already". + current = await db.token_col.find_one({"token": token}, {"activated": 1}) + if current and current.get("activated"): + return "already", 0.0 + return "invalid", 0.0 _invalidate_user_flags(user_id) hours = round((expires_at - now).total_seconds() / 3600, 1) logger.debug(f"Token atomically activated for user {user_id} ({hours}h)") From 855b77595e9964132b04d8a8a3287479652aaf86 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 18:21:23 +0000 Subject: [PATCH 14/49] fix(major): review findings across self-heal, gates, breaker, boot and logs Self-heal safety: - transient Telegram failures (FloodWait exhaustion, timeout, transport) now raise TelegramUnavailable -> 503 + Retry-After; they no longer masquerade as FileNotFound, which made a brownout DELETE every vault record requested during the outage - self-heal replacement preserves the existing public_hash (rewriting a legacy 20-char hash broke the 'legacy links stay valid forever' contract) - stream_file fetches its target once (no re-fetch per FloodWait retry) and bounds total FloodWait sleep (60s) instead of pinning the handler - safe_call caps a single FloodWait sleep at 30s (H8 budget honesty) Gates / access control (fail-closed): - preflight: unknown gate ids REJECT (were warn-and-continue = skipped security check); gate presets GATES_STANDARD/START/INFO added and common.py now routes through them (was hand-rolled per handler) - PRIVATE_MODE denies unattributable senders (from_user None); channel handler refuses to mint public links in PRIVATE_MODE - token deep-link uses quote_plus like the hardened /activate builder Rate limiter: - the immediate path now consumes a breaker token; a dry bucket falls through to the queue (bursts are shaped, not shed) -- the old top-shed also claimed queue-full, misleading users - breaker gate unified on breaker.rate > 0 (GLOBAL_RPS_LIMIT-only setups) - removed write-only user_queue_counts (+ its cancel-path leak edge) - requeue-vs-rotation insertion policy documented Boot / shutdown: - boot failures exit 1 (restart policies can actually fire) - background tasks tracked in one list (no locals().get bookkeeping); index-ensure task cancelled+awaited at shutdown; batched 10s wait - deprecated get_event_loop().time() -> get_running_loop() Config: - config.env.local really overrides config.env now (load_dotenv override=False had inverted the documented precedence) - str_to_int_set surfaces junk tokens (collect-all-errors) Logging / misc: - access log redacts legacy id-first capability hashes and /activate tokens; control chars escaped (log forging via %0A neutralized) - redact_secrets covers API_HASH, session strings, ?start= activation tokens - MSG_SHELL_OUTPUT_CAPTION fixes a format() KeyError that swallowed large /shell outputs; render_page no longer logs the capability hash - shortener URLs html-escaped into the HTML links message - update.py allows only https/http/git/ssh schemes (blocks ext::) - leanness: dead touch_file_record, requested_action, task_done, peek/ occupancy, unreachable branches, dead getattr defaults, duplicate disk syscalls, legacy-cache unique_id all removed --- Thunder/__main__.py | 71 ++++++++++++++++-------------- Thunder/bot/plugins/admin.py | 20 +++++---- Thunder/bot/plugins/callbacks.py | 5 +-- Thunder/bot/plugins/common.py | 40 ++++++----------- Thunder/bot/plugins/stream.py | 13 +++++- Thunder/server/__init__.py | 32 ++++++++++---- Thunder/server/exceptions.py | 9 +++- Thunder/server/stream_routes.py | 41 ++++++++++++------ Thunder/utils/bot_utils.py | 12 +++--- Thunder/utils/broadcast.py | 48 +++++++++++---------- Thunder/utils/canonical_files.py | 51 +++++++++++----------- Thunder/utils/custom_dl.py | 47 +++++++++++++------- Thunder/utils/decorators.py | 61 ++++++++++++++++++-------- Thunder/utils/flag_cache.py | 15 +------ Thunder/utils/force_channel.py | 18 +++++--- Thunder/utils/logger.py | 14 +++++- Thunder/utils/media_types.py | 8 ++-- Thunder/utils/messages.py | 15 ++++++- Thunder/utils/rate_limiter.py | 74 +++++++++++++------------------- Thunder/utils/render_template.py | 29 ++++++------- Thunder/utils/safe_call.py | 38 +++++++++++----- Thunder/vars.py | 29 +++++++++++-- update.py | 15 +++++++ 23 files changed, 421 insertions(+), 284 deletions(-) diff --git a/Thunder/__main__.py b/Thunder/__main__.py index 4c9e93d..53385d4 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -63,7 +63,7 @@ def print_banner(): print(banner) -def schedule_index_ensure() -> None: +def schedule_index_ensure() -> asyncio.Task: task = asyncio.create_task( db.ensure_indexes(raise_on_error=False), name="ensure_database_indexes" ) @@ -79,6 +79,7 @@ def _log_index_failure(done_task: asyncio.Task) -> None: logger.error(f"Background database index ensure failed: {e}", exc_info=True) task.add_done_callback(_log_index_failure) + return task async def import_plugins(): @@ -122,6 +123,7 @@ async def import_plugins(): async def start_services(): start_time = datetime.now() + background_tasks: list[asyncio.Task] = [] print_banner() print("╔════════════════ INITIALIZING BOT SERVICES ════════════════╗") @@ -134,7 +136,9 @@ async def start_services(): await set_commands() print(" βœ“ Bot commands set successfully.") - schedule_index_ensure() + # managed background task: cancelled + awaited at shutdown (the old + # fire-and-forget version leaked as a pending task) + background_tasks.append(schedule_index_ensure()) _harden_session_files() restart_message_data = await db.get_restart_message() @@ -155,14 +159,17 @@ async def start_services(): except Exception as e: logger.error(f" βœ– Failed to initialize Telegram Bot: {e}", exc_info=True) - return + # M13 contract: a failed boot must exit non-zero, or container + # restart policies never fire and the box sits dead but "healthy". + raise SystemExit(1) from e print(" β–Ά Starting Client initialization...") try: await initialize_clients() except Exception as e: logger.error(f" βœ– Failed to initialize clients: {e}", exc_info=True) - return + await _safe_teardown_step(cleanup_clients, "clients (boot failure)") + raise SystemExit(1) from e await import_plugins() @@ -173,7 +180,7 @@ async def start_services(): print(f" βœ“ Request executor pool started ({len(executor_tasks)} workers)") except Exception as e: logger.error(f" βœ– Failed to start request executor: {e}", exc_info=True) - return + raise SystemExit(1) from e print(" β–Ά Starting Web Server initialization...") try: @@ -183,29 +190,29 @@ async def start_services(): site = web.TCPSite(app_runner, bind_address, Var.PORT) await site.start() + background_tasks.extend(executor_tasks) + keepalive_task = asyncio.create_task(ping_server(), name="keepalive_task") + background_tasks.append(keepalive_task) print(" βœ“ Keep-alive service started") token_cleanup_task = asyncio.create_task( schedule_token_cleanup(), name="token_cleanup_task" ) + background_tasks.append(token_cleanup_task) # H6a: bounded bookkeeping -- periodic sweepers limiter_sweeper_task = asyncio.create_task( schedule_limiter_sweep(), name="limiter_sweeper_task" ) + background_tasks.append(limiter_sweeper_task) flag_sweeper_task = asyncio.create_task(flags.run_sweeper(), name="flag_cache_sweeper_task") + background_tasks.append(flag_sweeper_task) except Exception as e: logger.error(f" βœ– Failed to start Web Server: {e}", exc_info=True) - tasks_to_cancel: list[asyncio.Task] = [] - tasks_to_cancel.extend(locals().get("executor_tasks", []) or []) - for name in ("limiter_sweeper_task", "flag_sweeper_task"): - t = locals().get(name) - if t is not None: - tasks_to_cancel.append(t) - for t in tasks_to_cancel: + for t in background_tasks: t.cancel() - if tasks_to_cancel: - await asyncio.gather(*tasks_to_cancel, return_exceptions=True) + if background_tasks: + await asyncio.gather(*background_tasks, return_exceptions=True) # mirror shutdown_services ordering: the touch buffer must flush # BEFORE db.close, or _bulk_flush runs against a closed client and # silently discards every pending increment @@ -213,7 +220,7 @@ async def start_services(): await _safe_teardown_step(drain_background_touch_tasks, "touch buffer") await _safe_teardown_step(cleanup_clients, "clients") await _safe_teardown_step(db.close, "database") - return + raise SystemExit(1) from e elapsed_time = (datetime.now() - start_time).total_seconds() print("╠═══════════════════════════════════════════════════════════╣") @@ -224,14 +231,6 @@ async def start_services(): print("β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•") print(" β–Ά Bot is now running! Press CTRL+C to stop.") - background_tasks = [ - *executor_tasks, - keepalive_task, - token_cleanup_task, - limiter_sweeper_task, - flag_sweeper_task, - ] - try: await idle() finally: @@ -262,18 +261,24 @@ async def shutdown_services(background_tasks, app_runner) -> None: if not task.done(): task.cancel() - for task in background_tasks: - try: - await asyncio.wait_for(task, timeout=10) - except asyncio.CancelledError: - pass - except Exception as e: - errors.append((task.get_name(), e)) - logger.error(f"Background task {task.get_name()} failed at shutdown: {e}") + # one bounded wait for the WHOLE batch (the old per-task wait_for(x, 10) + # could stack to ~80s worst-case before teardown ever started) + if background_tasks: + done, pending = await asyncio.wait(background_tasks, timeout=10) + for t in done: + if t.cancelled(): + continue + exc = t.exception() + if exc is not None: + errors.append((t.get_name(), exc)) + logger.error(f"Background task {t.get_name()} failed at shutdown: {exc}") + for t in pending: + logger.warning(f"Background task {t.get_name()} did not stop within 10s") # 2. bounded drain: wait (<= 30 s) for in-flight streams to finish - drain_deadline = asyncio.get_event_loop().time() + 30 - while sum(work_loads.values()) > 0 and asyncio.get_event_loop().time() < drain_deadline: + loop = asyncio.get_running_loop() + drain_deadline = loop.time() + 30 + while sum(work_loads.values()) > 0 and loop.time() < drain_deadline: await asyncio.sleep(0.25) remaining = sum(work_loads.values()) if remaining: diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py index 475d96e..184bf0d 100644 --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -1,9 +1,9 @@ # Thunder/bot/plugins/admin.py import asyncio +import contextlib import html import os -import shutil import time from io import BytesIO from typing import Any @@ -58,7 +58,7 @@ MSG_SHELL_ERROR, MSG_SHELL_EXECUTING, MSG_SHELL_NO_OUTPUT, - MSG_SHELL_OUTPUT, + MSG_SHELL_OUTPUT_CAPTION, MSG_SHELL_OUTPUT_STDERR, MSG_SHELL_OUTPUT_STDOUT, MSG_SHELL_USAGE, @@ -185,10 +185,11 @@ async def show_stats(client: Client, message: Message): ram_used = humanbytes(ram_info.used) ram_free = humanbytes(ram_info.free) - total_disk, used_disk, free_disk = await asyncio.to_thread(shutil.disk_usage, ".") - - # H8: the last synchronous psutil call is off the event loop too - disk_percent = (await asyncio.to_thread(psutil.disk_usage, ".")).percent + # one psutil call yields all four values (was: shutil + psutil) + disk = await asyncio.to_thread(psutil.disk_usage, ".") + total_disk, used_disk, free_disk = disk.total, disk.used, disk.free + # H8: the synchronous psutil call is off the event loop + disk_percent = disk.percent limiter_line = ( ", ".join(f"{k}={v}" for k, v in rate_limiter.occupancy().items()) or "disabled" @@ -443,7 +444,7 @@ async def unban_command(client: Client, message: Message): @StreamBot.on_message(filters.command("shell") & owner_filter) async def run_shell_command(client: Client, message: Message): # L10: env kill-switch -- the powerful command is opt-in. - if not getattr(Var, "ENABLE_SHELL", False): + if not Var.ENABLE_SHELL: return await reply(message, text=MSG_SHELL_DISABLED, parse_mode=ParseMode.HTML) if len(message.command) < 2: @@ -469,7 +470,8 @@ async def run_shell_command(client: Client, message: Message): try: stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60) except TimeoutError: - process.kill() + with contextlib.suppress(ProcessLookupError): + process.kill() await process.communicate() raise TimeoutError("shell command exceeded 60s") from None @@ -494,7 +496,7 @@ async def run_shell_command(client: Client, message: Message): file = BytesIO(output.encode()) file.name = "shell_output.txt" await message.reply_document( - file, caption=MSG_SHELL_OUTPUT.format(command=html.escape(command)) + file, caption=MSG_SHELL_OUTPUT_CAPTION.format(command=html.escape(command)) ) else: await reply(message, text=output, parse_mode=ParseMode.HTML) diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py index c09a7d5..f63fa9f 100644 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -24,6 +24,7 @@ MSG_ERROR_BROADCAST_RESTART, MSG_ERROR_CALLBACK_UNSUPPORTED, MSG_ERROR_CLOSE_NOT_ALLOWED, + MSG_ERROR_UNEXPECTED, ) from Thunder.utils.safe_call import answer_safe, edit_safe from Thunder.vars import Var @@ -45,9 +46,7 @@ async def wrapper(client: Client, callback_query: CallbackQuery): error_id = secrets.token_hex(6) logger.error(f"Callback error {error_id} in {fn.__name__}: {e}", exc_info=True) try: - await answer_safe( - callback_query, "An error occurred. Please try again.", show_alert=True - ) + await answer_safe(callback_query, MSG_ERROR_UNEXPECTED, show_alert=True) except Exception: pass try: diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py index 7a74652..642f93d 100644 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -11,9 +11,9 @@ from Thunder.bot import StreamBot from Thunder.utils.bot_utils import gen_dc_txt, get_user, log_newusr, reply_user_err from Thunder.utils.commands import build_help_text -from Thunder.utils.decorators import check_banned +from Thunder.utils.decorators import GATES_INFO, GATES_START, preflight from Thunder.utils.file_properties import get_fname, get_fsize, parse_fid -from Thunder.utils.force_channel import force_channel_check, get_force_info +from Thunder.utils.force_channel import get_force_info from Thunder.utils.human_readable import humanbytes from Thunder.utils.logger import logger from Thunder.utils.messages import ( @@ -59,11 +59,7 @@ async def start_command(bot: Client, msg: Message): # M12: /start runs banned + private-mode only so the activation flow # stays reachable for token-gated users. - if not await check_banned(bot, msg): - return - from Thunder.utils.decorators import check_private_mode - - if not await check_private_mode(bot, msg): + if await preflight(bot, msg, gates=GATES_START) is None: return user = msg.from_user if user: @@ -81,17 +77,13 @@ async def start_command(bot: Client, msg: Message): return await reply_safe( msg, text=MSG_TOKEN_FAILED.format( - reason="This activation link is not for your account.", - error_id=str(int(time.time()))[-8:], + reason="This activation link is not for your account." ), ) if status == "already": return await reply_safe( msg, - text=MSG_TOKEN_FAILED.format( - reason="Token has already been activated.", - error_id=str(int(time.time()))[-8:], - ), + text=MSG_TOKEN_FAILED.format(reason="Token has already been activated."), ) if status == "ok": return await reply_safe(msg, text=MSG_TOKEN_ACTIVATED.format(duration_hours=hours)) @@ -133,7 +125,7 @@ async def _send_html(msg: Message, txt: str, btns): @StreamBot.on_message(filters.command("help") & filters.private) async def help_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): + if await preflight(bot, msg, gates=GATES_INFO) is None: return if msg.from_user: await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) @@ -153,7 +145,7 @@ async def help_command(bot: Client, msg: Message): @StreamBot.on_message(filters.command("about") & filters.private) async def about_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): + if await preflight(bot, msg, gates=GATES_INFO) is None: return if msg.from_user: await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) @@ -233,16 +225,14 @@ async def send_file_dc(msg: Message, file_msg: Message): @StreamBot.on_message(filters.command("dc")) async def dc_command(bot: Client, msg: Message): - # Gate chain for /dc (banned -> private-mode -> force-sub). The token + # Gate chain for /dc (banned -> private-mode, then force-sub). The token # gate is intentionally NOT applied: /dc is informational, and applying # it here would lock token-gated users out of diagnostics. - if not await check_banned(bot, msg): + if await preflight(bot, msg, gates=GATES_START) is None: return - from Thunder.utils.decorators import check_private_mode + from Thunder.utils.decorators import force_sub_gate - if not await check_private_mode(bot, msg): - return - if not await force_channel_check(bot, msg): + if not await force_sub_gate(bot, msg): return if not msg.from_user and not msg.reply_to_message: return await reply_user_err(msg, MSG_DC_ANON_ERROR) @@ -274,13 +264,11 @@ async def dc_command(bot: Client, msg: Message): @StreamBot.on_message(filters.command("ping") & filters.private) async def ping_command(bot: Client, msg: Message): - if not await check_banned(bot, msg): + if await preflight(bot, msg, gates=GATES_START) is None: return - from Thunder.utils.decorators import check_private_mode + from Thunder.utils.decorators import force_sub_gate - if not await check_private_mode(bot, msg): - return - if not await force_channel_check(bot, msg): + if not await force_sub_gate(bot, msg): return start = time.time() try: diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index 224b264..7774cc4 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -201,6 +201,11 @@ async def send_link(msg: Message, links: dict[str, Any]): @StreamBot.on_message(filters.command("link") & ~filters.private) async def link_handler(bot: Client, msg: Message, **kwargs): + # A channel-posted /link has no from_user; key the limiter on the + # sender chat instead of dropping the request with dead air. + if kwargs.get("rl_user_id") is None and msg.sender_chat and msg.sender_chat.id: + kwargs["rl_user_id"] = msg.sender_chat.id + async def _actual_link_handler(client: Client, message: Message, **handler_kwargs): shortener_val = await validate_request_common(client, message) if shortener_val is None: @@ -259,7 +264,6 @@ async def _actual_link_handler(client: Client, message: Message, **handler_kwarg except Exception as e: logger.error(f"Could not send processing status: {e}", exc_info=True) return - shortener_val = handler_kwargs.get("shortener", shortener_val) if num_files == 1: await process_single( client, @@ -331,6 +335,13 @@ async def channel_receive_handler(bot: Client, msg: Message): async def _actual_channel_receive_handler(client: Client, message: Message, **handler_kwargs): if not Var.CHANNEL: return + # M12: PRIVATE_MODE promises "owner + authorized users only" -- a + # channel post must not mint public links on a private instance + # (channels have no from_user, so the user gates cannot vouch for + # them; fail closed here). + if Var.PRIVATE_MODE: + logger.debug(f"Ignoring channel post from {message.chat.id} (PRIVATE_MODE).") + return notification_msg = handler_kwargs.get("notification_msg") is_banned_statically = ( diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py index 1782d04..fa26c79 100644 --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -5,6 +5,8 @@ from aiohttp import web +from Thunder.utils.logger import hash_path_token, logger + from .stream_routes import routes # H10: access log middleware -- logs method, redacted path (file tokens are @@ -16,7 +18,7 @@ def _redact_path(path: str) -> str: # canonical: /f/<32-hex>/ or /watch/f/<32-hex>/ path = re.sub( r"(?<=/f/)[0-9a-f]{20,32}", - lambda m: _hash_token(m.group(0)), + lambda m: hash_path_token(m.group(0)), path, ) # legacy: /watch/<6-char-hash>/ -> hash part @@ -24,16 +26,29 @@ def _redact_path(path: str) -> str: r"(?<=/watch/)[a-zA-Z0-9_-]{6}\d+", # hash the match itself -- the previous `m.group(0)[:-len(m.group(0))]` # slice always evaluated to "" so every file logged the same pseudonym - lambda m: _hash_token(m.group(0)) + "…", + lambda m: hash_path_token(m.group(0)) + "…", + path, + ) + # legacy id-first family: /<6-char-hash>/ -- the capability hash + # is the path segment itself (previously logged in plaintext) + path = re.sub( + r"(?<=/)[a-zA-Z0-9_-]{6}\d+(?=/)", + lambda m: hash_path_token(m.group(0)) + "…", + path, + ) + # activation tokens: /activate/<43-char urlsafe token> + path = re.sub( + r"(?<=/activate/)[A-Za-z0-9_-]{43}", + lambda m: hash_path_token(m.group(0)), path, ) return path -def _hash_token(token: str) -> str: - from Thunder.utils.logger import hash_path_token - - return hash_path_token(token) +def _escape_control_chars(path: str) -> str: + """Neutralize log forging: request.path is percent-DECODED, so a request + for /f/x/%0A[INFO] fake would otherwise inject forged log lines.""" + return "".join(ch if ch.isprintable() else f"%{ord(ch):02X}" for ch in path) @web.middleware @@ -48,14 +63,13 @@ async def access_log_middleware(request: web.Request, handler): finally: duration_ms = (time.perf_counter() - start) * 1000 try: - from Thunder.utils.logger import logger - # response stays None when the handler raised a non-HTTP # exception; getattr(None, ...) then falls back to 500 status = getattr(response, "status", 500) size = getattr(response, "content_length", None) logger.info( - f'{request.remote} "{request.method} {_redact_path(request.path)}" ' + f'{request.remote} "{request.method} ' + f'{_escape_control_chars(_redact_path(request.path))}" ' f"{status} {size if size is not None else '-'} {duration_ms:.1f}ms" ) except Exception: diff --git a/Thunder/server/exceptions.py b/Thunder/server/exceptions.py index 1f0d9e9..f960f5d 100644 --- a/Thunder/server/exceptions.py +++ b/Thunder/server/exceptions.py @@ -6,4 +6,11 @@ class InvalidHash(Exception): class FileNotFound(Exception): - pass + """The file/record is genuinely gone. Safe to self-heal a record on.""" + + +class TelegramUnavailable(Exception): + """Transient Telegram-side failure (FloodWait-exhaustion, timeout, + transport error). MUST NOT trigger record self-healing -- raising it + as FileNotFound made a Telegram brownout delete every vault record + requested during the outage. Route handlers map this to 503.""" diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index 0cf7a56..a663cf4 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -7,10 +7,11 @@ from urllib.parse import quote, quote_plus, unquote from aiohttp import web +from pyrogram.types import Message from Thunder import StartTime, __version__ from Thunder.bot import StreamBot, multi_clients, work_loads -from Thunder.server.exceptions import FileNotFound, InvalidHash +from Thunder.server.exceptions import FileNotFound, InvalidHash, TelegramUnavailable from Thunder.utils.bot_utils import quote_media_name from Thunder.utils.canonical_files import ( LEGACY_PUBLIC_HASH_LENGTH, @@ -32,8 +33,8 @@ # legacy 6-char capability hash family (L1: kept while ENABLE_LEGACY_LINKS=on) SECURE_HASH_LENGTH = 6 CHUNK_SIZE = 1024 * 1024 -# M9: per-client admission cap (env-tunable; was a hardcoded constant) -MAX_CONCURRENT_PER_CLIENT = max(1, int(getattr(Var, "MAX_CONCURRENT_STREAMS", 8))) +# M9: per-client admission cap +MAX_CONCURRENT_PER_CLIENT = max(1, Var.MAX_CONCURRENT_STREAMS) OVERLOAD_RETRY_AFTER_SECONDS = 2 RANGE_REGEX = re.compile(r"^bytes=(?P\d*)-(?P\d*)$") PATTERN_HASH_FIRST = re.compile(rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$") @@ -177,13 +178,6 @@ def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: return start, end -def _resolve_unique_id(file_info: dict) -> str: - unique_id = file_info.get("unique_id") or file_info.get("file_unique_id") - if not unique_id: - raise FileNotFound("File unique ID not found in info.") - return unique_id - - def _resolve_filename(file_info: dict, mime_type: str) -> str: filename = file_info.get("file_name") if filename: @@ -195,13 +189,20 @@ def _resolve_filename(file_info: dict, mime_type: str) -> str: return f"file_{secrets.token_hex(4)}.{ext}" +def _resolve_unique_id(file_info: dict) -> str: + unique_id = file_info.get("unique_id") or file_info.get("file_unique_id") + if not unique_id: + raise FileNotFound("File unique ID not found in info.") + return unique_id + + async def _serve_media_response( request: web.Request, *, file_info: dict, streamer: ByteStreamer, client_id: int, - media_ref: int | object, + media_ref: int | Message, ): file_size = int(file_info.get("file_size", 0) or 0) if file_size == 0: @@ -287,7 +288,7 @@ async def health_endpoint(request): ) -_ACTIVATION_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{43}\Z") +_ACTIVATION_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{43}") def _is_activation_token(token: str) -> bool: @@ -384,7 +385,6 @@ async def canonical_media_preview(request: web.Request): rendered_page = await render_media_page( file_name, src, - requested_action="stream", mime_type=file_record.get("mime_type"), ) @@ -422,7 +422,7 @@ async def media_preview(request: web.Request): path = request.match_info["path"] message_id, secure_hash = parse_media_request(path, request.query) - rendered_page = await render_page(message_id, secure_hash, requested_action="stream") + rendered_page = await render_page(message_id, secure_hash) response = web.Response( text=rendered_page, @@ -472,6 +472,9 @@ async def canonical_media_delivery(request: web.Request): raise FileNotFound( "Vault message missing; record self-healed, re-upload to regenerate the link" ) from None + # TelegramUnavailable (FloodWait-exhaustion / timeout / transport) + # is NOT proof the vault message is gone: it must not delete the + # record. It falls through to the 503 ladder below. media = get_media(vault_message) if not media: @@ -512,6 +515,11 @@ async def canonical_media_delivery(request: web.Request): except (FileNotFound, InvalidHash): work_loads[client_id] -= 1 raise + except TelegramUnavailable as e: + work_loads[client_id] -= 1 + raise web.HTTPServiceUnavailable( + text=str(e), headers={**CORS_HEADERS, "Retry-After": "5"} + ) from e except web.HTTPException as e: work_loads[client_id] -= 1 logger.debug(f"Client HTTP error in canonical stream: {e}") @@ -570,6 +578,11 @@ async def media_delivery(request: web.Request): except (FileNotFound, InvalidHash): work_loads[client_id] -= 1 raise + except TelegramUnavailable as e: + work_loads[client_id] -= 1 + raise web.HTTPServiceUnavailable( + text=str(e), headers={**CORS_HEADERS, "Retry-After": "5"} + ) from e except web.HTTPException as e: work_loads[client_id] -= 1 logger.debug(f"Client HTTP error in media stream: {e}") diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py index a71f001..a41b0d0 100644 --- a/Thunder/utils/bot_utils.py +++ b/Thunder/utils/bot_utils.py @@ -40,10 +40,12 @@ def format_link_message(links: dict[str, str]) -> str: text = MSG_LINKS.format( file_name=html.escape(str(links["media_name"])), file_size=links["media_size"], - download_link=links["online_link"], - stream_link=links["stream_link"], + # shortener responses are external data: escape them too, or one + # hostile/buggy shortener breaks Telegram entity parsing entirely + download_link=html.escape(str(links["online_link"])), + stream_link=html.escape(str(links["stream_link"])), ) - if getattr(Var, "FILE_TTL_DAYS", 0) > 0: + if Var.FILE_TTL_DAYS > 0: text += "\n\n" + MSG_FILE_EXPIRY_NOTE.format( days=MSG_FILE_TTL_DAYS_LABEL.format(days=Var.FILE_TTL_DAYS) ) @@ -62,7 +64,7 @@ async def _build_links( slink = f"{base_url}{stream_path}" olink = f"{base_url}{download_path}" - if shortener and getattr(Var, "SHORTEN_MEDIA_LINKS", False): + if shortener and Var.SHORTEN_MEDIA_LINKS: try: s_results = await asyncio.gather(shorten(slink), shorten(olink), return_exceptions=True) if isinstance(s_results[0], BaseException): @@ -100,7 +102,7 @@ async def gen_canonical_links( 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] + o_ids = [Var.OWNER_ID] # OWNER_ID is an int by construction async def send_with_flood_wait(chat_id: int): try: diff --git a/Thunder/utils/broadcast.py b/Thunder/utils/broadcast.py index 3234262..2b4a3ef 100644 --- a/Thunder/utils/broadcast.py +++ b/Thunder/utils/broadcast.py @@ -21,7 +21,11 @@ from Thunder.utils.database import db from Thunder.utils.logger import logger from Thunder.utils.messages import ( + MSG_BROADCAST_CANCELLED_PREFIX, MSG_BROADCAST_COMPLETE, + MSG_BROADCAST_FAILED_USERS, + MSG_BROADCAST_NO_USERS, + MSG_BROADCAST_PROGRESS, MSG_BROADCAST_START, MSG_BUTTON_CANCEL_BROADCAST, MSG_INVALID_BROADCAST_CMD, @@ -42,6 +46,16 @@ InputUserDeactivated, ) +# Closed mapping for the exact exception set above -- no dead fallback branch. +_PERMANENT_ERROR_REASONS: dict[type[Exception], tuple[str, str]] = { + ChannelInvalid: ("Channel", "invalid channel"), + InputUserDeactivated: ("User", "deactivated account"), + UserIsBlocked: ("User", "blocked the bot"), + UserDeactivated: ("User", "deactivated account"), + PeerIdInvalid: ("Recipient", "invalid ID"), + ChatWriteForbidden: ("Chat", "write forbidden"), +} + # pacing between sends per worker + progress-edit cadence (M4a) _BROADCAST_PACE_SECONDS = 0.2 _PROGRESS_EVERY = 25 @@ -97,8 +111,8 @@ async def broadcast_message(client: Client, message: Message, mode: str = "all") 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}'." + await tg_call( + status_msg.edit_text, MSG_BROADCAST_FAILED_USERS.format(mode=mode), retries=0 ) except Exception: pass @@ -107,7 +121,7 @@ async def broadcast_message(client: Client, message: Message, mode: str = "all") if stats["total"] == 0: try: - await status_msg.edit_text(f"ℹ️ **No users found for broadcast mode:** `{mode}`") + await tg_call(status_msg.edit_text, MSG_BROADCAST_NO_USERS.format(mode=mode), retries=0) except Exception: pass del broadcast_ids[broadcast_id] @@ -147,11 +161,10 @@ async def worker(): if stats["success"] and stats["success"] % _PROGRESS_EVERY == 0: await _edit_progress(status_msg, stats) finally: - queue.task_done() if user is not None: await asyncio.sleep(_BROADCAST_PACE_SECONDS) - worker_count = max(1, int(getattr(Var, "BROADCAST_WORKERS", 4))) + worker_count = Var.BROADCAST_WORKERS workers = [ asyncio.create_task(worker(), name=f"broadcast_worker_{i}") for i in range(worker_count) ] @@ -173,7 +186,7 @@ async def worker(): if not producer_task.done(): producer_task.cancel() try: - await status_msg.delete() + await tg_call(status_msg.delete, retries=0) except Exception: pass broadcast_ids.pop(broadcast_id, None) @@ -188,7 +201,7 @@ async def worker(): ) if stats["cancelled"]: - completion_msg = "πŸ›‘ **Broadcast Cancelled**\n\n" + completion_msg + completion_msg = MSG_BROADCAST_CANCELLED_PREFIX + completion_msg try: await reply_safe(message, completion_msg, parse_mode=ParseMode.MARKDOWN) @@ -208,20 +221,7 @@ async def _send_one(client: Client, message: Message, user_id: int, stats: dict) await tg_call(message.reply_to_message.copy, user_id, retries=2) stats["success"] += 1 except _PERMANENT_ERRORS as e: - if isinstance(e, ChannelInvalid): - recipient_type, reason = "Channel", "invalid channel" - elif isinstance(e, InputUserDeactivated): - recipient_type, reason = "User", "deactivated account" - elif isinstance(e, UserIsBlocked): - recipient_type, reason = "User", "blocked the bot" - elif isinstance(e, UserDeactivated): - recipient_type, reason = "User", "deactivated account" - elif isinstance(e, PeerIdInvalid): - recipient_type, reason = "Recipient", "invalid ID" - elif isinstance(e, ChatWriteForbidden): - recipient_type, reason = "Chat", "write forbidden" - else: - recipient_type, reason = "Recipient", f"error: {type(e).__name__}" + recipient_type, reason = _PERMANENT_ERROR_REASONS[type(e)] logger.warning(f"{recipient_type} {user_id} removed due to {reason}") try: @@ -244,8 +244,10 @@ async def _send_one(client: Client, message: Message, user_id: int, stats: dict) async def _edit_progress(status_msg: Message, stats: dict) -> None: try: - await status_msg.edit_text( - f"πŸ“£ **Broadcasting...** βœ… {stats['success']} / {stats['total']} delivered" + await tg_call( + status_msg.edit_text, + MSG_BROADCAST_PROGRESS.format(success=stats["success"], total=stats["total"]), + retries=0, ) except Exception: pass diff --git a/Thunder/utils/canonical_files.py b/Thunder/utils/canonical_files.py index 3f53a4d..d202e3b 100644 --- a/Thunder/utils/canonical_files.py +++ b/Thunder/utils/canonical_files.py @@ -13,6 +13,7 @@ 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.utils.media_types import ext_and_mime_for_class from Thunder.utils.safe_call import tg_call from Thunder.vars import Var @@ -30,8 +31,8 @@ # M14: bounded touch buffer -- overflow drops increments (counted) instead # of growing memory; flushes batch into a single BulkWrite. -_FLUSH_DELAY_SECONDS = max(1, min(60, int(getattr(Var, "TOUCH_FLUSH_SECONDS", 3)))) -_TOUCH_BUFFER_MAX = max(100, int(getattr(Var, "TOUCH_BUFFER_MAX", 1000))) +_FLUSH_DELAY_SECONDS = max(1, min(60, Var.TOUCH_FLUSH_SECONDS)) +_TOUCH_BUFFER_MAX = max(100, min(10_000, Var.TOUCH_BUFFER_MAX)) _dropped_touches = 0 _cache_by_unique_id: "OrderedDict[str, tuple[float, dict[str, Any]]]" = OrderedDict() @@ -50,16 +51,12 @@ def build_public_hash(file_unique_id: str) -> str: def _infer_mime_type(media: Any) -> str: + """Mime for a record: the media's own mime when present, else the + canonical map (NOT a local mini-map that drifted from it).""" 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") + return ext_and_mime_for_class(type(media).__name__.lower())[1] def build_file_record( @@ -210,7 +207,10 @@ async def _bulk_flush() -> None: await db.bulk_touch_file_records([(h, reused) for h, (_, reused) in items]) except Exception as e: # merge the batch back so the next flush retries -- clearing before - # the write succeeded silently discarded every pending increment + # the write succeeded silently discarded every pending increment. + # NOTE: with an ordered=False bulk write failing part-way, this + # re-applies increments for ops that DID apply -- seen_count may + # over-count for that subset (counters only; accepted trade-off). logger.error(f"Failed to bulk-flush {len(items)} touches: {e}", exc_info=True) for h, payload in items: _pending_touches.setdefault(h, payload) @@ -276,18 +276,11 @@ async def update_cached_file_id(record: dict[str, Any], file_id: str) -> None: await db.update_file_id(record["public_hash"], file_id, raise_on_error=True) -async def _fetch_canonical_message(record: dict[str, Any], client=None) -> Message | None: +async def _fetch_canonical_message(record: dict[str, Any], client) -> Message | None: canonical_message_id = record.get("canonical_message_id") if canonical_message_id is None: return None - # M12 layering break: the client is passed in by callers; the lazy - # fallback keeps backward compatibility for existing call sites. - if client is None: - from Thunder.bot import StreamBot - - client = StreamBot - try: message = await tg_call( client.get_messages, @@ -307,16 +300,14 @@ async def _fetch_canonical_message(record: dict[str, Any], client=None) -> Messa return message -async def _is_canonical_record_valid( - record: dict[str, Any], file_unique_id: str, client=None -) -> bool: +async def _is_canonical_record_valid(record: dict[str, Any], file_unique_id: str, client) -> bool: message = await _fetch_canonical_message(record, client) return bool(message and get_uniqid(message) == file_unique_id) async def _get_reusable_canonical_record( file_unique_id: str, - client=None, + client, ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]: existing = await get_file_by_unique_id(file_unique_id) if not existing: @@ -345,7 +336,7 @@ async def _get_reusable_canonical_record( async def _wait_for_other_worker_canonical_record( - file_unique_id: str, client=None + file_unique_id: str, client ) -> dict[str, Any] | None: loop = asyncio.get_running_loop() deadline = loop.time() + _INGEST_CLAIM_WAIT_SECONDS @@ -375,6 +366,12 @@ def _merge_replacement_record( refreshed["first_source_message_id"] = existing.get( "first_source_message_id", refreshed.get("first_source_message_id") ) + # Preserve the existing public_hash: re-hashing here would rewrite a + # legacy 20-char hash to the new 32-hex family and permanently break + # every published legacy link (the L4 "valid forever" contract). + preserved_hash = existing.get("public_hash") + if preserved_hash: + refreshed["public_hash"] = preserved_hash return refreshed @@ -408,7 +405,7 @@ async def file_ingest_lock(file_unique_id: str): async def get_or_create_canonical_file( source_message: Message, copy_media: Callable[[Message], Awaitable[Message | None]], - client=None, + client, ) -> tuple[dict[str, Any] | None, Message | None, bool]: file_unique_id = get_uniqid(source_message) if not file_unique_id: @@ -426,10 +423,10 @@ async def get_or_create_canonical_file( schedule_touch_file_record(reusable_record, reused=True) return reusable_record, None, True - claim_acquired = await db.acquire_file_ingest_claim( + claim_owner = await db.acquire_file_ingest_claim( file_unique_id, ttl_seconds=_INGEST_CLAIM_TTL_SECONDS ) - if not claim_acquired: + if not claim_owner: reusable_record = await _wait_for_other_worker_canonical_record( file_unique_id, client ) @@ -490,7 +487,7 @@ async def get_or_create_canonical_file( ) return None, stored_message, False finally: - await db.release_file_ingest_claim(file_unique_id) + await db.release_file_ingest_claim(file_unique_id, claim_owner) logger.error(f"Max ingest retries ({_MAX_INGEST_RETRIES}) exhausted for {file_unique_id}") return None, None, False diff --git a/Thunder/utils/custom_dl.py b/Thunder/utils/custom_dl.py index 5721206..c4d00e5 100644 --- a/Thunder/utils/custom_dl.py +++ b/Thunder/utils/custom_dl.py @@ -8,13 +8,17 @@ from pyrogram.errors import FloodWait from pyrogram.types import Message -from Thunder.server.exceptions import FileNotFound +from Thunder.server.exceptions import FileNotFound, TelegramUnavailable from Thunder.utils.file_properties import get_media from Thunder.utils.logger import logger from Thunder.utils.media_types import ext_and_mime_for_class from Thunder.utils.safe_call import tg_call from Thunder.vars import Var +# M9/H4b: bound the total time one streaming handler (and its admission +# slot) may stay pinned by repeated FloodWaits before we give up with 503. +_MAX_STREAM_FLOODWAIT_SECONDS = 60.0 + class ByteStreamer: __slots__ = ("client", "chat_id") @@ -27,21 +31,26 @@ async def get_message(self, message_id: int) -> Message: # H4b/H8: bounded FloodWait handling via tg_call -- the previous # open-ended sleep loop could pin an HTTP handler (and its stream # slot) indefinitely on a sustained Telegram flood. + # + # Transient Telegram failures raise TelegramUnavailable, NEVER + # FileNotFound: the delivery route self-heals (deletes the record) + # on FileNotFound, so conflating the two let a Telegram brownout + # destroy valid vault records en masse. try: message = await tg_call( self.client.get_messages, self.chat_id, message_id, retries=2, timeout=60 ) except FloodWait as e: - raise FileNotFound(f"Message {message_id} unavailable (FloodWait {e.value}s)") from e + raise TelegramUnavailable( + f"Telegram temporarily unavailable (FloodWait {e.value}s)" + ) from e except Exception as e: logger.debug(f"Error fetching message {message_id}: {e}", exc_info=True) - raise FileNotFound(f"Message {message_id} not found") from e + raise TelegramUnavailable(f"Telegram fetch failed for message {message_id}") from e - if isinstance(message, list): # defensive: pyrogram returns a list for list inputs - if not message: - raise FileNotFound(f"Message {message_id} not found") - message = message[0] - if not message or not message.media: + # pyrogram's stubs declare `Message | list[Message]`, but a single + # id always yields a single Message; a list here means bad input + if isinstance(message, list) or not message or not message.media: raise FileNotFound(f"Message {message_id} not found") return message @@ -56,15 +65,16 @@ async def stream_file( if limit > 0: chunk_limit = ((limit + (1024 * 1024) - 1) // (1024 * 1024)) + 1 - # H4b: the historical fallback-message plumbing was dead (the - # fallback id always equalled the primary ref, so the fallback ref - # was never appended) -- removed. + # Fetch the target ONCE, outside the retry loop: a per-retry re-fetch + # cost one extra get_messages RPC per FloodWait, and a failing + # re-fetch turned a mid-stream hiccup of an already-streaming file + # into a spurious not-found. + target = await self.get_message(media_ref) if isinstance(media_ref, int) else media_ref + chunks_done = 0 + floodwait_sleep_total = 0.0 while True: try: - target = ( - await self.get_message(media_ref) if isinstance(media_ref, int) else media_ref - ) # stream_media is an async generator in pyrofork; the stubs # union it with file_ref types, so narrow via ignore here. async for chunk in self.client.stream_media( # type: ignore[union-attr] @@ -82,11 +92,18 @@ async def stream_file( if chunk_limit: chunk_limit = max(chunk_limit - chunks_done, 0) chunks_done = 0 + # bound total pinned time on sustained floods (was unbounded) + if floodwait_sleep_total + e.value > _MAX_STREAM_FLOODWAIT_SECONDS: + raise TelegramUnavailable( + "Sustained Telegram flood while streaming " + f">{_MAX_STREAM_FLOODWAIT_SECONDS:.0f}s; try again shortly" + ) from e + floodwait_sleep_total += e.value logger.debug(f"FloodWait: stream_file, sleep {e.value}s") await asyncio.sleep(e.value) except Exception as e: logger.debug(f"Error streaming media ref {media_ref}: {e}", exc_info=True) - raise FileNotFound(f"Unable to stream file: {e}") from e + raise TelegramUnavailable(f"Unable to stream file: {e}") from e def get_file_info_sync(self, message: Message) -> dict[str, Any]: media = get_media(message) diff --git a/Thunder/utils/decorators.py b/Thunder/utils/decorators.py index b87ba30..76eebde 100644 --- a/Thunder/utils/decorators.py +++ b/Thunder/utils/decorators.py @@ -17,6 +17,7 @@ """ import html +from urllib.parse import quote_plus from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message @@ -25,6 +26,7 @@ from Thunder.utils.logger import logger from Thunder.utils.messages import ( MSG_DECORATOR_BANNED, + MSG_ERROR_ANONYMOUS_SENDER, MSG_ERROR_TEMP, MSG_ERROR_TOKEN_LINK_FAILED, MSG_ERROR_UNAUTHORIZED, @@ -95,10 +97,17 @@ async def check_banned(client, message: Message) -> bool: async def check_private_mode(client, message: Message) -> bool: """PRIVATE_MODE allowlist gate (M12): owner + authorized users only.""" - if not getattr(Var, "PRIVATE_MODE", False): + if not Var.PRIVATE_MODE: return True if not message.from_user: - return True + # Channel-posted / anonymous-admin messages have no verifiable user + # id, so allowlist membership cannot be checked: fail-closed. + logger.debug("Rejected unattributable sender (PRIVATE_MODE, no from_user).") + try: + await reply_safe(message, MSG_PRIVATE_MODE_DENIED) + except Exception: + pass + return False user_id = message.from_user.id if user_id == Var.OWNER_ID: return True @@ -123,11 +132,21 @@ async def check_private_mode(client, message: Message) -> bool: async def require_token(client, message: Message) -> bool: """Token-activation gate (H7: cached checks, fail-closed).""" try: - if not message.from_user: + # NOTE: the TOKEN_ENABLED short-circuit comes FIRST -- when the + # feature is off this gate must be a no-op even for anonymous + # senders, or group /link via an anonymous admin would break. + if not Var.TOKEN_ENABLED: return True - if not getattr(Var, "TOKEN_ENABLED", False): - return True + if not message.from_user: + # Channel-posted / anonymous senders cannot hold an activation + # token: fail-closed (they also cannot complete the flow). + logger.debug("Denied unattributable sender (token gate, no from_user).") + try: + await reply_safe(message, MSG_ERROR_ANONYMOUS_SENDER) + except Exception: + pass + return False user_id = message.from_user.id if user_id == Var.OWNER_ID: @@ -182,7 +201,9 @@ async def require_token(client, message: Message) -> bool: except Exception: pass return False - deep_link = f"https://t.me/{me.username}?start={temp_token_string}" + deep_link = ( + "https://t.me/" + me.username + "?start=" + quote_plus(temp_token_string, safe="") + ) short_url = deep_link try: @@ -210,9 +231,7 @@ async def require_token(client, message: Message) -> bool: except Exception as e: logger.error(f"Error in require_token: {e}", exc_info=True) try: - await reply_safe( - message, "An error occurred while checking your authorization. Please try again." - ) + await reply_safe(message, MSG_ERROR_UNEXPECTED) except Exception as inner_e: logger.error( f"Failed to send error message to user in require_token: {inner_e}", exc_info=True @@ -223,7 +242,7 @@ async def require_token(client, message: Message) -> bool: async def get_shortener_status(client, message: Message) -> bool: try: user_id = message.from_user.id if message.from_user else None - use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False) + use_shortener = Var.SHORTEN_MEDIA_LINKS if user_id: try: if user_id == Var.OWNER_ID or await allowed(user_id): @@ -236,7 +255,7 @@ async def get_shortener_status(client, message: Message) -> bool: 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) + return Var.SHORTEN_MEDIA_LINKS # -------------------------------------------------------------------------- @@ -244,31 +263,37 @@ async def get_shortener_status(client, message: Message) -> bool: # -------------------------------------------------------------------------- #: gate registry -- order is the documented contract; adding a new gate is a -#: one-place change here (gate chain asserted by tests/test_unit/test_registry.py). +#: one-place change here (chain asserted by tests/test_unit/test_preflight.py). PREFLIGHT_GATES = { "banned": check_banned, "private_mode": check_private_mode, "token": require_token, } +#: preset gate chains -- the documented orders, so callers cannot invent +#: their own sequence and a new command cannot forget a gate +GATES_STANDARD: tuple = ("banned", "private_mode", "token") +GATES_START: tuple = ("banned", "private_mode") +GATES_INFO: tuple = ("banned",) + async def preflight( client, message: Message, *, - gates: tuple = ("banned", "private_mode", "token"), + gates: tuple = GATES_STANDARD, ) -> bool | None: """Run the standard gate chain in order. Returns the final shortener status (last gate's value convention) or - ``None`` when any gate rejects the request. + ``None`` when any gate rejects the request. Unknown gate ids REJECT + (fail-closed) -- a typo'd id must never silently disable a check. """ for name in gates: gate = PREFLIGHT_GATES.get(name) if gate is None: - # a typo'd gate id must never silently disable a security check - logger.warning(f"preflight: unknown gate {name!r} skipped -- fix the gate id") - continue + logger.error(f"preflight: unknown gate {name!r}; rejecting request (fail-closed)") + return None if not await gate(client, message): return None return await get_shortener_status(client, message) @@ -307,7 +332,7 @@ async def owner_only(client, update) -> bool: logger.error(f"Error in owner_only: {e}", exc_info=True) try: if hasattr(update, "answer"): - await answer_safe(update, "An error occurred. Please try again.", show_alert=True) + await answer_safe(update, MSG_ERROR_UNEXPECTED, 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/flag_cache.py b/Thunder/utils/flag_cache.py index dc1285c..5815774 100644 --- a/Thunder/utils/flag_cache.py +++ b/Thunder/utils/flag_cache.py @@ -79,16 +79,6 @@ async def _load_and_store( self._data.popitem(last=False) return value - def peek(self, key: Hashable) -> tuple[bool, Any]: - """Non-loading read: ``(hit, value)``.""" - if key not in self._data: - return False, None - value, ts = self._data[key] - if time.monotonic() - ts > self.ttl_seconds: - self._data.pop(key, None) - return False, None - return True, value - def invalidate(self, *keys: Hashable) -> None: for key in keys: self._data.pop(key, None) @@ -96,9 +86,6 @@ def invalidate(self, *keys: Hashable) -> None: def clear(self) -> None: self._data.clear() - def occupancy(self) -> int: - return len(self._data) - async def sweep(self) -> int: now = time.monotonic() before = len(self._data) @@ -122,4 +109,4 @@ async def run_sweeper(self) -> None: flags = FlagCache(name="user_flags") -__all__ = ["FlagCache", "flags", "DEFAULT_TTL_SECONDS"] +__all__ = ["FlagCache", "flags", "DEFAULT_TTL_SECONDS", "DEFAULT_MAX_ITEMS"] diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py index e2c6e33..ab2a7c5 100644 --- a/Thunder/utils/force_channel.py +++ b/Thunder/utils/force_channel.py @@ -9,7 +9,12 @@ from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message from Thunder.utils.logger import logger -from Thunder.utils.messages import MSG_COMMUNITY_CHANNEL +from Thunder.utils.messages import ( + MSG_COMMUNITY_CHANNEL, + MSG_FORCE_JOIN_BUTTON, + MSG_FORCE_SUB_CHECK_FAILED, + MSG_FORCE_SUB_REQUIRED, +) from Thunder.utils.safe_call import reply_safe, tg_call from Thunder.vars import Var @@ -88,23 +93,22 @@ async def force_channel_check(client: Client, message: Message): channel_title=html.escape(title or "Channel") ), parse_mode=ParseMode.HTML, - reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton("Join", url=link)]]), + reply_markup=InlineKeyboardMarkup( + [[InlineKeyboardButton(MSG_FORCE_JOIN_BUTTON, url=link)]] + ), ) except Exception as e: logger.warning(f"Could not send force-sub prompt: {e}") else: try: - await reply_safe(message, "You must join the channel to use this bot.") + await reply_safe(message, MSG_FORCE_SUB_REQUIRED) except Exception as e: logger.warning(f"Could not send force-sub notice: {e}") return False except Exception as e: logger.error(f"Error checking force channel: {e}", exc_info=True) try: - await reply_safe( - message, - "An unexpected error occurred while checking channel membership. Please try again.", - ) + await reply_safe(message, MSG_FORCE_SUB_CHECK_FAILED) except Exception as inner_e: logger.warning(f"Could not send force-sub error notice: {inner_e}") return False diff --git a/Thunder/utils/logger.py b/Thunder/utils/logger.py index 3b035d5..2601165 100644 --- a/Thunder/utils/logger.py +++ b/Thunder/utils/logger.py @@ -25,17 +25,29 @@ BOT_TOKEN_PATTERN = re.compile(r"\d{8,10}:[A-Za-z0-9_-]{35,}") MONGO_URI_PATTERN = re.compile(r"mongodb(\+srv)?://[^:]+:[^@]+@") SESSION_TOKEN_PATTERN = re.compile(r"(?i)(authorization:\s*)(Bearer\s+)?[A-Za-z0-9._\-]{20,}") +# API_HASH assignments (32-hex value; contextual so file hashes still log) +API_HASH_PATTERN = re.compile(r"(?i)(api_hash['\"]?\s*[:=]\s*['\"]?)([0-9a-f]{32})") +# pyrogram session strings (long base64url blobs assigned to session vars) +SESSION_STRING_PATTERN = re.compile( + r"(?i)(session_string['\"]?\s*[:=]\s*['\"]?)([A-Za-z0-9_-]{40,})" +) +# activation tokens in t.me deep links (?start=<43-char urlsafe token>) +ACTIVATION_TOKEN_PATTERN = re.compile(r"(\?start=)([A-Za-z0-9_-]{43})") REDACTED = "***REDACTED***" def redact_secrets(text: str) -> str: - """Strip bot tokens and Mongo credentials from a log payload.""" + """Strip bot tokens, Mongo credentials, API hashes, session strings and + activation tokens from a log payload.""" if not text: return text text = BOT_TOKEN_PATTERN.sub(REDACTED, text) text = MONGO_URI_PATTERN.sub("mongodb://***:***@", text) text = SESSION_TOKEN_PATTERN.sub(r"\1\2" + REDACTED, text) + text = API_HASH_PATTERN.sub(r"\1" + REDACTED, text) + text = SESSION_STRING_PATTERN.sub(r"\1" + REDACTED, text) + text = ACTIVATION_TOKEN_PATTERN.sub(r"\1" + REDACTED, text) return text diff --git a/Thunder/utils/media_types.py b/Thunder/utils/media_types.py index 00fbf50..5f32bb2 100644 --- a/Thunder/utils/media_types.py +++ b/Thunder/utils/media_types.py @@ -4,9 +4,11 @@ Historically three drifted copies existed (``custom_dl.get_file_info_sync``, ``file_properties.get_fname``, ``common.send_file_dc``); this module replaces -all of them (plan H4c). Both naming families are keyed: pyrogram class -names come out lower-cased with no underscore (``videonote``) while message -attribute names use ``video_note`` -- both are accepted everywhere. +the two mime/extension-driven ones (plan H4c). ``common.send_file_dc`` keeps +its own display-name map, which is a presentation concern, not a mime/ext one. +Both naming families are keyed: pyrogram class names come out lower-cased with +no underscore (``videonote``) while message attribute names use ``video_note`` +-- both are accepted everywhere. """ # message attribute name -> stable canonical key diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py index 9eb3282..733d8d5 100644 --- a/Thunder/utils/messages.py +++ b/Thunder/utils/messages.py @@ -28,6 +28,15 @@ "πŸ”’ **Private bot.** This instance is restricted to authorized users. " "If you believe you should have access, contact the owner." ) +MSG_ERROR_ANONYMOUS_SENDER = ( + "πŸ™ˆ **Unidentifiable sender.** This command needs a regular user account " + "(anonymous admins and channel posts cannot use it)." +) +MSG_FORCE_JOIN_BUTTON = "πŸ“’ Join" +MSG_FORCE_SUB_REQUIRED = "You must join the channel to use this bot." +MSG_FORCE_SUB_CHECK_FAILED = ( + "An unexpected error occurred while checking channel membership. Please try again." +) # ------ File & Media Errors ------ MSG_ERROR_PROCESSING_MEDIA = "⚠️ **Oops!** Something went wrong while processing your media. Please try again. If the issue persists, contact support." @@ -40,7 +49,6 @@ MSG_TOKEN_FAILED = ( "⚠️ **Token Activation Failed!**\n\n" "> ❗ Reason: {reason}\n\n" - "> πŸ†” Support ID: {error_id}\n\n" "πŸ”‘ Please check your token or contact support." ) MSG_SHELL_ERROR = """**❌ Shell Command Error ❌** @@ -118,6 +126,7 @@ MSG_SHELL_EXECUTING = "Executing Command... βš™οΈ\n
{command}
" MSG_SHELL_OUTPUT = """**Shell Command Output:**
{output}
""" +MSG_SHELL_OUTPUT_CAPTION = "**Shell Command Output** (see attached file):\n
{command}
" MSG_SHELL_OUTPUT_STDOUT = "[stdout]:\n
{output}
" MSG_SHELL_OUTPUT_STDERR = "[stderr]:\n
{error}
" MSG_SHELL_NO_OUTPUT = "βœ… Command Executed: No output." @@ -313,6 +322,10 @@ "`/broadcast regular` - Broadcast to regular (non-authorized) users only\n\n" "**Note:** Reply to the message you want to broadcast." ) +MSG_BROADCAST_FAILED_USERS = "❌ **Broadcast Failed:** Unable to fetch users for mode '{mode}'." +MSG_BROADCAST_NO_USERS = "ℹ️ **No users found for broadcast mode:** `{mode}`" +MSG_BROADCAST_CANCELLED_PREFIX = "πŸ›‘ **Broadcast Cancelled**\n\n" +MSG_BROADCAST_PROGRESS = "πŸ“£ **Broadcasting...** βœ… {success} / {total} delivered" # ===================================================================================== # ====== PERMISSION MESSAGES ====== diff --git a/Thunder/utils/rate_limiter.py b/Thunder/utils/rate_limiter.py index 0621114..23acb1e 100644 --- a/Thunder/utils/rate_limiter.py +++ b/Thunder/utils/rate_limiter.py @@ -12,8 +12,12 @@ inside the executor, double-charging every queued request); FloodWait inside a worker requeues the request with an attempt counter instead of sleeping the worker. -H6c: a global RPS token-bucket breaker (Β§5.1b) sheds bursts before they all -hit Telegram-side FLOOD_WAIT at once. +H6c: a global RPS token-bucket breaker (Β§5.1b) shapes bursts through the +queue: the immediate path also consumes a breaker token, and when the +bucket is dry the request falls through to the queue instead of being +dropped -- queued workers consume tokens at exec time, so bursts of +distinct users are spread out instead of all hitting Telegram-side +FLOOD_WAIT at once. """ import asyncio @@ -90,7 +94,6 @@ class RateLimiter: def __init__(self): self.request_queue: deque[dict] = deque() self.priority_queue: deque[dict] = deque() - self.user_queue_counts: dict[int, int] = {} self.request_event: asyncio.Event = asyncio.Event() self.request_lock: asyncio.Lock = asyncio.Lock() @@ -254,17 +257,10 @@ async def sweep(self) -> dict[str, int]: self.file_processing_times.pop(key, None) dropped_files += 1 - dropped_counts = 0 - for user_id, count in list(self.user_queue_counts.items()): - if count <= 0: - self.user_queue_counts.pop(user_id, None) - dropped_counts += 1 - return { "user_windows": dropped_users, "global_entries": dropped_global, "file_entries": dropped_files, - "stale_counts": dropped_counts, } def occupancy(self) -> dict[str, float | int]: @@ -282,6 +278,12 @@ async def _requeue_request(self, request_data: dict, queue_type: str, delay: flo if delay > 0: request_data["not_before"] = time.time() + delay async with self.request_lock: + # Re-insertion policy (deliberate, bounded by MAX_REQUEST_ATTEMPTS): + # FloodWait/breaker requeues go to the FRONT -- those requests have + # already waited (their delay has elapsed by the time they run + # again), while not-yet-executed peers have not. Deferred + # rotations in _process_one go to the BACK -- they have not waited + # yet and must not jump the queue. if queue_type == "priority": self.priority_queue.appendleft(request_data) else: @@ -328,7 +330,6 @@ async def add_to_queue( self.request_queue.append(request_data) queue_name = "regular" - self.user_queue_counts[user_id] = self.user_queue_counts.get(user_id, 0) + 1 logger.debug( f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}" ) @@ -373,14 +374,16 @@ async def _process_one(self) -> bool: return True if record: request_data["charged"] = True - if self.global_rate_limit_enabled and not self.breaker.allow(): + if self.breaker.rate > 0 and not self.breaker.allow(): + # breaker is active via GLOBAL_RPS_LIMIT or the derived + # per-minute rate -- same shaping at exec time, same requeue + # discipline for both sources retry = max(self.breaker.retry_after(), 0.5) await self._requeue_request(request_data, queue_type, delay=retry) return True logger.debug(f"Processing request for user {user_id} from {queue_type} queue.") start_time = time.time() - processed = False try: await request_data["func"](*request_data["args"], **request_data["kwargs"]) processing_time = time.time() - start_time @@ -396,7 +399,6 @@ async def _process_one(self) -> bool: file_identifier, deque(maxlen=100) ) file_times.append(processing_time) - processed = True except FloodWait as e: # H6b: requeue with an attempt counter instead of stalling the @@ -408,30 +410,16 @@ async def _process_one(self) -> bool: f"Dropping request for user {user_id} after {attempts} " f"FloodWait requeues (last wait {e.value}s)." ) - processed = True # leave the queue await self._notify_drop(request_data) else: logger.warning(f"FloodWait for user {user_id}, requeueing (attempt {attempts}).") await self._requeue_request(request_data, queue_type, delay=min(e.value, 300.0)) except asyncio.CancelledError: - # Shutdown/cancellation: release the queue slot so the user's - # count does not leak, then propagate. - async with self.request_lock: - if user_id in self.user_queue_counts: - self.user_queue_counts[user_id] -= 1 - if self.user_queue_counts[user_id] <= 0: - self.user_queue_counts.pop(user_id, None) + # Shutdown/cancellation: propagate; the queue item is already + # popped, so nothing further to release. raise except Exception as e: logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) - processed = True - finally: - if processed: - async with self.request_lock: - if user_id in self.user_queue_counts: - self.user_queue_counts[user_id] -= 1 - if self.user_queue_counts[user_id] <= 0: - self.user_queue_counts.pop(user_id, None) return True async def _notify_drop(self, request_data: dict) -> None: @@ -496,7 +484,6 @@ async def shutdown(self): async with self.request_lock: self.request_queue.clear() self.priority_queue.clear() - self.user_queue_counts.clear() self.request_event.clear() if self._deferred_timer is not None: self._deferred_timer.cancel() @@ -596,7 +583,7 @@ async def estimate_wait_time(self, user_id: int, file_identifier: str | None = N def start_executors() -> list[asyncio.Task]: """Start the worker pool (H6b) -- callers keep the tasks for shutdown.""" workers: list[asyncio.Task] = [] - for i in range(max(1, int(getattr(Var, "EXECUTOR_WORKERS", 5)))): + for i in range(Var.EXECUTOR_WORKERS): workers.append( asyncio.create_task( rate_limiter.request_executor(), name=f"request_executor_worker_{i}" @@ -625,19 +612,16 @@ async def handle_rate_limited_request( await handler(bot, message, *args, **kwargs) return - # H6c: probe without consuming -- the queued exec path below is where - # breaker tokens are consumed. The immediate path is gated by the 60s - # user window only (charging here == charging at exec); sub-second - # breaker throttling therefore applies to queued traffic, not bursts of - # within-window users. - if rate_limiter.global_rate_limit_enabled and rate_limiter.breaker.retry_after() > 0: - logger.warning(f"Global RPS breaker engaged; shedding request for user {user_id}.") - if not (rl_user_id is not None and rl_user_id < 0): - await send_queue_full_message(bot, message, file_identifier) - return - - # Immediate path: executes right now, so charging here == charging at exec. - if await rate_limiter.check_limits(user_id, record=True): + # H6c (revised): the immediate path now consumes a breaker token too. + # Bursts of distinct within-window users were exactly the traffic that + # produced Telegram-side FLOOD_WAITs, and it never touched the bucket + # (tokens were consumed only by the queued exec path). A dry bucket no + # longer sheds: the request falls through to the queue, where workers + # consume tokens at exec time, so bursts are shaped instead of dropped. + immediate = await rate_limiter.check_limits(user_id, record=False) + if immediate and rate_limiter.breaker.rate > 0: + immediate = rate_limiter.breaker.allow() # consumes a token on success + if immediate and await rate_limiter.check_limits(user_id, record=True): logger.debug(f"User {user_id} within rate limits, executing immediately.") await handler(bot, message, *args, **kwargs) return diff --git a/Thunder/utils/render_template.py b/Thunder/utils/render_template.py index fba7cc5..8af0ce3 100644 --- a/Thunder/utils/render_template.py +++ b/Thunder/utils/render_template.py @@ -59,7 +59,6 @@ def _page_kind(mime_type: str | None, file_name: str) -> str: async def render_media_page( file_name: str, src: str, - requested_action: str | None = None, mime_type: str | None = None, ) -> str: # NOTE: src must be a pre-encoded URL. Templates use |safe to avoid double-encoding. @@ -76,40 +75,38 @@ async def render_media_page( # L1: the legacy /watch route re-fetched the vault message from Telegram on # every view; a small TTL+LRU cache keeps repeat views off the API. -_legacy_cache: "OrderedDict[tuple[int, str], tuple[float, str, str]]" = OrderedDict() +_legacy_cache: "OrderedDict[tuple[int, str], tuple[float, str]]" = OrderedDict() _LEGACY_CACHE_TTL_SECONDS = 600 _LEGACY_CACHE_MAX_ITEMS = 1024 -def _legacy_cache_get(key) -> tuple[str, str] | None: +def _legacy_cache_get(key) -> str | None: cached = _legacy_cache.get(key) if not cached: return None - ts, file_name, unique_id = cached + ts, file_name = cached if time.monotonic() - ts > _LEGACY_CACHE_TTL_SECONDS: _legacy_cache.pop(key, None) return None _legacy_cache.move_to_end(key) - return file_name, unique_id + return file_name -def _legacy_cache_put(key, file_name: str, unique_id: str) -> None: - _legacy_cache[key] = (time.monotonic(), file_name, unique_id) +def _legacy_cache_put(key, file_name: str) -> None: + _legacy_cache[key] = (time.monotonic(), file_name) _legacy_cache.move_to_end(key) while len(_legacy_cache) > _LEGACY_CACHE_MAX_ITEMS: _legacy_cache.popitem(last=False) -async def render_page( - message_id: int, secure_hash: str, requested_action: str | None = None -) -> str: +async def render_page(message_id: int, secure_hash: str) -> str: key = (int(message_id), str(secure_hash)) cached = _legacy_cache_get(key) if cached is not None: - file_name, _ = cached + file_name = cached quoted_filename = quote_media_name(file_name) src = urllib.parse.urljoin(Var.URL, f"{secure_hash}{message_id}/{quoted_filename}") - return await render_media_page(file_name, src, requested_action) + return await render_media_page(file_name, src) try: from Thunder.bot import StreamBot # M12 layering break: lazy import @@ -136,14 +133,16 @@ async def render_page( if not file_unique_id or file_unique_id[:6] != secure_hash: raise InvalidHash("File unique ID or secure hash mismatch during rendering.") - _legacy_cache_put(key, file_name, file_unique_id) + _legacy_cache_put(key, file_name) quoted_filename = quote_media_name(file_name) src = urllib.parse.urljoin(Var.URL, f"{secure_hash}{message_id}/{quoted_filename}") - return await render_media_page(file_name, src, requested_action) + return await render_media_page(file_name, src) except Exception as e: + # the capability hash is a credential: never write it to bot.txt + # (which /log uploads) -- log the message id instead logger.error( - f"Error in render_page for message_id {message_id} and hash {secure_hash}: {e}", + f"Error in render_page for message_id {message_id} (hash redacted): {e}", exc_info=True, ) raise diff --git a/Thunder/utils/safe_call.py b/Thunder/utils/safe_call.py index 6f475cb..3af7fb9 100644 --- a/Thunder/utils/safe_call.py +++ b/Thunder/utils/safe_call.py @@ -6,9 +6,11 @@ thin wrappers below instead of the historical copy-pasted ``try/except FloodWait`` pairs. Semantics preserved from the old pattern: -* on ``FloodWait`` the coroutine sleeps for ``e.value`` seconds and retries, - at most ``retries`` times (default 1 -- i.e. two attempts total, matching - the previous inline behaviour); +* on ``FloodWait`` the coroutine sleeps for ``min(e.value, MAX_FLOODWAIT_SLEEP_SECONDS)`` + seconds and retries, at most ``retries`` times (default 1 -- i.e. two + attempts total, matching the previous inline behaviour); Telegram can + send multi-minute FloodWaits, and an uncapped sleep let a "lightweight" + RPC pin its caller far beyond the wall-clock budget it advertises (H8); * after the retries are exhausted the exception propagates unchanged. Wall-clock budgets (H8): lightweight RPCs get a default timeout so a hung @@ -31,6 +33,10 @@ # edit_text, answer, ...). Env-overridable via TG_RPC_TIMEOUT_SECONDS. DEFAULT_RPC_TIMEOUT_SECONDS = 30.0 +# H8: cap a single FloodWait sleep so a lightweight RPC cannot exceed its +# advertised budget by minutes. Total sleep is also bounded by ``retries``. +MAX_FLOODWAIT_SLEEP_SECONDS = 30.0 + # Call shapes that are allowed to run unbounded by default (large media # transfers). Matched by attribute name of the callable. _UNBOUNDED_SHAPES = { @@ -53,13 +59,21 @@ } -def _env_timeout() -> float | None: - try: - from Thunder.vars import Var # lazy: avoids any import-order coupling +_env_timeout_cache: float | None = None + + +def _env_timeout() -> float: + # TG_RPC_TIMEOUT_SECONDS is static per process; resolve it once instead + # of re-importing + re-reading on every RPC. + global _env_timeout_cache + if _env_timeout_cache is None: + try: + from Thunder.vars import Var # lazy: avoids any import-order coupling - return float(getattr(Var, "TG_RPC_TIMEOUT_SECONDS", DEFAULT_RPC_TIMEOUT_SECONDS)) - except Exception: - return DEFAULT_RPC_TIMEOUT_SECONDS + _env_timeout_cache = float(Var.TG_RPC_TIMEOUT_SECONDS) + except Exception: + _env_timeout_cache = DEFAULT_RPC_TIMEOUT_SECONDS + return _env_timeout_cache def _default_timeout(fn: Callable[..., Awaitable[T]]) -> float | None: @@ -94,11 +108,12 @@ async def tg_call( attempt += 1 if attempt > retries: raise + sleep_for = min(e.value, MAX_FLOODWAIT_SLEEP_SECONDS) logger.debug( f"FloodWait in {getattr(fn, '__name__', fn)}, " - f"sleeping {e.value}s (attempt {attempt}/{retries})" + f"sleeping {sleep_for}s (asked {e.value}s, attempt {attempt}/{retries})" ) - await asyncio.sleep(e.value) + await asyncio.sleep(sleep_for) except Exception as e: if on_error is not None: try: @@ -136,4 +151,5 @@ async def answer_safe(query: Any, text: str = "", retries: int = 1, **kwargs: An "delete_safe", "answer_safe", "DEFAULT_RPC_TIMEOUT_SECONDS", + "MAX_FLOODWAIT_SLEEP_SECONDS", ] diff --git a/Thunder/vars.py b/Thunder/vars.py index 492808e..7b2360f 100644 --- a/Thunder/vars.py +++ b/Thunder/vars.py @@ -16,12 +16,33 @@ import os -from dotenv import load_dotenv +from dotenv import dotenv_values from Thunder.utils.logger import logger -load_dotenv("config.env") -load_dotenv("config.env.local") # optional local override layer + +def _load_env_layers() -> None: + """Load ``config.env`` then ``config.env.local`` with REAL precedence. + + ``load_dotenv(override=False)`` (the historical behaviour) never lets a + later file override keys an earlier file already set, so the documented + "local layer wins" was inverted -- operator edits in config.env.local + were silently ignored. Precedence now is: real environment > + config.env.local > config.env; existing os.environ entries still win + (same contract as load_dotenv's default). + """ + merged: dict[str, str | None] = {} + for path in ("config.env", "config.env.local"): + try: + merged.update(dotenv_values(path)) + except OSError as e: + logger.warning(f"Could not read {path}: {e}") + for key, value in merged.items(): + if value is not None: + os.environ.setdefault(key, value) + + +_load_env_layers() def str_to_bool(val: str) -> bool: @@ -37,6 +58,8 @@ def str_to_int_set(val: str) -> set[int]: try: result.add(int(x)) except (TypeError, ValueError): + # collect-all-errors (M6): junk tokens are surfaced, not skipped + _config_errors.append(f"{val!r} contains a non-integer entry: {x!r}") continue return result diff --git a/update.py b/update.py index def6353..d803e1d 100644 --- a/update.py +++ b/update.py @@ -90,6 +90,21 @@ def main() -> None: logger.info("Not a git repository; skipping self-update.") return + # defense-in-depth: UPSTREAM_REPO flows into git's remote handling, and + # the argv/dash guards do not stop the git-remote-ext family + # (ext::sh -c ...) -- allowlist the ordinary transport schemes only. + if "://" in UPSTREAM_REPO and UPSTREAM_REPO.split("://", 1)[0] not in { + "https", + "http", + "git", + "ssh", + }: + logger.error("UPSTREAM_REPO uses a unsupported scheme; skipping self-update.") + return + if UPSTREAM_REPO.startswith(("ext::", "ssh://;")): + logger.error("UPSTREAM_REPO uses a forbidden scheme; skipping self-update.") + return + backed_up = _backup_config() try: # git >= 2.27 warns without the refspec; be explicit. From 42fd3a803c76e7ec3d64cb1ec72831a3ceba6fb4 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 18:21:23 +0000 Subject: [PATCH 15/49] fix(infra): Makefile tabs, hash-pinned Docker install, CI sync gates + integration tier - Makefile recipes indented with hard TABs (spaces made every target fail with 'missing separator') - Dockerfile installs requirements.lock (uv export of uv.lock, --require-hashes): transitives were previously resolved fresh and unpinned on every build, so CI's supply-chain guarantees never reached the shipped image; git dropped from the image (self-update no-ops without .git by design -- replace the image instead) - CI: push trigger 'branches: ain]' corruption fixed to [main] - CI: requirements.txt<->pyproject and requirements.lock<->uv.lock sync gates (two sources of truth must agree) - CI: integration tier (real MongoDB via testcontainers) now actually runs -- the atomicity guarantees were never executed in CI - CI: concurrency group + job timeout-minutes - README: SLEEP_THRESHOLD 600 / GLOBAL_RATE_LIMIT False (vars.py ground truth); auto-update note (needs a git checkout) - AGENTS.md documents the requirements.lock flow --- .github/workflows/quality.yml | 46 +++ AGENTS.md | 7 +- Dockerfile | 17 +- Makefile | 34 +-- README.md | 8 +- requirements.lock | 537 ++++++++++++++++++++++++++++++++++ 6 files changed, 618 insertions(+), 31 deletions(-) create mode 100644 requirements.lock diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 4b6d6a8..efe4e2c 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -9,9 +9,14 @@ on: permissions: contents: read +concurrency: + group: quality-${{ github.ref }} + cancel-in-progress: true + jobs: quality: runs-on: ubuntu-latest + timeout-minutes: 30 steps: - uses: actions/checkout@v5 @@ -31,6 +36,24 @@ jobs: - name: Lockfile is current with pyproject (uv.lock must never drift) run: uv lock --check + - name: requirements.txt is in sync with pyproject (two sources of truth must agree) + run: | + uv run python - <<'PY' + import sys, tomllib + deps = {d.strip() for d in tomllib.load(open("pyproject.toml", "rb"))["project"]["dependencies"]} + listed = {l.strip() for l in open("requirements.txt") if l.strip() and not l.startswith("#")} + if deps != listed: + print("pyproject:", sorted(deps)) + print("requirements.txt:", sorted(listed)) + sys.exit("requirements.txt drifted from pyproject [project.dependencies]") + PY + + - name: requirements.lock is in sync with uv.lock (Docker consumes this) + run: | + uv export --frozen --no-dev --hashes -o /tmp/requirements.lock.check + # uv embeds the export command in the header; compare content only + diff -u <(grep -v '^#' requirements.lock) <(grep -v '^#' /tmp/requirements.lock.check) + - name: Ruff (lint + format check) run: | uv run ruff check Thunder/ update.py @@ -85,3 +108,26 @@ jobs: tags: | fyaz05/thunder:latest fyaz05/thunder:${{ github.sha }} + + integration: + # the atomicity guarantees (token activation CAS, ingest claims) are only + # proven against a real MongoDB -- this tier never ran in CI before + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install uv + run: python -m pip install --upgrade uv + + - name: Install dependencies (locked, incl. testcontainers) + run: uv sync --frozen --group dev + + - name: Integration tests (real MongoDB via testcontainers) + run: uv run pytest -m integration + env: + TEST_INTEGRATION: "1" diff --git a/AGENTS.md b/AGENTS.md index 2dad6c1..9c5b5ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,11 +18,14 @@ bash thunder.sh # best-effort self-update (shell-free) + python3 -m T Managed in `pyproject.toml`, exported to `requirements.txt` (8 direct deps, all exact-pinned; the CI dependency-count gate fails beyond 8). `uv.lock` pins the full transitive graph with hashes β€” regenerate it with -`uv lock` whenever `pyproject.toml` changes (CI fails if it drifts): +`uv lock` whenever `pyproject.toml` changes (CI fails if it drifts). +`requirements.lock` is the hash-pinned full-graph export that the Dockerfile +installs with `--require-hashes`; CI fails if it drifts from `uv.lock`: ```bash uv sync --frozen # reproducible env from the lockfile -pip install -r requirements.txt +pip install -r requirements.txt # direct pins (human installs) +pip install --require-hashes -r requirements.lock # what Docker ships # aiohttp, pyrofork, tgcrypto-pyrofork, pymongo, Jinja2, python-dotenv, psutil, uvloop ``` diff --git a/Dockerfile b/Dockerfile index e49654f..cfaaf7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,17 +5,18 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - git \ - && apt-get clean && \ - rm -rf /var/lib/apt/lists/* \ - && useradd --create-home --shell /bin/bash thunder +# no git in the image: self-update no-ops cleanly without .git/git, and a +# container should be replaced by pulling a new image, not mutating itself. +# python:3.13-slim already ships everything else the runtime needs. +RUN useradd --create-home --shell /bin/bash thunder -COPY requirements.txt . +# hash-pinned FULL graph (uv export of uv.lock) -- the previous direct-pins +# install resolved fresh, unpinned transitives on every build, so the +# supply-chain guarantees held in CI never reached the shipped image +COPY requirements.lock . RUN pip install --upgrade pip && \ - pip install --no-cache-dir -r requirements.txt + pip install --no-cache-dir --require-hashes -r requirements.lock COPY --chown=thunder:thunder . . diff --git a/Makefile b/Makefile index 9e144fb..4de79ee 100644 --- a/Makefile +++ b/Makefile @@ -4,32 +4,32 @@ # NOTE: recipes MUST be indented with hard TABs, not spaces. format: - ruff check Thunder/ update.py --fix - ruff format Thunder/ update.py + ruff check Thunder/ update.py --fix + ruff format Thunder/ update.py lint: - ruff check Thunder/ update.py - mypy Thunder --ignore-missing-imports + ruff check Thunder/ update.py + mypy Thunder --ignore-missing-imports test: - pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 + pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 coverage: - pytest -m unit --cov=Thunder --cov-report=html + pytest -m unit --cov=Thunder --cov-report=html audit: - uv run pip-audit - bandit -r Thunder -ll --skip B101 - vulture Thunder --min-confidence 80 - @count=$$(grep -cE '^[a-zA-Z0-9_-]+==' requirements.txt); \ - echo "Direct runtime deps: $$count"; \ - if [ "$$count" -gt 8 ]; then \ - echo "ERROR: dependency count increased beyond 8; justify or remove."; \ - exit 1; \ - fi + uv run pip-audit + bandit -r Thunder -ll --skip B101 + vulture Thunder --min-confidence 80 + @count=$$(grep -cE '^[a-zA-Z0-9_-]+==' requirements.txt); \ + echo "Direct runtime deps: $$count"; \ + if [ "$$count" -gt 8 ]; then \ + echo "ERROR: dependency count increased beyond 8; justify or remove."; \ + exit 1; \ + fi run: - python3 -m Thunder + python3 -m Thunder clean: - rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ + rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ diff --git a/README.md b/README.md index a18f316..3c41811 100644 --- a/README.md +++ b/README.md @@ -133,7 +133,7 @@ Copy `config_sample.env` to `config.env` and fill in your values. | `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` | +| `SLEEP_THRESHOLD` | Client switch threshold | `600` | | `WORKERS` | Async workers | `8` | | `NAME` | Bot name | `ThunderF2L` | | `BIND_ADDRESS` | Bind address | `0.0.0.0` | @@ -149,7 +149,7 @@ Copy `config_sample.env` to `config.env` and fill in your values. | `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` | +| `GLOBAL_RATE_LIMIT` | Global limiting | `False` | | `MAX_GLOBAL_REQUESTS_PER_MINUTE` | Global limit | `4` |
@@ -312,7 +312,7 @@ python3 -m venv venv source venv/bin/activate # Windows: venv\Scripts\activate # 3. Install dependencies -pip install -r requirements.txt +pip install -r requirements.txt # direct pins; Docker uses the hash-pinned requirements.lock # 4. Configure cp config_sample.env config.env @@ -373,7 +373,7 @@ After deployment, to add any additional environment variables, use the Koyeb das ```bash heroku ps:scale web=1 ``` -7. Set `UPSTREAM_REPO` for auto-updates on dyno restart: +7. Set `UPSTREAM_REPO` for auto-updates on dyno restart (requires a git checkout β€” Docker images update by pulling a new image instead): ```bash heroku config:set UPSTREAM_REPO="https://github.com/fyaz05/FileToLink" UPSTREAM_BRANCH="main" ``` diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..3f4ba42 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,537 @@ +# This file was autogenerated by uv via the following command: +# uv export --frozen --no-dev --hashes -o requirements.lock +aiohappyeyeballs==2.7.1 \ + --hash=sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d \ + --hash=sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472 + # via aiohttp +aiohttp==3.14.3 \ + --hash=sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf \ + --hash=sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc \ + --hash=sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147 \ + --hash=sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf \ + --hash=sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7 \ + --hash=sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85 \ + --hash=sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30 \ + --hash=sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553 \ + --hash=sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7 \ + --hash=sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86 \ + --hash=sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e \ + --hash=sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da \ + --hash=sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100 \ + --hash=sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71 \ + --hash=sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22 \ + --hash=sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1 \ + --hash=sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb \ + --hash=sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062 \ + --hash=sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661 \ + --hash=sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a \ + --hash=sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd \ + --hash=sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8 \ + --hash=sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0 \ + --hash=sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919 \ + --hash=sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f \ + --hash=sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098 \ + --hash=sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c \ + --hash=sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371 \ + --hash=sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b \ + --hash=sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f \ + --hash=sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100 \ + --hash=sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc \ + --hash=sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41 \ + --hash=sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716 \ + --hash=sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33 \ + --hash=sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80 \ + --hash=sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646 \ + --hash=sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e \ + --hash=sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b \ + --hash=sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963 \ + --hash=sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25 \ + --hash=sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f \ + --hash=sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807 \ + --hash=sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f \ + --hash=sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82 \ + --hash=sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0 \ + --hash=sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9 \ + --hash=sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0 \ + --hash=sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c \ + --hash=sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5 \ + --hash=sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b \ + --hash=sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4 \ + --hash=sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2 \ + --hash=sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9 \ + --hash=sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0 \ + --hash=sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883 \ + --hash=sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d \ + --hash=sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6 \ + --hash=sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924 \ + --hash=sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde \ + --hash=sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0 \ + --hash=sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9 \ + --hash=sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627 \ + --hash=sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0 \ + --hash=sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24 + # via thunder-filetolink +aiosignal==1.4.0 \ + --hash=sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e \ + --hash=sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7 + # via aiohttp +attrs==26.1.0 \ + --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ + --hash=sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32 + # via aiohttp +dnspython==2.8.0 \ + --hash=sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af \ + --hash=sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f + # via pymongo +frozenlist==1.8.0 \ + --hash=sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686 \ + --hash=sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0 \ + --hash=sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121 \ + --hash=sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd \ + --hash=sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7 \ + --hash=sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c \ + --hash=sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d \ + --hash=sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b \ + --hash=sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79 \ + --hash=sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f \ + --hash=sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7 \ + --hash=sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef \ + --hash=sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed \ + --hash=sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe \ + --hash=sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e \ + --hash=sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930 \ + --hash=sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37 \ + --hash=sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128 \ + --hash=sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2 \ + --hash=sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f \ + --hash=sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df \ + --hash=sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c \ + --hash=sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0 \ + --hash=sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad \ + --hash=sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82 \ + --hash=sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30 \ + --hash=sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62 \ + --hash=sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c \ + --hash=sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52 \ + --hash=sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d \ + --hash=sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1 \ + --hash=sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a \ + --hash=sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714 \ + --hash=sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65 \ + --hash=sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506 \ + --hash=sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888 \ + --hash=sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41 \ + --hash=sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8 \ + --hash=sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed \ + --hash=sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231 \ + --hash=sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496 \ + --hash=sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a \ + --hash=sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24 \ + --hash=sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7 \ + --hash=sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e \ + --hash=sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e \ + --hash=sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8 \ + --hash=sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51 \ + --hash=sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8 \ + --hash=sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806 \ + --hash=sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042 \ + --hash=sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a \ + --hash=sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2 \ + --hash=sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0 \ + --hash=sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e \ + --hash=sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b \ + --hash=sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d \ + --hash=sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1 \ + --hash=sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94 \ + --hash=sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822 \ + --hash=sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a \ + --hash=sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11 \ + --hash=sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51 \ + --hash=sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40 \ + --hash=sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5 \ + --hash=sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027 + # via + # aiohttp + # aiosignal +idna==3.19 \ + --hash=sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15 \ + --hash=sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4 + # via yarl +jinja2==3.1.6 \ + --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \ + --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67 + # via thunder-filetolink +markupsafe==3.0.3 \ + --hash=sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf \ + --hash=sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175 \ + --hash=sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219 \ + --hash=sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb \ + --hash=sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6 \ + --hash=sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab \ + --hash=sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218 \ + --hash=sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634 \ + --hash=sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73 \ + --hash=sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe \ + --hash=sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa \ + --hash=sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37 \ + --hash=sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97 \ + --hash=sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19 \ + --hash=sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9 \ + --hash=sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9 \ + --hash=sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc \ + --hash=sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4 \ + --hash=sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354 \ + --hash=sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698 \ + --hash=sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9 \ + --hash=sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc \ + --hash=sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485 \ + --hash=sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12 \ + --hash=sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025 \ + --hash=sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009 \ + --hash=sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d \ + --hash=sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5 \ + --hash=sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f \ + --hash=sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1 \ + --hash=sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287 \ + --hash=sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6 \ + --hash=sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581 \ + --hash=sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed \ + --hash=sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026 \ + --hash=sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676 \ + --hash=sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795 \ + --hash=sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5 \ + --hash=sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d \ + --hash=sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe \ + --hash=sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda \ + --hash=sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e \ + --hash=sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737 \ + --hash=sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523 \ + --hash=sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50 + # via jinja2 +multidict==6.7.1 \ + --hash=sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9 \ + --hash=sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581 \ + --hash=sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43 \ + --hash=sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1 \ + --hash=sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6 \ + --hash=sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c \ + --hash=sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262 \ + --hash=sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd \ + --hash=sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d \ + --hash=sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d \ + --hash=sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3 \ + --hash=sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601 \ + --hash=sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292 \ + --hash=sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed \ + --hash=sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362 \ + --hash=sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23 \ + --hash=sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2 \ + --hash=sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb \ + --hash=sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d \ + --hash=sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65 \ + --hash=sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d \ + --hash=sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108 \ + --hash=sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177 \ + --hash=sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5 \ + --hash=sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd \ + --hash=sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5 \ + --hash=sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060 \ + --hash=sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37 \ + --hash=sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56 \ + --hash=sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df \ + --hash=sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963 \ + --hash=sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118 \ + --hash=sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84 \ + --hash=sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f \ + --hash=sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889 \ + --hash=sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7 \ + --hash=sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048 \ + --hash=sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59 \ + --hash=sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709 \ + --hash=sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c \ + --hash=sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2 \ + --hash=sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee \ + --hash=sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609 \ + --hash=sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c \ + --hash=sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445 \ + --hash=sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1 \ + --hash=sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5 \ + --hash=sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31 \ + --hash=sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33 \ + --hash=sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7 \ + --hash=sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca \ + --hash=sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429 \ + --hash=sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9 \ + --hash=sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4 \ + --hash=sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2 \ + --hash=sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52 \ + --hash=sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c \ + --hash=sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2 \ + --hash=sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a \ + --hash=sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1 \ + --hash=sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c \ + --hash=sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e \ + --hash=sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8 \ + --hash=sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32 \ + --hash=sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3 \ + --hash=sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489 \ + --hash=sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23 \ + --hash=sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34 \ + --hash=sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8 \ + --hash=sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d \ + --hash=sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b \ + --hash=sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4 \ + --hash=sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d \ + --hash=sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0 + # via + # aiohttp + # yarl +propcache==0.5.2 \ + --hash=sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427 \ + --hash=sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa \ + --hash=sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7 \ + --hash=sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a \ + --hash=sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0 \ + --hash=sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94 \ + --hash=sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917 \ + --hash=sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3 \ + --hash=sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa \ + --hash=sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a \ + --hash=sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821 \ + --hash=sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb \ + --hash=sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853 \ + --hash=sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56 \ + --hash=sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55 \ + --hash=sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704 \ + --hash=sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f \ + --hash=sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999 \ + --hash=sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb \ + --hash=sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d \ + --hash=sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4 \ + --hash=sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03 \ + --hash=sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5 \ + --hash=sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979 \ + --hash=sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e \ + --hash=sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117 \ + --hash=sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa \ + --hash=sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96 \ + --hash=sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5 \ + --hash=sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191 \ + --hash=sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078 \ + --hash=sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837 \ + --hash=sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c \ + --hash=sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8 \ + --hash=sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe \ + --hash=sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d \ + --hash=sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539 \ + --hash=sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285 \ + --hash=sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d \ + --hash=sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4 \ + --hash=sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f \ + --hash=sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836 \ + --hash=sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f \ + --hash=sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e \ + --hash=sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe \ + --hash=sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a \ + --hash=sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39 \ + --hash=sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164 \ + --hash=sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2 \ + --hash=sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0 \ + --hash=sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335 \ + --hash=sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568 \ + --hash=sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4 \ + --hash=sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80 \ + --hash=sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2 \ + --hash=sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370 \ + --hash=sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4 \ + --hash=sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757 \ + --hash=sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825 \ + --hash=sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f \ + --hash=sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d \ + --hash=sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366 \ + --hash=sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc \ + --hash=sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c \ + --hash=sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7 \ + --hash=sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702 \ + --hash=sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098 \ + --hash=sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751 \ + --hash=sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e \ + --hash=sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6 + # via + # aiohttp + # yarl +psutil==7.2.2 \ + --hash=sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372 \ + --hash=sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9 \ + --hash=sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841 \ + --hash=sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63 \ + --hash=sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979 \ + --hash=sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a \ + --hash=sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b \ + --hash=sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9 \ + --hash=sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee \ + --hash=sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312 \ + --hash=sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b \ + --hash=sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9 \ + --hash=sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e \ + --hash=sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc \ + --hash=sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1 \ + --hash=sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf \ + --hash=sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea \ + --hash=sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988 \ + --hash=sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486 \ + --hash=sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00 \ + --hash=sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8 + # via thunder-filetolink +pyaes==1.6.1 \ + --hash=sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f + # via pyrofork +pymediainfo-pyrofork==6.0.2 \ + --hash=sha256:674fa8e53de861635b9dc4f77c2ad712306a798bf28864952503bf328210c4c3 \ + --hash=sha256:fce9402edfd1fa09aba7b3cac4c41ba7fcf6820e561b4db4f9c1a1a68c487c36 + # via pyrofork +pymongo==4.18.0 \ + --hash=sha256:03de6fdfbcd4ad0634313956bffcced13abc9e575b9c74dec70f69cc248b501c \ + --hash=sha256:04766d0930bc06dc99e3274a800fb81e85f2629f27ba7bc326e2d4d13ea449d5 \ + --hash=sha256:0a61148df54b254b4157aaba2139cb8fc97b8232281d783e0b7b2d58cb000fcb \ + --hash=sha256:0d821ca5865e88c2dc29e259a191d770a53093c6749c6b228f18b078cb598faf \ + --hash=sha256:13b1ad2110fbc8ec151e996ae8ea22727db8b2bd48515141d76d4fb54bd07da3 \ + --hash=sha256:277e61864ad6a064d75d7efbf5ce0e57378c420e48af6b7bad63e8356dcb22e1 \ + --hash=sha256:29c60dce70a300a09c0af39c2dbebd1a0199db9eb4d3ef03a4eeb587e7675668 \ + --hash=sha256:34f3c9adcc26dbfdc50cbb27da5c369588e6862241ea5cf96d33139f57fd9e0b \ + --hash=sha256:368cd67dd1d3ead5836c20457b84d674256f27b3e99c332d085081680c8465b9 \ + --hash=sha256:3c50998113e17737fc2048e0184eda8c3aef465acc5a554fb76e2b0266805278 \ + --hash=sha256:4343c06f00fafdd8c8d73521ed8b4c468aa74e1b8f65cd757fb5f6ac3df685b3 \ + --hash=sha256:47627909a177036117b91a3378df8634dc8e93cf4ccd66b22086b0098d3c72de \ + --hash=sha256:4a81d166a43e8af1e5152b6854a263ba0a8831f7dd2ca1badc716f219f4f1bc0 \ + --hash=sha256:4c6f85e33ef338148cd70bc682fc73bfd202a1cde554057d807905d2310767ad \ + --hash=sha256:5a0e70d348d87e50406bc932f7998a40cc6cfdd4b0628dd0c00ff2470f07e3b1 \ + --hash=sha256:6f33cd2033e8ea216d3069b5ebea79ded74ef2a93f69a93b26cf310082f9b5b9 \ + --hash=sha256:721f9b1a378d5bbf1bc2b9de2b7d3eeb4466d2878c7f430d483af7e3f580c935 \ + --hash=sha256:996a87a5b9c048e3dddf497acde55c7955748572091c70e1424d3c4779171526 \ + --hash=sha256:a2d96ad52eca16939564cbae9c91ffa92a9705ad46cb8d5de26b95dba0d40793 \ + --hash=sha256:abd0db088de3935b87aa27398d6fa98718c3a1d84e36ffbb927a353a9d76a032 \ + --hash=sha256:b230196ea62fc4542d9d6b78ddd9dbf0c9437ac2fde5810f7305377ae50fed11 \ + --hash=sha256:b43545a785e4054db2f712ae7d2a640874500a2352e705dbe49c8a1b90de87f2 \ + --hash=sha256:b88f2dfa33e1680e8990b45036c7cdb79c5c5e0b1f08b2e5c942893d76853eb6 \ + --hash=sha256:c9b8e25f672f5b2b30c6b834e162454e721cf1f782e753f0e9ea3bca225900f3 \ + --hash=sha256:cd7577d4f28c882b42b356f86f7cf7566698beb347b75dcd742ffe5aac3c3462 \ + --hash=sha256:d971ee533dbd7b836abec1e4ceefaeb9f6637c262561614482f213b8a19085be \ + --hash=sha256:e11e86c9a0f81d23cdd0d0a42a9312c888139d7d330ddd744973d79fec87630c \ + --hash=sha256:e8dd4a0c2bd52dd9f78d8808578d5f2ecf1b0fab41a4a169400c969a3e06ae65 \ + --hash=sha256:eda647930ecf0419fb3cecc448c40020e3871adbae523b302d15d2c49d22c866 \ + --hash=sha256:ee7ed67136ee8e69c53c657253dbfe19486edd9560a3d8363cc0ee3614b52297 \ + --hash=sha256:f15feb666fa56a43e4b318471eff7fba6facbfc3b2555185a0fef871f6b9ccf1 + # via thunder-filetolink +pyrofork==2.3.69 \ + --hash=sha256:13f7a7fbfa5ede230df6b6df10fcc2c6b33b4c3d75bf2088a7d32f41621df8e4 \ + --hash=sha256:945b30d50b31819a903749825e2748ac5a6af1e073bf97da8c53e510ff3ed58d + # via thunder-filetolink +pysocks==1.7.1 \ + --hash=sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5 \ + --hash=sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0 + # via pyrofork +python-dotenv==1.2.3 \ + --hash=sha256:904552145e8bfed22162c09dab1c2b9b54fefa7b23ba780f4f26ca0316b0f0d9 \ + --hash=sha256:a20a594dabeaa385725aa239d5244871c143ecb356add8a20fcf23773a6c3a35 + # via thunder-filetolink +tgcrypto-pyrofork==1.2.8 \ + --hash=sha256:106317b2c42cc5fcd7475a50647fee2da304076cdfcd2444f72d5254927b2afa \ + --hash=sha256:12d237eb8de98fa759df97bdb988dd6f60702c3376dbf16d2babdd2196bfb58a \ + --hash=sha256:1b202757b7711b642362baa32529c2a7896d4f259ae25df6a82d8f748a3d30b2 \ + --hash=sha256:1f28c07ee6c0ef423b3ff14562881f3bbcb6614d5394f378526f32a109650e24 \ + --hash=sha256:1ff578ac8b54607e6d536f9593f78d8bea9b99f2e607ea4bd71b1b2a3a5f949c \ + --hash=sha256:2e94273c733cba188b28b903eb10ed014eaeb454ccc9269e96c89d7f43d12ddf \ + --hash=sha256:36a71e5cbd14f3226803a2d05c0d3d43e0781565a895d40681ef82410398d950 \ + --hash=sha256:66792dfd71a90248cea9b855a40e9339686d19dc131134bd7ce4ec10b99a3509 \ + --hash=sha256:82bd2e8f249eaef92132ce5a310c27844e9fbb43666e5bfbf6dd1872c1c2eda2 \ + --hash=sha256:8e1086bcf070a8bdae4e81d7732b1cc082b2f31c6fdea884336d4f98c93d7d82 \ + --hash=sha256:8eaf42413eb7b2efae1122106803c26dc792f0ad6d98ed77d179950c979d0d35 \ + --hash=sha256:9b1538e0c14d2aee1b9dc72cddfd7706d2e4ea768addecc3f12ff8d44f38d3e1 \ + --hash=sha256:a8572c5c46c51352e294f7f68df2ed425756e25c08d0e2ef94e055ed243e3104 \ + --hash=sha256:ae1a23ed300786e28e8d9c2024effba7efc732d99fd8a2db314c02c35355f01f \ + --hash=sha256:bc888db2675247a1e3d9040577e025d64b66b72702223030b0e18ed10037b99e \ + --hash=sha256:c50a8ddd8e5256528f8318bcafbe1f59f1cc1c300db0ee16ec49955baead861c \ + --hash=sha256:cebd0cf96f27de50fedbbb836e459fb2d7d960ea1a454ac141ead0209d43bf5f \ + --hash=sha256:d3441ec567f9411ffbe5182fd4fb8c49fbd12faccd71c50fc469b9784e15b04d \ + --hash=sha256:d4886fa409c891e129c6ab439542e0b80b001b31bcefac63509340b0c691f73b \ + --hash=sha256:ef8b75bb9516ca1990f2a0ccdf26a84f301381410cb0c63bc14959a70e895a8e \ + --hash=sha256:f17a4dd0197e0972f056242bc06f97d86e890f8e29bda69af3dbc6f25d40c33f \ + --hash=sha256:fe3d75abef53bfbfa6e80dc6d25075f603f3ebe1dafa9d5c09b2780e0ea3a382 + # via thunder-filetolink +uvloop==0.22.1 \ + --hash=sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e \ + --hash=sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8 \ + --hash=sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad \ + --hash=sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35 \ + --hash=sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289 \ + --hash=sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142 \ + --hash=sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74 \ + --hash=sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 \ + --hash=sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f \ + --hash=sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e \ + --hash=sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d \ + --hash=sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079 \ + --hash=sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6 \ + --hash=sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3 \ + --hash=sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21 \ + --hash=sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c \ + --hash=sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e \ + --hash=sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25 \ + --hash=sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88 + # via thunder-filetolink +yarl==1.24.5 \ + --hash=sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36 \ + --hash=sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331 \ + --hash=sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498 \ + --hash=sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027 \ + --hash=sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144 \ + --hash=sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76 \ + --hash=sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740 \ + --hash=sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24 \ + --hash=sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95 \ + --hash=sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb \ + --hash=sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928 \ + --hash=sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c \ + --hash=sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41 \ + --hash=sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550 \ + --hash=sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f \ + --hash=sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede \ + --hash=sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad \ + --hash=sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104 \ + --hash=sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9 \ + --hash=sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1 \ + --hash=sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385 \ + --hash=sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2 \ + --hash=sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4 \ + --hash=sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc \ + --hash=sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213 \ + --hash=sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448 \ + --hash=sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0 \ + --hash=sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075 \ + --hash=sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4 \ + --hash=sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4 \ + --hash=sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f \ + --hash=sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7 \ + --hash=sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9 \ + --hash=sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047 \ + --hash=sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da \ + --hash=sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca \ + --hash=sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a \ + --hash=sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688 \ + --hash=sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16 \ + --hash=sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88 \ + --hash=sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5 \ + --hash=sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75 \ + --hash=sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f \ + --hash=sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff \ + --hash=sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f \ + --hash=sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0 \ + --hash=sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5 \ + --hash=sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6 \ + --hash=sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373 \ + --hash=sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd \ + --hash=sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d \ + --hash=sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba \ + --hash=sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104 + # via aiohttp From 43914ee3efcf5ae978efb50b43228805d2590f95 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 18:21:23 +0000 Subject: [PATCH 16/49] test: preflight chain, env-layer precedence, self-heal + transport regressions - test_preflight: preset ids resolve in the registry, documented order, unknown gate id rejects fail-closed, None-vs-False return contract - test_config_env_layers: config.env.local overrides config.env; real environment still wins (subprocess, hermetic) - test_canonical_files: replacement merge preserves legacy public_hash - test_custom_dl_exceptions: transport errors -> TelegramUnavailable, genuine absence -> FileNotFound (self-heal safety contract) - updated flag-cache and int-set tests to the leaner/fail-closed behavior --- tests/test_unit/test_canonical_files.py | 23 +++++++ tests/test_unit/test_config.py | 8 ++- tests/test_unit/test_config_env_layers.py | 69 ++++++++++++++++++++ tests/test_unit/test_custom_dl_exceptions.py | 31 +++++++++ tests/test_unit/test_flag_cache.py | 10 ++- tests/test_unit/test_preflight.py | 56 ++++++++++++++++ 6 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 tests/test_unit/test_config_env_layers.py create mode 100644 tests/test_unit/test_custom_dl_exceptions.py create mode 100644 tests/test_unit/test_preflight.py diff --git a/tests/test_unit/test_canonical_files.py b/tests/test_unit/test_canonical_files.py index a96dd7a..d1a87c5 100644 --- a/tests/test_unit/test_canonical_files.py +++ b/tests/test_unit/test_canonical_files.py @@ -68,3 +68,26 @@ def test_merge_falls_back_to_refreshed_sources(): merged = _merge_replacement_record(existing, refreshed) assert merged["first_source_chat_id"] == -1 assert merged["first_source_message_id"] == 1 + + +@pytest.mark.unit +def test_merge_preserves_legacy_public_hash(): + """Self-heal replacement must keep the existing public_hash: rewriting a + legacy 20-char hash to 32-hex would permanently break published links.""" + from Thunder.utils.canonical_files import _merge_replacement_record + + existing = { + "public_hash": "a" * 20, + "created_at": "t0", + "seen_count": 3, + "reuse_count": 1, + "first_source_chat_id": 11, + "first_source_message_id": 22, + } + refreshed = {"public_hash": "b" * 32, "created_at": "t1"} + merged = _merge_replacement_record(existing, refreshed) + assert merged["public_hash"] == "a" * 20 + assert merged["seen_count"] == 4 + assert merged["reuse_count"] == 1 + assert merged["first_source_chat_id"] == 11 + assert merged["first_source_message_id"] == 22 diff --git a/tests/test_unit/test_config.py b/tests/test_unit/test_config.py index 3cee7fe..7455082 100644 --- a/tests/test_unit/test_config.py +++ b/tests/test_unit/test_config.py @@ -34,9 +34,15 @@ def test_str_to_bool(raw, expected): @pytest.mark.unit def test_str_to_int_set(): + import Thunder.vars as vars_mod + assert str_to_int_set("") == set() assert str_to_int_set("-100111 -100222") == {-100111, -100222} - assert str_to_int_set("1 junk 2") == {1, 2} # junk skipped + before = len(vars_mod._config_errors) + assert str_to_int_set("1 junk 2") == {1, 2} + # junk is surfaced (M6 collect-all-errors), never silently skipped + assert len(vars_mod._config_errors) == before + 1 + assert "junk" in vars_mod._config_errors[-1] @pytest.mark.unit diff --git a/tests/test_unit/test_config_env_layers.py b/tests/test_unit/test_config_env_layers.py new file mode 100644 index 0000000..37121ce --- /dev/null +++ b/tests/test_unit/test_config_env_layers.py @@ -0,0 +1,69 @@ +# tests/test_unit/test_config_env_layers.py +"""config.env.local must actually override config.env (documented precedence: +real environment > config.env.local > config.env).""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent.parent + +_PROBE = "import Thunder.vars as v; print(int(v.Var.PRIVATE_MODE), v.Var.MAX_BATCH_FILES)" + + +def _run_in(tmp_path, extra_env=None): + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("PRIVATE_", "MAX_BATCH")) + } + env.update( + { + "API_ID": "1", + "API_HASH": "h", + "BOT_TOKEN": "1:x", + "BIN_CHANNEL": "-1", + "DATABASE_URL": "mongodb://localhost/x", + "OWNER_ID": "42", + "PYTHONPATH": str(REPO_ROOT), + } + ) + if extra_env: + env.update(extra_env) + return subprocess.run( + [sys.executable, "-c", _PROBE], + capture_output=True, + text=True, + env=env, + cwd=str(tmp_path), + timeout=30, + ) + + +@pytest.mark.unit +def test_local_layer_overrides_base_layer(tmp_path): + (tmp_path / "config.env").write_text("PRIVATE_MODE=True\nMAX_BATCH_FILES=5\n") + (tmp_path / "config.env.local").write_text("PRIVATE_MODE=False\nMAX_BATCH_FILES=7\n") + proc = _run_in(tmp_path) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == "0 7" # local wins over base + + +@pytest.mark.unit +def test_base_layer_applies_without_local(tmp_path): + (tmp_path / "config.env").write_text("PRIVATE_MODE=True\nMAX_BATCH_FILES=5\n") + proc = _run_in(tmp_path) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == "1 5" + + +@pytest.mark.unit +def test_real_environment_beats_both_files(tmp_path): + (tmp_path / "config.env").write_text("PRIVATE_MODE=True\n") + (tmp_path / "config.env.local").write_text("PRIVATE_MODE=True\n") + proc = _run_in(tmp_path, {"PRIVATE_MODE": "False"}) + assert proc.returncode == 0, proc.stderr + assert proc.stdout.strip() == "0 50" # os.environ still wins (default MAX_BATCH=50) diff --git a/tests/test_unit/test_custom_dl_exceptions.py b/tests/test_unit/test_custom_dl_exceptions.py new file mode 100644 index 0000000..d41fe5c --- /dev/null +++ b/tests/test_unit/test_custom_dl_exceptions.py @@ -0,0 +1,31 @@ +# tests/test_unit/test_custom_dl_exceptions.py +"""Transient Telegram failures must NOT surface as FileNotFound: the delivery +route self-heals (deletes the vault record) on FileNotFound, so conflating the +two let a Telegram brownout destroy valid records en masse.""" + +from types import SimpleNamespace + +import pytest + +from Thunder.server.exceptions import FileNotFound, TelegramUnavailable +from Thunder.utils.custom_dl import ByteStreamer + + +@pytest.mark.unit +async def test_transport_error_maps_to_unavailable(): + class _C: + async def get_messages(self, *a, **k): + raise TimeoutError("upstream hung") + + with pytest.raises(TelegramUnavailable): + await ByteStreamer(_C()).get_message(1) + + +@pytest.mark.unit +async def test_genuine_absence_maps_to_file_not_found(): + class _C: + async def get_messages(self, *a, **k): + return SimpleNamespace(media=None, id=1) + + with pytest.raises(FileNotFound): + await ByteStreamer(_C()).get_message(1) diff --git a/tests/test_unit/test_flag_cache.py b/tests/test_unit/test_flag_cache.py index 0a94917..ba34af5 100644 --- a/tests/test_unit/test_flag_cache.py +++ b/tests/test_unit/test_flag_cache.py @@ -43,8 +43,7 @@ async def boom(): with pytest.raises(RuntimeError): await cache.get_or_load("k", boom) # nothing cached on failure - hit, _ = cache.peek("k") - assert not hit + assert "k" not in cache._data @pytest.mark.unit @@ -57,9 +56,8 @@ async def loader(v): await cache.get_or_load("a", lambda: loader("a")) await cache.get_or_load("b", lambda: loader("b")) await cache.get_or_load("c", lambda: loader("c")) - assert cache.occupancy() == 2 - hit, _ = cache.peek("a") # oldest evicted - assert not hit + assert len(cache._data) == 2 + assert "a" not in cache._data # oldest evicted @pytest.mark.unit @@ -71,7 +69,7 @@ async def loader(): await cache.get_or_load("k", loader) dropped = await cache.sweep() assert dropped == 1 - assert cache.occupancy() == 0 + assert not cache._data @pytest.mark.unit diff --git a/tests/test_unit/test_preflight.py b/tests/test_unit/test_preflight.py new file mode 100644 index 0000000..f448cb2 --- /dev/null +++ b/tests/test_unit/test_preflight.py @@ -0,0 +1,56 @@ +# tests/test_unit/test_preflight.py +"""M12: unified preflight chain -- gate presets, ordering, fail-closed ids.""" + +import pytest + +from Thunder.utils.decorators import ( + GATES_INFO, + GATES_START, + GATES_STANDARD, + PREFLIGHT_GATES, + preflight, +) +from Thunder.vars import Var + + +@pytest.mark.unit +def test_gate_presets_exist_in_registry(): + """Every preset id must resolve in PREFLIGHT_GATES -- a typo'd id is a + fail-closed rejection in production, and this test fails in CI first.""" + for preset, name in ((GATES_STANDARD, "GATES_STANDARD"), (GATES_START, "GATES_START"), (GATES_INFO, "GATES_INFO")): + for gate_id in preset: + assert gate_id in PREFLIGHT_GATES, f"{name}: unknown gate id {gate_id!r}" + + +@pytest.mark.unit +def test_gate_presets_match_documented_order(): + # banned -> private-mode -> token (AGENTS.md contract) + assert GATES_STANDARD == ("banned", "private_mode", "token") + # /start must stay reachable for token-gated users (no token gate) + assert GATES_START == ("banned", "private_mode") + + +@pytest.mark.unit +async def test_unknown_gate_id_rejects_fail_closed(): + """A typo'd gate id must REJECT, never silently skip a security check.""" + + class _Msg: + from_user = None + + assert await preflight(object(), _Msg(), gates=("banned", "typo_gate")) is None + assert await preflight(object(), _Msg(), gates=("typo_gate",)) is None + + +@pytest.mark.unit +async def test_preflight_returns_shortener_status_for_owner(): + """All gates passing returns the shortener status (NOT None) -- the + False/None contract: callers must use `is None`.""" + + class _User: + id = Var.OWNER_ID + + class _Msg: + from_user = _User() + + result = await preflight(object(), _Msg(), gates=GATES_INFO) + assert result is not None or Var.SHORTEN_MEDIA_LINKS is True From b5e83fb3393291e539693a14811225d1774ea39f Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 19:04:59 +0000 Subject: [PATCH 17/49] fix(data): TTL backfill via supported _id-paged batches + lifecycle integration tests The previous fix passed timeoutMS=120_000 to update_many, which pymongo 4.18 does not accept (no per-op CSOT override on CRUD) -- every boot with FILE_TTL_DAYS>0 raised TypeError, aborted ensure_indexes and silently skipped all unique indexes: the exact failure mode the fix was meant to remove, masked in CI because tests only exercised the TTL=0 path. Replace the single COLLSCAN update_many with an _id-paged micro-batch migration (every statement an index seek inside the client-wide 5s budget), bounded per boot and resumable via a migration marker; scope the TTL conflict handler to IndexOptionsConflict (code 85) and treat ExecutionTimeout as 'build continues server-side' instead of dropping a live build. Add integration coverage for the full TTL lifecycle (first boot backfill, TTL-change recreate with uniques intact, steady state, and real row expiry). --- Thunder/utils/database.py | 176 ++++++++++++++++++++++++++------ tests/integration/test_mongo.py | 135 ++++++++++++++++++++---- 2 files changed, 261 insertions(+), 50 deletions(-) diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py index f164be5..bec3ed3 100644 --- a/Thunder/utils/database.py +++ b/Thunder/utils/database.py @@ -6,7 +6,7 @@ from pymongo import AsyncMongoClient, UpdateOne from pymongo.asynchronous.collection import AsyncCollection -from pymongo.errors import DuplicateKeyError, OperationFailure +from pymongo.errors import DuplicateKeyError, ExecutionTimeout, OperationFailure from Thunder.utils.flag_cache import flags from Thunder.utils.logger import logger @@ -32,6 +32,8 @@ def __init__(self, uri: str, database_name: str, **kwargs): 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 + # One-shot migration markers (backfill completion bookkeeping) + self.migration_flags_col: AsyncCollection = self.db.migration_flags async def _deduplicate_users(self) -> None: pipeline: list[dict[str, Any]] = [ @@ -50,40 +52,150 @@ async def _deduplicate_users(self) -> None: 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: + async def _file_ttl_index_seconds(self) -> int | None: + """Current ``expireAfterSeconds`` of the file TTL index, or None when + the index is absent (or its options cannot be inspected).""" try: - # L2: optional file TTL -- backfill first so pre-existing rows do - # not vanish the moment the index is created (default off). - if Var.FILE_TTL_DAYS > 0: - # Backfill before the TTL index exists so pre-existing rows - # do not vanish the moment it is created (default off). The - # client-level 5s timeoutMS would abort this COLLSCAN on any - # sizeable vault, so this one-off migration gets its own - # generous budget. - await self.files_col.update_many( - {"last_seen_at": {"$exists": False}}, - {"$set": {"last_seen_at": datetime.datetime.now(datetime.UTC)}}, - timeoutMS=120_000, # type: ignore[call-arg] # CSOT per-op budget (stub lag) - ) - try: - await self.files_col.create_index( - "last_seen_at", expireAfterSeconds=Var.FILE_TTL_DAYS * 86400 - ) - except OperationFailure as e: - # Mongo cannot change a TTL value via createIndexes; an - # operator changing FILE_TTL_DAYS between boots must not - # abort the remaining (unique-index) ensures below. - logger.warning( - f"FILE_TTL_DAYS changed between boots; recreating file TTL index: {e}" - ) + # AsyncCollection.list_indexes() is a coroutine in pymongo's + # async API: iterate the awaited cursor, never the coroutine. + cursor = await self.files_col.list_indexes() + async for idx in cursor: + if idx.get("name") == "last_seen_at_1": try: - await self.files_col.drop_index("last_seen_at_1") - except Exception: - pass - await self.files_col.create_index( - "last_seen_at", expireAfterSeconds=Var.FILE_TTL_DAYS * 86400 + return int(idx.get("expireAfterSeconds", -1)) + except (TypeError, ValueError): + return -1 + except Exception as e: + logger.warning(f"Could not inspect file TTL index: {e}") + return None + + async def _backfill_done(self) -> bool: + try: + return bool( + await self.migration_flags_col.find_one({"_id": "file_last_seen_backfill_done"}) + ) + except Exception: + return False + + async def _mark_backfill_done(self) -> None: + try: + await self.migration_flags_col.update_one( + {"_id": "file_last_seen_backfill_done"}, + {"$set": {"done_at": datetime.datetime.now(datetime.UTC)}}, + upsert=True, + ) + except Exception as e: + logger.warning(f"Could not record backfill completion marker: {e}") + + async def _backfill_file_last_seen(self) -> None: + """One-off migration: stamp ``last_seen_at`` on legacy rows that lack + it, so the TTL index activated right after gives them a full window + instead of letting them age out from an undefined reference point. + + Runs as a sequence of ``_id``-paged micro-batches: every statement is + a cheap ``_id``-index seek that fits comfortably inside the + client-wide 5s ``timeoutMS``. A single ``update_many`` here would be + a COLLSCAN that ExecutionTimeouts on sizeable vaults -- and its + failure used to abort every subsequent index ensure (review item 9). + + A bounded number of batches per boot keeps startup latency + predictable; an interrupted migration resumes on the next boot and + must never raise (the unique-index ensures below always run). + """ + stamp = datetime.datetime.now(datetime.UTC) + batch_size = 500 + max_batches_per_boot = 100 # ~50k rows scanned per boot; resumes next boot + last_id: Any = None + stamped = 0 + try: + for _ in range(max_batches_per_boot): + page_filter: dict[str, Any] = {} + if last_id is not None: + page_filter["_id"] = {"$gt": last_id} + page = ( + await self.files_col.find(page_filter, {"last_seen_at": 1}) + .sort("_id", 1) + .to_list(batch_size) + ) + if not page: + await self._mark_backfill_done() + return + last_id = page[-1]["_id"] + stale_ids = [doc["_id"] for doc in page if "last_seen_at" not in doc] + if stale_ids: + await self.files_col.update_many( + {"_id": {"$in": stale_ids}}, + {"$set": {"last_seen_at": stamp}}, ) - logger.info(f"File TTL index active: {Var.FILE_TTL_DAYS} days") + stamped += len(stale_ids) + if len(page) < batch_size: + await self._mark_backfill_done() + if stamped: + logger.info(f"Backfilled last_seen_at on {stamped} legacy file records.") + return + logger.warning( + "last_seen_at backfill hit the per-boot scan cap " + f"({max_batches_per_boot * batch_size} docs); resuming on next boot." + ) + except Exception as e: + # The migration must never abort the remaining index ensures. + logger.warning(f"last_seen_at backfill interrupted (resumes next boot): {e}") + + async def _create_file_ttl_index(self, expire_after_seconds: int) -> None: + """Create (or recreate after an operator TTL change) the file TTL + index without letting its failure modes abort the remaining ensures.""" + try: + await self.files_col.create_index( + "last_seen_at", expireAfterSeconds=expire_after_seconds + ) + except ExecutionTimeout: + # A first-ever build on a large vault can exceed the client + # budget; the server-side build continues and create_index is + # idempotent once it completes, so just re-check next boot. + logger.warning( + "File TTL index build exceeded the client timeout budget; " + "the server-side build continues and is re-checked on next boot." + ) + except OperationFailure as e: + if e.code != 85: # 85 = IndexOptionsConflict + logger.warning(f"File TTL index creation failed: {e}") + return + # Mongo cannot alter a TTL value via createIndexes; an operator + # changing FILE_TTL_DAYS between boots must not abort the + # remaining (unique-index) ensures below. + logger.warning("FILE_TTL_DAYS changed between boots; recreating file TTL index.") + try: + await self.files_col.drop_index("last_seen_at_1") + except Exception: + pass + try: + await self.files_col.create_index( + "last_seen_at", expireAfterSeconds=expire_after_seconds + ) + except ExecutionTimeout: + logger.warning( + "File TTL index rebuild exceeded the client timeout budget; " + "re-checked on next boot." + ) + + async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool: + try: + # L2: optional file TTL -- backfill before the TTL index exists + # so pre-existing rows get a full window instead of vanishing + # the moment the index is created (default off). + if Var.FILE_TTL_DAYS > 0: + expected_ttl = Var.FILE_TTL_DAYS * 86400 + current_ttl = await self._file_ttl_index_seconds() + backfill_done = await self._backfill_done() + if current_ttl == expected_ttl and backfill_done: + logger.debug(f"File TTL index already active: {Var.FILE_TTL_DAYS} days") + else: + if not backfill_done: + # Stamp legacy rows before (re)activating the index so + # pre-existing files get a full TTL window. + await self._backfill_file_last_seen() + await self._create_file_ttl_index(expected_ttl) + logger.info(f"File TTL index active: {Var.FILE_TTL_DAYS} days") else: try: await self.files_col.drop_index("last_seen_at_1") diff --git a/tests/integration/test_mongo.py b/tests/integration/test_mongo.py index 19c8c64..62b1ca4 100644 --- a/tests/integration/test_mongo.py +++ b/tests/integration/test_mongo.py @@ -25,27 +25,32 @@ @pytest.fixture(scope="module") -def db(): +def mongo_container(): if docker_unavailable or os.getenv("TEST_INTEGRATION") != "1": pytest.skip("integration tier disabled (set TEST_INTEGRATION=1 with Docker)") with MongoContainer("mongo:7") as mongo: - import Thunder.utils.database as database_module - import Thunder.utils.tokens as tokens_module - - # Bind a fresh Database directly to the container URI. Rebinding is - # required because Thunder.vars is cached in sys.modules by the unit - # tier's imports (Var.DATABASE_URL still points at the platform - # config), and `from ... import db` copies froze the old instance in - # every consumer module. Reload-based approaches never worked. - fresh = database_module.Database(mongo.get_connection_url(), "thunder_test") - original = database_module.db - database_module.db = fresh - tokens_module.db = fresh - try: - yield fresh - finally: - database_module.db = original - tokens_module.db = original + yield mongo + + +@pytest.fixture(scope="module") +def db(mongo_container): + import Thunder.utils.database as database_module + import Thunder.utils.tokens as tokens_module + + # Bind a fresh Database directly to the container URI. Rebinding is + # required because Thunder.vars is cached in sys.modules by the unit + # tier's imports (Var.DATABASE_URL still points at the platform + # config), and `from ... import db` copies froze the old instance in + # every consumer module. Reload-based approaches never worked. + fresh = database_module.Database(mongo_container.get_connection_url(), "thunder_test") + original = database_module.db + database_module.db = fresh + tokens_module.db = fresh + try: + yield fresh + finally: + database_module.db = original + tokens_module.db = original async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover @@ -70,3 +75,97 @@ async def test_ensure_indexes_and_token_atomicity(db): # pragma: no cover results = await asyncio.gather(consume(token, 424242), consume(token, 424242)) statuses = sorted(status for status, _ in results) assert statuses == ["already", "ok"] + + +async def test_ensure_indexes_ttl_lifecycle( # pragma: no cover + mongo_container, monkeypatch +): + """Review item 9 regression: FILE_TTL_DAYS must survive a change between + boots (IndexOptionsConflict -> drop+recreate) without aborting the + remaining unique-index ensures, and the legacy-row backfill must stamp + last_seen_at before the index first activates. + + Uses its own Database instance (fresh event-loop affinity) so it does + not share the module fixture's client loop. + """ + from datetime import datetime + + import Thunder.utils.database as database_module + import Thunder.vars as vars_module + + var = vars_module.Var + ttl_db = database_module.Database(mongo_container.get_connection_url(), "thunder_ttl_test") + + # legacy row predating any TTL index (no last_seen_at field) + await ttl_db.files_col.insert_one( + { + "file_unique_id": "ttl-legacy-1", + "public_hash": "f" * 32, + "canonical_message_id": -77001, + "created_at": datetime.now(UTC), + } + ) + + # first boot with TTL enabled: backfill runs, index is created + monkeypatch.setattr(var, "FILE_TTL_DAYS", 1) + assert await ttl_db.ensure_indexes(raise_on_error=True) is True + info = await ttl_db.files_col.index_information() + assert info["last_seen_at_1"]["expireAfterSeconds"] == 86400 + row = await ttl_db.files_col.find_one({"file_unique_id": "ttl-legacy-1"}) + assert "last_seen_at" in row, "legacy row must be stamped before the TTL index activates" + + # second boot with a CHANGED TTL: recreate must not abort the uniques + monkeypatch.setattr(var, "FILE_TTL_DAYS", 2) + assert await ttl_db.ensure_indexes(raise_on_error=True) is True + info = await ttl_db.files_col.index_information() + assert info["last_seen_at_1"]["expireAfterSeconds"] == 2 * 86400 + for unique_index in ("file_unique_id_1", "public_hash_1", "canonical_message_id_1"): + assert unique_index in info, f"unique index {unique_index} must still be ensured" + + # third boot with the SAME TTL: steady state, still green + assert await ttl_db.ensure_indexes(raise_on_error=True) is True + + await ttl_db.close() + + +async def test_file_ttl_actually_expires_rows( # pragma: no cover + mongo_container, monkeypatch +): + """End-to-end TTL semantics: a row whose last_seen_at is older than the + window must eventually disappear once the index is active.""" + from datetime import datetime, timedelta + + import Thunder.utils.database as database_module + import Thunder.vars as vars_module + + var = vars_module.Var + ttl_db = database_module.Database( + mongo_container.get_connection_url(), "thunder_ttl_expire_test" + ) + await ttl_db.files_col.insert_one( + { + "file_unique_id": "ttl-doomed-1", + "public_hash": "e" * 32, + "canonical_message_id": -77002, + "created_at": datetime.now(UTC), + "last_seen_at": datetime.now(UTC) - timedelta(days=30), + } + ) + + monkeypatch.setattr(var, "FILE_TTL_DAYS", 1) + assert await ttl_db.ensure_indexes(raise_on_error=True) is True + + # Mongo's TTL monitor runs roughly once a minute; poll for up to 90s. + deadline = datetime.now(UTC) + timedelta(seconds=90) + gone = False + while datetime.now(UTC) < deadline: + remaining = await ttl_db.files_col.count_documents({"file_unique_id": "ttl-doomed-1"}) + if remaining == 0: + gone = True + break + import asyncio + + await asyncio.sleep(5) + assert gone, "TTL monitor did not expire the aged row in time" + + await ttl_db.close() From 78c02e53942c0c28c85e2b3e89514ba0ae0da7bc Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 19:05:11 +0000 Subject: [PATCH 18/49] fix(security): close raw-path access-log sink, drop capability-hash logs, add route-ladder regression tests - AppRunner(access_log=None): aiohttp's own access logger still emitted raw %r request lines on a latent sink (inert today only because the logger has no handlers; any future basicConfig() would have leaked unredacted paths). - canonical stream warnings no longer log the capability hash alongside record/vault sizes; /log would have handed out a link fingerprint. - unit tests pin the delivery ladder: TelegramUnavailable -> 503 with Retry-After and NO record deletion; true absence / media-less vault message -> self-heal + 404; admission slot released on every path. --- Thunder/__main__.py | 8 +- Thunder/server/stream_routes.py | 8 +- tests/test_unit/test_stream_routes.py | 102 ++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 7 deletions(-) diff --git a/Thunder/__main__.py b/Thunder/__main__.py index 53385d4..02feda9 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -5,7 +5,7 @@ import importlib.util import os import sys -from datetime import datetime +import time from pathlib import Path if sys.platform == "win32": @@ -122,7 +122,7 @@ async def import_plugins(): async def start_services(): - start_time = datetime.now() + start_time = time.monotonic() background_tasks: list[asyncio.Task] = [] print_banner() print("╔════════════════ INITIALIZING BOT SERVICES ════════════════╗") @@ -184,7 +184,7 @@ async def start_services(): print(" β–Ά Starting Web Server initialization...") try: - app_runner = web.AppRunner(await web_server()) + app_runner = web.AppRunner(await web_server(), access_log=None) await app_runner.setup() bind_address = Var.BIND_ADDRESS site = web.TCPSite(app_runner, bind_address, Var.PORT) @@ -222,7 +222,7 @@ async def start_services(): await _safe_teardown_step(db.close, "database") raise SystemExit(1) from e - elapsed_time = (datetime.now() - start_time).total_seconds() + elapsed_time = time.monotonic() - start_time print("╠═══════════════════════════════════════════════════════════╣") print(f" β–Ά Bot Name: {bot_info.first_name}") print(f" β–Ά Username: @{bot_info.username}") diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index a663cf4..2bf6029 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -484,9 +484,11 @@ async def canonical_media_delivery(request: web.Request): serve_info = dict(file_record) actual_size = int(getattr(media, "file_size", 0) or 0) if actual_size and actual_size != int(serve_info.get("file_size", 0) or 0): + # no capability hash in logs: hash+size would fingerprint the + # link for anyone who later reads /log output logger.warning( - f"Record size {serve_info.get('file_size')} != vault size {actual_size} " - f"for {secure_hash}; serving verified length" + f"Record size {serve_info.get('file_size')} != vault size {actual_size}; " + "serving verified length" ) if actual_size: # never tell clients a Content-Length the upstream cannot deliver @@ -501,7 +503,7 @@ async def canonical_media_delivery(request: web.Request): await update_cached_file_id(file_record, new_file_id) except Exception as e: logger.warning( - f"Failed to refresh cached file_id for canonical file {secure_hash}: {e}", + f"Failed to refresh cached file_id for a canonical file: {e}", exc_info=True, ) diff --git a/tests/test_unit/test_stream_routes.py b/tests/test_unit/test_stream_routes.py index 8e91080..1ff24bb 100644 --- a/tests/test_unit/test_stream_routes.py +++ b/tests/test_unit/test_stream_routes.py @@ -1,7 +1,10 @@ # tests/test_unit/test_stream_routes.py """HTTP parsing primitives (H2 target) + L4 dual-hash + L6 disposition.""" +from types import SimpleNamespace + import pytest +from aiohttp import web from aiohttp.web import HTTPBadRequest, HTTPRequestRangeNotSatisfiable from Thunder.server.stream_routes import ( @@ -151,3 +154,102 @@ def test_hostile_token_cannot_reshape_url(self): url = _telegram_activate_url("MyBot", "x&start=evil#frag") assert url.startswith("https://t.me/MyBot?start=") assert "&" not in url[24:] and "#" not in url[24:] + + +class TestCanonicalDeliveryErrorLadder: + """Review fix regression: transport errors must 503 WITHOUT deleting the + record; only true Telegram-side absence may self-heal (delete).""" + + @staticmethod + def _request(): + return SimpleNamespace(match_info={"secure_hash": "a" * 32}) + + @pytest.mark.unit + async def test_transport_error_maps_to_503_and_never_deletes(self, monkeypatch): + import Thunder.server.stream_routes as stream_routes + from Thunder.server.exceptions import TelegramUnavailable + + deleted: list[dict] = [] + + async def get_message(_ref): + raise TelegramUnavailable("FloodWait exhausted") + + async def get_file_by_hash(_h, raise_on_error=False): + return {"file_unique_id": "u1", "canonical_message_id": 123, "file_size": 10} + + async def forget_stale_record(record): + deleted.append(record) + + monkeypatch.setattr(stream_routes, "get_file_by_hash", get_file_by_hash) + monkeypatch.setattr( + stream_routes, + "select_optimal_client", + lambda: (0, SimpleNamespace(get_message=get_message)), + ) + monkeypatch.setattr(stream_routes, "forget_stale_record", forget_stale_record) + monkeypatch.setattr(stream_routes, "work_loads", {0: 0}) + + with pytest.raises(web.HTTPServiceUnavailable) as exc: + await stream_routes.canonical_media_delivery(self._request()) + assert exc.value.headers.get("Retry-After") == "5" + assert deleted == [] # transient error must NOT destroy the record + assert stream_routes.work_loads == {0: 0} # admission slot released + + @pytest.mark.unit + async def test_true_absence_self_heals_and_maps_to_404(self, monkeypatch): + import Thunder.server.stream_routes as stream_routes + from Thunder.server.exceptions import FileNotFound + + deleted: list[dict] = [] + + async def get_message(_ref): + raise FileNotFound("no such message") + + async def get_file_by_hash(_h, raise_on_error=False): + return {"file_unique_id": "u1", "canonical_message_id": 123, "file_size": 10} + + async def forget_stale_record(record): + deleted.append(record) + + monkeypatch.setattr(stream_routes, "get_file_by_hash", get_file_by_hash) + monkeypatch.setattr( + stream_routes, + "select_optimal_client", + lambda: (0, SimpleNamespace(get_message=get_message)), + ) + monkeypatch.setattr(stream_routes, "forget_stale_record", forget_stale_record) + monkeypatch.setattr(stream_routes, "work_loads", {0: 0}) + + with pytest.raises(web.HTTPNotFound): + await stream_routes.canonical_media_delivery(self._request()) + assert len(deleted) == 1 # genuine absence is the one self-heal case + assert stream_routes.work_loads == {0: 0} + + @pytest.mark.unit + async def test_medialess_vault_message_self_heals(self, monkeypatch): + import Thunder.server.stream_routes as stream_routes + + deleted: list[dict] = [] + + async def get_message(_ref): + return SimpleNamespace() # message exists but carries no media + + async def get_file_by_hash(_h, raise_on_error=False): + return {"file_unique_id": "u1", "canonical_message_id": 123, "file_size": 10} + + async def forget_stale_record(record): + deleted.append(record) + + monkeypatch.setattr(stream_routes, "get_file_by_hash", get_file_by_hash) + monkeypatch.setattr( + stream_routes, + "select_optimal_client", + lambda: (0, SimpleNamespace(get_message=get_message)), + ) + monkeypatch.setattr(stream_routes, "forget_stale_record", forget_stale_record) + monkeypatch.setattr(stream_routes, "get_media", lambda m: None) + monkeypatch.setattr(stream_routes, "work_loads", {0: 0}) + + with pytest.raises(web.HTTPNotFound): + await stream_routes.canonical_media_delivery(self._request()) + assert len(deleted) == 1 From 4126b077ba6c1cc23d99e6d0717c093a5c99ad28 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 19:05:11 +0000 Subject: [PATCH 19/49] refactor(lean): finish dead-code sweep from the recheck pass Leftovers the fix session missed, all verified unreachable or write-only: - config_parser try/except that cannot raise (digit filter + or 0) - registry Command.hidden speculative knob ('none today') - stream.py batch worker: orphan task_done() (no queue.join exists) and the dead getattr(Var, 'BATCH_WORKERS', 5) fallback - decorators.py: unreachable empty-token guard (generate() raises, never returns empty) - shortener.py: four dead getattr(Var) fallbacks (vars.py validates all) - messages.py: unused MSG_SHELL_OUTPUT constant - tests: immediate-path breaker consumption + dry-bucket fall-through, consume() status ladder incl. corrupt token without expires_at --- Thunder/bot/plugins/stream.py | 51 +++++++------- Thunder/bot/registry.py | 5 +- Thunder/utils/config_parser.py | 40 +++++------ Thunder/utils/decorators.py | 11 +-- Thunder/utils/messages.py | 2 - Thunder/utils/shortener.py | 8 +-- tests/test_unit/test_rate_limiter.py | 92 ++++++++++++++++++++++++++ tests/test_unit/test_tokens_consume.py | 82 +++++++++++++++++++++++ 8 files changed, 223 insertions(+), 68 deletions(-) create mode 100644 tests/test_unit/test_tokens_consume.py diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index 7774cc4..b8bb4e2 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -585,7 +585,7 @@ async def process_batch( """ total_started = time.monotonic() deadline = total_started + _BATCH_DEADLINE_BASE + 2 * count - worker_count = max(1, int(getattr(Var, "BATCH_WORKERS", 5))) + worker_count = max(1, int(Var.BATCH_WORKERS)) ids: list[int] = list(range(start_id, start_id + count)) results: dict[int, dict[str, Any] | None] = {} @@ -641,33 +641,30 @@ async def worker(): nonlocal skipped while True: mid = await queue.get() - try: - if mid is None: - return - if time.monotonic() > deadline: - results[mid] = None - skipped += 1 - counters["done"] += 1 - continue - m = fetched.get(mid) - if mid in fetch_failed: - results[mid] = None - counters["failed"] += 1 - elif m is not None: - links = await process_single( - bot, msg, m, None, shortener_val, original_request_msg=msg - ) - results[mid] = links - if not links: - counters["failed"] += 1 - else: - results[mid] = None - skipped += 1 + if mid is None: + return + if time.monotonic() > deadline: + results[mid] = None + skipped += 1 counters["done"] += 1 - if counters["done"] % BATCH_UPDATE_INTERVAL == 0 and counters["done"] < count: - await progress_edit() - finally: - queue.task_done() + continue + m = fetched.get(mid) + if mid in fetch_failed: + results[mid] = None + counters["failed"] += 1 + elif m is not None: + links = await process_single( + bot, msg, m, None, shortener_val, original_request_msg=msg + ) + results[mid] = links + if not links: + counters["failed"] += 1 + else: + results[mid] = None + skipped += 1 + counters["done"] += 1 + if counters["done"] % BATCH_UPDATE_INTERVAL == 0 and counters["done"] < count: + await progress_edit() # initial status (guarded: a deleted/undeletable status message must not # abort the whole batch before it starts) diff --git a/Thunder/bot/registry.py b/Thunder/bot/registry.py index 5d50055..182f69c 100644 --- a/Thunder/bot/registry.py +++ b/Thunder/bot/registry.py @@ -19,7 +19,6 @@ class Command(NamedTuple): name: str description: str owner_only: bool = False - hidden: bool = False # not listed anywhere (none today) COMMANDS: list[Command] = [ @@ -52,7 +51,7 @@ def bot_commands() -> list[BotCommand]: return [ BotCommand(cmd.name, cmd.description[:_MAX_DESC_LEN]) for cmd in COMMANDS - if not cmd.owner_only and not cmd.hidden + if not cmd.owner_only ] @@ -60,7 +59,7 @@ def help_command_rows() -> str: """/help surface: same public commands, same order.""" rows = "" for cmd in COMMANDS: - if cmd.owner_only or cmd.hidden: + if cmd.owner_only: continue rows += MSG_HELP_COMMAND_ROW.format(name=cmd.name, description=cmd.description) return rows diff --git a/Thunder/utils/config_parser.py b/Thunder/utils/config_parser.py index 3ad4a90..5c48f5e 100644 --- a/Thunder/utils/config_parser.py +++ b/Thunder/utils/config_parser.py @@ -2,8 +2,6 @@ import os -from Thunder.utils.logger import logger - class TokenParser: def __init__(self, config_file: str | None = None): @@ -11,24 +9,22 @@ def __init__(self, config_file: str | None = None): 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) + # The sort key cannot raise: the digit filter yields an empty string + # at worst, which `or 0` turns into a valid int. + 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 diff --git a/Thunder/utils/decorators.py b/Thunder/utils/decorators.py index 76eebde..66d86c4 100644 --- a/Thunder/utils/decorators.py +++ b/Thunder/utils/decorators.py @@ -175,15 +175,8 @@ async def require_token(client, message: Message) -> bool: pass return False - if not temp_token_string: - logger.error( - f"Temporary token generation returned empty for user {user_id}.", exc_info=True - ) - try: - await reply_safe(message, MSG_ERROR_TOKEN_LINK_FAILED) - except Exception: - pass - return False + # generate() either returns a token string or raises; there is no + # empty-string path to defend against. try: me = await tg_call(client.get_me) diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py index 733d8d5..35d7b09 100644 --- a/Thunder/utils/messages.py +++ b/Thunder/utils/messages.py @@ -124,8 +124,6 @@ "owner-only command." ) MSG_SHELL_EXECUTING = "Executing Command... βš™οΈ\n
{command}
" -MSG_SHELL_OUTPUT = """**Shell Command Output:** -
{output}
""" MSG_SHELL_OUTPUT_CAPTION = "**Shell Command Output** (see attached file):\n
{command}
" MSG_SHELL_OUTPUT_STDOUT = "[stdout]:\n
{output}
" MSG_SHELL_OUTPUT_STDERR = "[stderr]:\n
{error}
" diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py index eb65ec9..2706e7d 100644 --- a/Thunder/utils/shortener.py +++ b/Thunder/utils/shortener.py @@ -201,13 +201,11 @@ async def initialize(self) -> bool: if self.ready: return True - if not ( - getattr(Var, "SHORTEN_ENABLED", False) or getattr(Var, "SHORTEN_MEDIA_LINKS", False) - ): + if not (Var.SHORTEN_ENABLED or Var.SHORTEN_MEDIA_LINKS): return False - site = getattr(Var, "URL_SHORTENER_SITE", "") - api_key = getattr(Var, "URL_SHORTENER_API_KEY", "") + site = Var.URL_SHORTENER_SITE + api_key = Var.URL_SHORTENER_API_KEY if not (site and api_key): return False diff --git a/tests/test_unit/test_rate_limiter.py b/tests/test_unit/test_rate_limiter.py index ac0392f..daf5cb8 100644 --- a/tests/test_unit/test_rate_limiter.py +++ b/tests/test_unit/test_rate_limiter.py @@ -92,3 +92,95 @@ async def test_zero_rate_always_allows(self): async def test_occupancy_shape(): occ = rate_limiter.occupancy() assert {"queued", "tracked_users", "global_window", "breaker_tokens"} <= set(occ) + + +@pytest.mark.unit +async def test_immediate_path_consumes_breaker_token(monkeypatch): + """H6c (revised): fresh-user bursts execute inline, so the immediate path + must consume a breaker token BEFORE running the handler.""" + from types import SimpleNamespace + + import Thunder.utils.rate_limiter as rl_mod + + rl = rate_limiter + rl.enabled = True + rl._initialization_error = False + rl.max_requests_per_period = 100 + rl.rate_limit_period_seconds = 60 + rl.global_rate_limit_enabled = False + monkeypatch.setattr(rl, "is_owner", lambda user_id: False) + monkeypatch.setattr(rl, "breaker", TokenBucket(rate_per_second=5.0, burst_multiplier=2.0)) + + uid = 90_100 + rl.user_requests.pop(uid, None) + + executed: list = [] + + async def handler(bot, message, *args, **kwargs): + executed.append(message) + + msg = SimpleNamespace(from_user=SimpleNamespace(id=uid), document=None) + + before = rl.breaker.available() + await rl_mod.handle_rate_limited_request(None, msg, handler) + assert len(executed) == 1 + assert rl.breaker.available() < before # one token consumed on the spot + rl.user_requests.pop(uid, None) + + +@pytest.mark.unit +async def test_dry_breaker_defers_immediate_request_to_queue(monkeypatch): + """A dry breaker must not drop the request: it falls through to the + queue, where workers consume tokens at exec time (shaping, not shedding).""" + from types import SimpleNamespace + + import Thunder.utils.rate_limiter as rl_mod + + rl = rate_limiter + rl.enabled = True + rl._initialization_error = False + rl.max_requests_per_period = 100 + rl.rate_limit_period_seconds = 60 + rl.global_rate_limit_enabled = False + monkeypatch.setattr(rl, "is_owner", lambda user_id: False) + monkeypatch.setattr(rl, "breaker", TokenBucket(rate_per_second=5.0, burst_multiplier=2.0)) + monkeypatch.setattr(rl, "get_user_priority", _async_return("regular")) + monkeypatch.setattr(rl_mod, "send_queue_notification", _async_noop) + monkeypatch.setattr(rl_mod, "send_queue_full_message", _async_noop) + + queued: list = [] + + async def fake_add_to_queue(handler, user_id, file_identifier, bot, message, *args, **kwargs): + queued.append(user_id) + + monkeypatch.setattr(rl, "add_to_queue", fake_add_to_queue) + + # drain the bucket completely + while rl.breaker.allow(): + pass + + uid = 90_101 + rl.user_requests.pop(uid, None) + + executed: list = [] + + async def handler(bot, message, *args, **kwargs): + executed.append(message) + + msg = SimpleNamespace(from_user=SimpleNamespace(id=uid), document=None) + + await rl_mod.handle_rate_limited_request(None, msg, handler) + assert executed == [] + assert queued == [uid] + rl.user_requests.pop(uid, None) + + +def _async_return(value): + async def _fn(*args, **kwargs): + return value + + return _fn + + +async def _async_noop(*args, **kwargs): + return None diff --git a/tests/test_unit/test_tokens_consume.py b/tests/test_unit/test_tokens_consume.py new file mode 100644 index 0000000..41a75d4 --- /dev/null +++ b/tests/test_unit/test_tokens_consume.py @@ -0,0 +1,82 @@ +# tests/test_unit/test_tokens_consume.py +"""consume() status ladder for corrupt/expired tokens -- no Mongo required. + +Regression (review C-1 family): a corrupt token row without a usable +expires_at must surface as "invalid", never as the misleading "already" +that the historical fallback produced. +""" + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +import pytest + +import Thunder.utils.tokens as tokens_module +from Thunder.utils.tokens import consume + + +class _FakeTokenCol: + """find_one_and_update always loses the CAS (returns None); find_one + returns the full row for the pre-check and -- when a projection is + passed (the post-CAS re-read signature) -- the scripted post-CAS doc.""" + + def __init__(self, doc, post_cas_doc=None): + self._doc = doc + self._post_cas_doc = post_cas_doc + + async def find_one(self, *args, **_kwargs): + if self._post_cas_doc is not None and len(args) > 1: + return self._post_cas_doc + return self._doc + + async def find_one_and_update(self, *_args, **_kwargs): + return None + + +@pytest.mark.unit +async def test_corrupt_token_without_expires_at_is_invalid(monkeypatch): + doc = {"token": "t", "user_id": 7, "activated": False} # expires_at missing + monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeTokenCol(doc))) + status, hours = await consume("t", 7) + assert (status, hours) == ("invalid", 0.0) + + +@pytest.mark.unit +async def test_expired_unactivated_token_is_invalid_not_already(monkeypatch): + doc = { + "token": "t", + "user_id": 7, + "activated": False, + "expires_at": datetime.now(UTC) - timedelta(hours=1), + } + monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeTokenCol(doc))) + status, hours = await consume("t", 7) + assert (status, hours) == ("invalid", 0.0) + + +@pytest.mark.unit +async def test_cas_loss_to_concurrent_winner_is_already(monkeypatch): + doc = { + "token": "t", + "user_id": 7, + "activated": False, + "expires_at": datetime.now(UTC) + timedelta(hours=1), + } + col = _FakeTokenCol(doc, post_cas_doc={"activated": True}) + monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=col)) + status, _hours = await consume("t", 7) + assert status == "already" + + +@pytest.mark.unit +async def test_cas_loss_to_expiry_is_invalid(monkeypatch): + doc = { + "token": "t", + "user_id": 7, + "activated": False, + "expires_at": datetime.now(UTC) + timedelta(hours=1), + } + col = _FakeTokenCol(doc, post_cas_doc={"activated": False}) + monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=col)) + status, _hours = await consume("t", 7) + assert status == "invalid" From 52e54cf40a0e9d308dbc570d236763e6944e0b7a Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 19:05:18 +0000 Subject: [PATCH 20/49] chore(infra/docs): hermetic test tier, CI lint scope, Makefile consistency, doc truth - vars.py honours THUNDER_SKIP_CONFIG_FILES=1; conftest sets it so a developer's own config.env cannot leak optional knobs into the unit tier; the subprocess precedence tests opt back out explicitly - CI ruff scope now covers tests/ (was Thunder/ update.py only) - Makefile: all targets through 'uv run' (was mixed ambient/project envs), find-based clean (globstar is not POSIX sh) - requirements.txt header: the Docker image consumes requirements.lock; this file is the direct-dependency surface kept in sync by CI - README: document config.env.local precedence (FAQ claimed 'config.env Only') --- .github/workflows/quality.yml | 4 ++-- Makefile | 27 +++++++++++++---------- README.md | 7 +++++- Thunder/vars.py | 6 +++++ requirements.txt | 4 +++- tests/conftest.py | 6 +++++ tests/test_unit/test_config_env_layers.py | 5 ++++- tests/test_unit/test_preflight.py | 8 +++++-- 8 files changed, 48 insertions(+), 19 deletions(-) diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index efe4e2c..ce2e5a7 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -56,8 +56,8 @@ jobs: - name: Ruff (lint + format check) run: | - uv run ruff check Thunder/ update.py - uv run ruff format --check Thunder/ update.py + uv run ruff check Thunder/ update.py tests/ + uv run ruff format --check Thunder/ update.py tests/ - name: Mypy (blocking) run: uv run mypy Thunder --ignore-missing-imports diff --git a/Makefile b/Makefile index 4de79ee..67d72d1 100644 --- a/Makefile +++ b/Makefile @@ -2,34 +2,37 @@ # L8: developer entry points (see CONTRIBUTING.md) # NOTE: recipes MUST be indented with hard TABs, not spaces. +# All tools run through `uv run` so they execute inside the project +# environment regardless of the developer's ambient virtualenv. format: - ruff check Thunder/ update.py --fix - ruff format Thunder/ update.py + uv run ruff check Thunder/ update.py tests/ --fix + uv run ruff format Thunder/ update.py tests/ lint: - ruff check Thunder/ update.py - mypy Thunder --ignore-missing-imports + uv run ruff check Thunder/ update.py tests/ + uv run mypy Thunder --ignore-missing-imports test: - pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 + uv run pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 coverage: - pytest -m unit --cov=Thunder --cov-report=html + uv run pytest -m unit --cov=Thunder --cov-report=html audit: uv run pip-audit - bandit -r Thunder -ll --skip B101 - vulture Thunder --min-confidence 80 + uv run bandit -r Thunder -ll --skip B101 + uv run vulture Thunder --min-confidence 80 @count=$$(grep -cE '^[a-zA-Z0-9_-]+==' requirements.txt); \ echo "Direct runtime deps: $$count"; \ if [ "$$count" -gt 8 ]; then \ - echo "ERROR: dependency count increased beyond 8; justify or remove."; \ - exit 1; \ + echo "ERROR: dependency count increased beyond 8; justify or remove."; \ + exit 1; \ fi run: - python3 -m Thunder + uv run python3 -m Thunder clean: - rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov **/__pycache__ + rm -rf .pytest_cache .ruff_cache .mypy_cache htmlcov build dist + find . -type d -name "__pycache__" -not -path "./.venv/*" -exec rm -rf {} + diff --git a/README.md b/README.md index 3c41811..9320941 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,11 @@ User Uploads File β†’ Telegram Bot β†’ Forwards to Channel β†’ Generates Direct Copy `config_sample.env` to `config.env` and fill in your values. +> **Tip:** for machine-local overrides, create a `config.env.local`. It is +> loaded after `config.env` and its values win (precedence: real +> environment > `config.env.local` > `config.env`). Keep it out of version +> control for machine-specific tweaks. + ### Essential Configuration | Variable | Description | Example | @@ -488,7 +493,7 @@ Your reverse proxy is now securely streaming files behind Cloudflare! 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`. +2. **Use `config.env` (plus optional `config.env.local` overrides)**: Do not edit `vars.py` or `config_sample.env`. The bot reads your settings from `config.env` and, if present, `config.env.local` (local layer wins). 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?** diff --git a/Thunder/vars.py b/Thunder/vars.py index 7b2360f..86afc0c 100644 --- a/Thunder/vars.py +++ b/Thunder/vars.py @@ -30,7 +30,13 @@ def _load_env_layers() -> None: were silently ignored. Precedence now is: real environment > config.env.local > config.env; existing os.environ entries still win (same contract as load_dotenv's default). + + ``THUNDER_SKIP_CONFIG_FILES=1`` disables both files entirely -- the test + tiers use it so a developer's own config.env cannot leak values into a + run that is supposed to be hermetic. """ + if os.environ.get("THUNDER_SKIP_CONFIG_FILES") == "1": + return merged: dict[str, str | None] = {} for path in ("config.env", "config.env.local"): try: diff --git a/requirements.txt b/requirements.txt index c9591cd..4ee459d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,7 @@ # Generated export of pyproject [project.dependencies] (H1). -# The Dockerfile consumes this file; versions are exact pins. +# CI sync gate: this file must stay set-equal to pyproject dependencies. +# The Docker image consumes requirements.lock (a hash-pinned export of +# uv.lock); this file is the human-readable direct-dependency surface. aiohttp==3.14.3 pyrofork==2.3.69 tgcrypto-pyrofork==1.2.8 diff --git a/tests/conftest.py b/tests/conftest.py index d0d6023..1d55974 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,3 +29,9 @@ } for _key, _value in _required.items(): os.environ[_key] = _value + +# Hermeticity: never read the developer's own config.env / config.env.local +# in-process (optional knobs like PRIVATE_MODE would silently leak into +# assertions). The subprocess-based precedence tests opt back out; the +# kill-switch is honoured by vars._load_env_layers(). +os.environ["THUNDER_SKIP_CONFIG_FILES"] = "1" diff --git a/tests/test_unit/test_config_env_layers.py b/tests/test_unit/test_config_env_layers.py index 37121ce..c22729f 100644 --- a/tests/test_unit/test_config_env_layers.py +++ b/tests/test_unit/test_config_env_layers.py @@ -15,10 +15,13 @@ def _run_in(tmp_path, extra_env=None): + # These tests exercise the config-file layers themselves, so they must + # opt back OUT of the conftest's THUNDER_SKIP_CONFIG_FILES hermeticity + # switch before spawning the probe process. env = { k: v for k, v in os.environ.items() - if not k.startswith(("PRIVATE_", "MAX_BATCH")) + if not k.startswith(("PRIVATE_", "MAX_BATCH")) and k != "THUNDER_SKIP_CONFIG_FILES" } env.update( { diff --git a/tests/test_unit/test_preflight.py b/tests/test_unit/test_preflight.py index f448cb2..ced474e 100644 --- a/tests/test_unit/test_preflight.py +++ b/tests/test_unit/test_preflight.py @@ -5,8 +5,8 @@ from Thunder.utils.decorators import ( GATES_INFO, - GATES_START, GATES_STANDARD, + GATES_START, PREFLIGHT_GATES, preflight, ) @@ -17,7 +17,11 @@ def test_gate_presets_exist_in_registry(): """Every preset id must resolve in PREFLIGHT_GATES -- a typo'd id is a fail-closed rejection in production, and this test fails in CI first.""" - for preset, name in ((GATES_STANDARD, "GATES_STANDARD"), (GATES_START, "GATES_START"), (GATES_INFO, "GATES_INFO")): + for preset, name in ( + (GATES_STANDARD, "GATES_STANDARD"), + (GATES_START, "GATES_START"), + (GATES_INFO, "GATES_INFO"), + ): for gate_id in preset: assert gate_id in PREFLIGHT_GATES, f"{name}: unknown gate id {gate_id!r}" From 398e3951fc4159a2dcdd6a22124f00cdfebf8272 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 19:25:35 +0000 Subject: [PATCH 21/49] fix(security): single-pass path pseudonymization + regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four sequential re.sub rules let the id-first rule re-match the 8-hex pseudonyms produced by the canonical rule (~39% of them end in two digits), double-hashing the same file and stamping a misleading "…" truncation marker on canonical /f/ links. One combined alternation pass pins exactly-once semantics; 10 unit tests cover every family (canon 20/32, watch, id-first, activate), the digit-tailed-pseudonym regression, and the control-char escape. --- Thunder/server/__init__.py | 51 ++++++------- tests/test_unit/test_access_log.py | 111 +++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+), 28 deletions(-) create mode 100644 tests/test_unit/test_access_log.py diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py index fa26c79..0627e60 100644 --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -14,35 +14,30 @@ # Modeled on ThunderGo's http/server.go logMiddleware + redactPath. +# Single combined pass: every path segment is pseudonymized exactly once. +# Sequential re.sub rules would let the id-first rule re-match 8-hex +# pseudonyms produced by the canonical rule (~39% of them end in two +# digits), double-hashing the same file and stamping a misleading "…". +_PSEUDONYM_RE = re.compile( + r"(?P(?<=/f/)[0-9a-f]{20,32})" + r"|(?P(?<=/watch/)[a-zA-Z0-9_-]{6}\d+)" + r"|(?P(?<=/activate/)[A-Za-z0-9_-]{43})" + r"|(?P(?<=/)[a-zA-Z0-9_-]{6}\d+(?=/))" +) + +# "…" marks legacy segments whose id suffix was consumed by the hash; +# canonical /f/ and /activate/ tokens keep their bare pseudonym. +_TRUNCATED_GROUPS = frozenset({"legacy", "idfirst"}) + + +def _pseudonymize(m: re.Match) -> str: + token = m.group(0) + suffix = "…" if m.lastgroup in _TRUNCATED_GROUPS else "" + return hash_path_token(token) + suffix + + def _redact_path(path: str) -> str: - # canonical: /f/<32-hex>/ or /watch/f/<32-hex>/ - path = re.sub( - r"(?<=/f/)[0-9a-f]{20,32}", - lambda m: hash_path_token(m.group(0)), - path, - ) - # legacy: /watch/<6-char-hash>/ -> hash part - path = re.sub( - r"(?<=/watch/)[a-zA-Z0-9_-]{6}\d+", - # hash the match itself -- the previous `m.group(0)[:-len(m.group(0))]` - # slice always evaluated to "" so every file logged the same pseudonym - lambda m: hash_path_token(m.group(0)) + "…", - path, - ) - # legacy id-first family: /<6-char-hash>/ -- the capability hash - # is the path segment itself (previously logged in plaintext) - path = re.sub( - r"(?<=/)[a-zA-Z0-9_-]{6}\d+(?=/)", - lambda m: hash_path_token(m.group(0)) + "…", - path, - ) - # activation tokens: /activate/<43-char urlsafe token> - path = re.sub( - r"(?<=/activate/)[A-Za-z0-9_-]{43}", - lambda m: hash_path_token(m.group(0)), - path, - ) - return path + return _PSEUDONYM_RE.sub(_pseudonymize, path) def _escape_control_chars(path: str) -> str: diff --git a/tests/test_unit/test_access_log.py b/tests/test_unit/test_access_log.py new file mode 100644 index 0000000..75f120b --- /dev/null +++ b/tests/test_unit/test_access_log.py @@ -0,0 +1,111 @@ +# tests/test_unit/test_access_log.py +"""H10 access-log middleware: path pseudonymization + log-forging escape. + +Regression: the pre-recheck _redact_path ran four sequential re.sub rules; +the id-first rule could re-match the 8-hex pseudonyms produced by the +canonical rule (~39% of them end in two digits), double-hashing the same +file and stamping a misleading "…" truncation marker. The single combined +pass pins exactly-once semantics. +""" + +import pytest + +import Thunder.server as server_mod +from Thunder.server import _escape_control_chars, _redact_path + +# 6 alnum chars + 2 trailing digits: as a *pseudonym* this shape used to be +# re-matched by the id-first rule and hashed a second time. +_FAKE_PSEUDONYM = "ab12cd99" + + +@pytest.fixture(name="fake_hash") +def _fake_hash(monkeypatch): + """Deterministic hash_path_token with a digit-tailed output.""" + calls: list[str] = [] + + def _fake(token: str) -> str: + calls.append(token) + return _FAKE_PSEUDONYM + + monkeypatch.setattr(server_mod, "hash_path_token", _fake) + return calls + + +@pytest.mark.unit +def test_canonical_pseudonym_hashed_exactly_once(fake_hash): + path = "/f/" + "a" * 32 + "/video.mp4" + out = _redact_path(path) + assert out == f"/f/{_FAKE_PSEUDONYM}/video.mp4" + # one input hashed, and the output never re-hashed + assert fake_hash == ["a" * 32] + assert "…" not in out + + +@pytest.mark.unit +def test_legacy_watch_segment_suffixed(fake_hash): + out = _redact_path("/watch/AbCdEf12345/name.mp4") + assert out == f"/watch/{_FAKE_PSEUDONYM}…/name.mp4" + assert fake_hash == ["AbCdEf12345"] + + +@pytest.mark.unit +def test_id_first_segment_suffixed(fake_hash): + out = _redact_path("/AbCdEf12345/name.mp4") + assert out == f"/{_FAKE_PSEUDONYM}…/name.mp4" + assert fake_hash == ["AbCdEf12345"] + + +@pytest.mark.unit +def test_legacy_20_char_canonical_hash(fake_hash): + out = _redact_path("/f/" + "b" * 20 + "/v") + assert out == f"/f/{_FAKE_PSEUDONYM}/v" + assert fake_hash == ["b" * 20] + + +@pytest.mark.unit +def test_activation_token_redacted(fake_hash): + out = _redact_path("/activate/" + "T" * 43) + assert out == f"/activate/{_FAKE_PSEUDONYM}" + assert fake_hash == ["T" * 43] + + +@pytest.mark.unit +def test_plain_paths_untouched(fake_hash): + assert fake_hash == [] + for path in ("/health", "/status", "/watch/", "/"): + assert _redact_path(path) == path + assert fake_hash == [] + + +@pytest.mark.unit +def test_short_id_segment_left_alone(fake_hash): + # fewer than 6 hash chars: not the legacy capability shape + assert _redact_path("/123/v.mp4") == "/123/v.mp4" + assert fake_hash == [] + + +@pytest.mark.unit +def test_real_pseudonym_stable_and_bare(): + # with the real hasher: deterministic, and canonical output never + # carries the legacy truncation marker + out1 = _redact_path("/f/" + "c" * 32 + "/v") + out2 = _redact_path("/f/" + "c" * 32 + "/v") + assert out1 == out2 + assert "…" not in out1 + pseudonym = out1.split("/f/")[1].split("/")[0] + assert len(pseudonym) == 8 + int(pseudonym, 16) # 8-hex + + +@pytest.mark.unit +def test_control_chars_escaped(): + forged = "/f/x\n[INFO] fake line\r\t" + out = _escape_control_chars(forged) + assert "\n" not in out and "\r" not in out and "\t" not in out + assert "%0A" in out and "[INFO] fake line" in out + + +@pytest.mark.unit +def test_printable_path_untouched(): + path = "/f/abc/file.mp4?q=1" + assert _escape_control_chars(path) == path From 9ff46b194f98df81baf8f77d959678a9018fe37f Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 19:25:35 +0000 Subject: [PATCH 22/49] build: enable mypy check_untyped_defs; fix the 5 latent defects it finds Untyped function bodies were previously unchecked; enabling the flag surfaces 5 real issues, all fixed at the annotation level: - process_single(status_msg) accepts None from the batch worker, where no per-file status message exists (existing call sites already None-guard) -> Message | None - get_readable_time() already int()-coerces internally and a test feeds it 90.9 -> accept int | float (the /status uptime caller passes float) - pyrogram exposes Client.username dynamically; read via getattr with fallback in /status, pin via explicit ignore at boot --- Thunder/__main__.py | 7 +++++-- Thunder/bot/plugins/stream.py | 2 +- Thunder/server/stream_routes.py | 2 +- Thunder/utils/time_format.py | 2 +- pyproject.toml | 2 +- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Thunder/__main__.py b/Thunder/__main__.py index 02feda9..a7d544b 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -131,8 +131,11 @@ async def start_services(): try: await tg_call(StreamBot.start) bot_info = await tg_call(StreamBot.get_me) - StreamBot.username = bot_info.username - print(f" βœ“ Bot initialized successfully as @{StreamBot.username}") + # pyrogram exposes Client.username dynamically (set during sign-in), + # so mypy cannot see it; pin it explicitly for /status consumers. + username = bot_info.username + StreamBot.username = username # type: ignore[attr-defined] + print(f" βœ“ Bot initialized successfully as @{username}") await set_commands() print(" βœ“ Bot commands set successfully.") diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index b8bb4e2..3e1750e 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -484,7 +484,7 @@ async def process_single( bot: Client, msg: Message, file_msg: Message, - status_msg: Message, + status_msg: Message | None, shortener_val: bool, original_request_msg: Message | None = None, notification_msg: Message | None = None, diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index 2bf6029..a682cd5 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -348,7 +348,7 @@ async def status_endpoint(request): "uptime": get_readable_time(uptime), }, "telegram_bot": { - "username": f"@{StreamBot.username or 'unknown'}", + "username": f"@{getattr(StreamBot, 'username', None) or 'unknown'}", "active_clients": len(multi_clients), "dc_id": dc_id, }, diff --git a/Thunder/utils/time_format.py b/Thunder/utils/time_format.py index 39a1ba7..d8e6a02 100644 --- a/Thunder/utils/time_format.py +++ b/Thunder/utils/time_format.py @@ -5,7 +5,7 @@ _TIME_PERIODS = (("d", 86400), ("h", 3600), ("m", 60), ("s", 1)) -def get_readable_time(seconds: int) -> str: +def get_readable_time(seconds: int | float) -> str: try: result = [] for suffix, period in _TIME_PERIODS: diff --git a/pyproject.toml b/pyproject.toml index 1224f0a..a6ed182 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,7 @@ ignore = [ [tool.mypy] python_version = "3.13" ignore_missing_imports = true -check_untyped_defs = false +check_untyped_defs = true warn_unused_ignores = false exclude = ["tests/"] From 1927e6bf5b86d94cc6ae3aba621ee46f0b771ff3 Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 19:25:35 +0000 Subject: [PATCH 23/49] docs: document the anonymous-sender deny rule (TOKEN/PRIVATE modes) Fail-closed gates reject messages with no attributable from_user (channel posts, anonymous admins); say so in the README token section and the AGENTS gate-chain notes. --- AGENTS.md | 3 +++ README.md | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 9c5b5ad..728ba3a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -140,6 +140,9 @@ from Thunder.vars import Var # All env config - Owner bypasses everything; authorized users bypass all but the ban check. - `/start` runs only `banned + private-mode` so the activation flow stays reachable. - `PRIVATE_MODE=True` restricts the whole bot to owner + authorized users. +- Messages with no attributable sender (`from_user is None`: channel posts, + anonymous admins) are DENIED by the private-mode and token gates β€” + fail-closed, never bypass. - Adding a new gate = one entry in `PREFLIGHT_GATES` + a row above. ## Rate Limiting diff --git a/README.md b/README.md index 9320941..82f8b60 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,12 @@ Enable controlled access with tokens: 3. Admins can grant permanent authorization with `/authorize` to bypass tokens. 4. Tokens include activation links for secure access. +> **Note**: with `TOKEN_ENABLED=True` (or `PRIVATE_MODE=True`), messages +> without an attributable sender β€” e.g. channel posts or anonymous-admin +> messages in the bot's own channel β€” are rejected by design. Interact with +> the bot from a personal account so your user ID can be checked against +> the token/ban gates. + ### URL Shortening Configure URL shortening for cleaner links: From 5a2cc09c755f0241e153e41a954fccf1315da71b Mon Sep 17 00:00:00 2001 From: fyaz05 Date: Sun, 6 Sep 2026 19:48:24 +0000 Subject: [PATCH 24/49] style: lean comments branch-wide Every comment in the branch diff trimmed to 0-2 lines carrying only the why/invariant/pitfall (plan tags H/M/L kept). No code changes: verified by AST comparison (docstrings stripped) on all 47 touched .py files; Makefile tabs, TOML/YAML parse, and the full local gate suite unaffected. --- .gitattributes | 3 +- .github/dependabot.yml | 5 +- .github/workflows/quality.yml | 16 ++-- Dockerfile | 10 +-- Makefile | 4 +- Thunder/__init__.py | 5 +- Thunder/__main__.py | 21 ++--- Thunder/bot/plugins/admin.py | 24 +++--- Thunder/bot/plugins/callbacks.py | 14 ++-- Thunder/bot/plugins/common.py | 17 ++-- Thunder/bot/plugins/stream.py | 45 +++++----- Thunder/server/__init__.py | 18 ++-- Thunder/server/stream_routes.py | 17 ++-- Thunder/template/req.html | 25 +----- Thunder/utils/bot_utils.py | 9 +- Thunder/utils/broadcast.py | 13 ++- Thunder/utils/canonical_files.py | 31 +++---- Thunder/utils/commands.py | 3 +- Thunder/utils/config_parser.py | 3 +- Thunder/utils/custom_dl.py | 36 +++----- Thunder/utils/database.py | 45 ++++------ Thunder/utils/decorators.py | 18 ++-- Thunder/utils/force_channel.py | 10 +-- Thunder/utils/keepalive.py | 2 +- Thunder/utils/logger.py | 9 +- Thunder/utils/media_types.py | 13 ++- Thunder/utils/messages.py | 22 ----- Thunder/utils/rate_limiter.py | 88 +++++++------------- Thunder/utils/render_template.py | 13 ++- Thunder/utils/safe_call.py | 34 +++----- Thunder/utils/shortener.py | 35 +++----- Thunder/utils/tokens.py | 22 ++--- Thunder/vars.py | 41 ++++----- config_sample.env | 46 ++-------- pyproject.toml | 12 ++- requirements.txt | 6 +- tests/conftest.py | 18 +--- tests/integration/test_mongo.py | 26 ++---- tests/test_unit/test_access_log.py | 14 +--- tests/test_unit/test_canonical_files.py | 1 - tests/test_unit/test_config.py | 1 - tests/test_unit/test_config_env_layers.py | 9 +- tests/test_unit/test_custom_dl_exceptions.py | 5 +- tests/test_unit/test_flag_cache.py | 1 - tests/test_unit/test_human_readable.py | 1 - tests/test_unit/test_media_types.py | 1 - tests/test_unit/test_preflight.py | 1 - tests/test_unit/test_rate_limiter.py | 1 - tests/test_unit/test_redaction.py | 1 - tests/test_unit/test_registry.py | 1 - tests/test_unit/test_safe_call.py | 1 - tests/test_unit/test_shortener.py | 1 - tests/test_unit/test_stream_routes.py | 6 +- tests/test_unit/test_time_format.py | 1 - tests/test_unit/test_tokens_consume.py | 13 +-- thunder.sh | 3 +- update.py | 8 +- 57 files changed, 277 insertions(+), 572 deletions(-) diff --git a/.gitattributes b/.gitattributes index cbcdca3..02bfbb2 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,8 +1,7 @@ # 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) +# Windows scripts (*.bat), if ever added, need: text eol=crlf # Binary assets *.png binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8ad62c7..f450b8f 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,6 +1,5 @@ -# NOTE: Dependabot cannot regenerate uv.lock. When a pip PR arrives, -# run `uv lock` locally, commit the updated lockfile, and push it to -# the PR branch -- CI's `uv lock --check` gate fails otherwise. +# Dependabot cannot regenerate uv.lock: after each pip PR, run `uv lock` and +# push the updated lockfile to the PR branch, or CI's `uv lock --check` fails. version: 2 updates: - package-ecosystem: pip diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index ce2e5a7..c1c700f 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -27,9 +27,8 @@ jobs: - name: Install uv run: python -m pip install --upgrade uv - # Install the hash-pinned lockfile (runtime + dev tools), so CI tests - # and audits the exact dependency graph that ships -- not a fresh - # pip resolution that can drift from uv.lock. + # Install the hash-pinned lockfile (runtime + dev) so CI tests and audits + # the exact dependency graph that ships, not a fresh pip resolution. - name: Install dependencies (locked, hash-pinned) run: uv sync --frozen --group dev @@ -65,8 +64,7 @@ jobs: - name: Unit tests run: uv run pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 - # Environment audit: includes transitive deps from the locked env, - # which `-r requirements.txt` (direct pins only) never covered. + # Audit the locked env: `-r requirements.txt` (direct pins only) never covered transitives. - name: pip-audit (locked environment incl. transitives) run: uv run pip-audit @@ -86,8 +84,8 @@ jobs: fi docker: - # publish images from main only (a feature-branch push must never - # overwrite the fyaz05/thunder:latest tag) + # publish from main pushes only: a feature-branch push must never + # overwrite the fyaz05/thunder:latest tag if: github.repository == 'fyaz05/FileToLink' && github.event_name == 'push' && github.ref == 'refs/heads/main' needs: quality runs-on: ubuntu-latest @@ -110,8 +108,8 @@ jobs: fyaz05/thunder:${{ github.sha }} integration: - # the atomicity guarantees (token activation CAS, ingest claims) are only - # proven against a real MongoDB -- this tier never ran in CI before + # token CAS / ingest-claim atomicity is only provable against a real + # MongoDB, and this tier never ran in CI before runs-on: ubuntu-latest timeout-minutes: 20 steps: diff --git a/Dockerfile b/Dockerfile index cfaaf7a..1a76072 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,14 +5,12 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app -# no git in the image: self-update no-ops cleanly without .git/git, and a -# container should be replaced by pulling a new image, not mutating itself. -# python:3.13-slim already ships everything else the runtime needs. +# no git in the image: self-update no-ops cleanly, and a container should be +# replaced by pulling a new image, not mutated. RUN useradd --create-home --shell /bin/bash thunder -# hash-pinned FULL graph (uv export of uv.lock) -- the previous direct-pins -# install resolved fresh, unpinned transitives on every build, so the -# supply-chain guarantees held in CI never reached the shipped image +# requirements.lock = hash-pinned FULL graph (uv export of uv.lock); plain +# direct pins would resolve fresh, unpinned transitives at every image build. COPY requirements.lock . RUN pip install --upgrade pip && \ diff --git a/Makefile b/Makefile index 67d72d1..86ca0d0 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,7 @@ .PHONY: format lint test coverage audit run clean # L8: developer entry points (see CONTRIBUTING.md) -# NOTE: recipes MUST be indented with hard TABs, not spaces. -# All tools run through `uv run` so they execute inside the project -# environment regardless of the developer's ambient virtualenv. +# Recipes need hard TABs; tools run via `uv run` (project env, not the ambient venv). format: uv run ruff check Thunder/ update.py tests/ --fix diff --git a/Thunder/__init__.py b/Thunder/__init__.py index a768d57..d6ca287 100644 --- a/Thunder/__init__.py +++ b/Thunder/__init__.py @@ -5,7 +5,6 @@ StartTime = time.time() -# L8: build-time injectable version (Docker/PaaS may set APP_VERSION; the -# pyproject.toml [project] version is the single source of truth for the -# default). Exposed by /status and /stats. +# L8: build-time injectable version -- Docker/PaaS may set APP_VERSION; +# the pyproject.toml [project] version is the default. Exposed by /status and /stats. __version__ = os.getenv("APP_VERSION", "2.2.0") diff --git a/Thunder/__main__.py b/Thunder/__main__.py index a7d544b..ff0da3d 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -131,16 +131,14 @@ async def start_services(): try: await tg_call(StreamBot.start) bot_info = await tg_call(StreamBot.get_me) - # pyrogram exposes Client.username dynamically (set during sign-in), - # so mypy cannot see it; pin it explicitly for /status consumers. + # pyrogram sets Client.username dynamically, so mypy cannot see it; pin it for /status. username = bot_info.username StreamBot.username = username # type: ignore[attr-defined] print(f" βœ“ Bot initialized successfully as @{username}") await set_commands() print(" βœ“ Bot commands set successfully.") - # managed background task: cancelled + awaited at shutdown (the old - # fire-and-forget version leaked as a pending task) + # managed background task: cancelled + awaited at shutdown background_tasks.append(schedule_index_ensure()) _harden_session_files() @@ -162,8 +160,7 @@ async def start_services(): except Exception as e: logger.error(f" βœ– Failed to initialize Telegram Bot: {e}", exc_info=True) - # M13 contract: a failed boot must exit non-zero, or container - # restart policies never fire and the box sits dead but "healthy". + # M13: a failed boot must exit non-zero or container restart policies never fire. raise SystemExit(1) from e print(" β–Ά Starting Client initialization...") @@ -216,9 +213,8 @@ async def start_services(): t.cancel() if background_tasks: await asyncio.gather(*background_tasks, return_exceptions=True) - # mirror shutdown_services ordering: the touch buffer must flush - # BEFORE db.close, or _bulk_flush runs against a closed client and - # silently discards every pending increment + # ordering: the touch buffer must flush BEFORE db.close, or _bulk_flush + # runs against a closed client and silently discards pending increments await _safe_teardown_step(rate_limiter.shutdown, "rate limiter") await _safe_teardown_step(drain_background_touch_tasks, "touch buffer") await _safe_teardown_step(cleanup_clients, "clients") @@ -264,8 +260,7 @@ async def shutdown_services(background_tasks, app_runner) -> None: if not task.done(): task.cancel() - # one bounded wait for the WHOLE batch (the old per-task wait_for(x, 10) - # could stack to ~80s worst-case before teardown ever started) + # one bounded wait for the WHOLE batch (per-task waits could stack ~80s before teardown) if background_tasks: done, pending = await asyncio.wait(background_tasks, timeout=10) for t in done: @@ -342,8 +337,8 @@ async def schedule_limiter_sweep(): if __name__ == "__main__": - # L5: session files carry bearer-equivalent auth keys -- a restrictive - # umask covers the window between file creation and _harden_session_files + # L5: session files carry bearer-equivalent auth keys -- the restrictive umask + # covers the window before _harden_session_files runs. os.umask(0o077) try: asyncio.run(start_services()) diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py index 184bf0d..d35815c 100644 --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -84,7 +84,7 @@ owner_filter = filters.private & filters.user(Var.OWNER_ID) -# H10: /log tail cap (mirrors ThunderGo handlers_owner.go handleLog) +# H10: /log tail cap _LOG_TAIL_BYTES = 45 * 1024 * 1024 @@ -185,10 +185,9 @@ async def show_stats(client: Client, message: Message): ram_used = humanbytes(ram_info.used) ram_free = humanbytes(ram_info.free) - # one psutil call yields all four values (was: shutil + psutil) disk = await asyncio.to_thread(psutil.disk_usage, ".") total_disk, used_disk, free_disk = disk.total, disk.used, disk.free - # H8: the synchronous psutil call is off the event loop + # H8: sync psutil stays off the event loop disk_percent = disk.percent limiter_line = ( @@ -230,9 +229,8 @@ async def show_stats(client: Client, message: Message): async def restart_bot(client: Client, message: Message): msg = await reply(message, text=MSG_RESTARTING) await db.add_restart_message(msg.id, message.chat.id) - # mirror __main__ teardown ordering (M13): the touch buffer batches view - # counts for up to a few seconds -- execv skips every finally block, so - # drain it here or the restart loses those increments + # M13 teardown ordering: execv skips every finally block, so drain the + # touch buffer here or the restart loses pending view-count increments from Thunder.utils.canonical_files import drain_background_touch_tasks await drain_background_touch_tasks() @@ -249,9 +247,9 @@ async def send_logs(client: Client, message: Message): return try: - # H10: never upload raw logs -- stream the (capped) tail through the - # shared redaction regexes so bot tokens / Mongo URIs cannot leak. - # File IO + regex over megabytes must not run on the event loop (H8). + # H10: never upload raw logs -- capped tail through the shared redaction + # regexes so bot tokens / Mongo URIs cannot leak. + # H8: file IO + regex over megabytes off the event loop. def _read_redacted_tail() -> str: with open(LOG_FILE, "rb") as f: f.seek(0, os.SEEK_END) @@ -325,8 +323,8 @@ async def list_authorized_command(client: Client, message: Message): if not users: return await reply(message, text=MSG_NO_AUTH_USERS) - # M7: HTML + html.escape for user-controlled display names. - # One batched get_users RPC instead of one per row (N+1 FloodWait risk). + # M7: user-controlled display names get html.escape()d; one batched + # get_users RPC avoids N+1 FloodWaits. id_to_user: dict[int, Any] = {} try: tg_users = await tg_call(client.get_users, [u["user_id"] for u in users], retries=1) @@ -371,7 +369,7 @@ async def ban_command(client: Client, message: Message): try: target_id = int(message.command[1]) - # M7: reason is user-controlled and the ban messages are HTML now + # M7: user-controlled reason; ban messages are HTML reason = html.escape(" ".join(message.command[2:])) or MSG_ADMIN_NO_BAN_REASON banned_by_id = message.from_user.id if message.from_user else None @@ -443,7 +441,7 @@ async def unban_command(client: Client, message: Message): @StreamBot.on_message(filters.command("shell") & owner_filter) async def run_shell_command(client: Client, message: Message): - # L10: env kill-switch -- the powerful command is opt-in. + # L10: env kill-switch -- /shell is opt-in. if not Var.ENABLE_SHELL: return await reply(message, text=MSG_SHELL_DISABLED, parse_mode=ParseMode.HTML) diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py index f63fa9f..78a63db 100644 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -69,8 +69,7 @@ async def get_force_channel_button(client: Client): if not Var.FORCE_CHANNEL_ID: return None try: - # reuse the resolved-once cache in force_channel.get_force_info - # instead of a fresh get_chat RPC on every help-panel render + # get_force_info resolves once and caches -- no fresh get_chat RPC per render link, title = await get_force_info(client) if link: return [ @@ -93,7 +92,7 @@ async def help_callback(client: Client, callback_query: CallbackQuery): if force_button: buttons.append(force_button) buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]) - # keep /help command and the help panel on one implementation (M1) + # M1: /help command and the help panel share one implementation help_text = build_help_text(Var.MAX_BATCH_FILES) try: await edit_safe( @@ -154,8 +153,7 @@ async def restart_broadcast_callback(client: Client, callback_query: CallbackQue @StreamBot.on_callback_query(filters.regex(r"^close_panel$")) @guard_callback async def close_panel_callback(client: Client, callback_query: CallbackQuery): - # M11: permission check -- previously any group member who saw a Close - # button could trigger deletion attempts. + # M11: only the owner or whoever triggered the panel may close it. closer_id = callback_query.from_user.id if callback_query.from_user else None message = callback_query.message @@ -164,10 +162,8 @@ async def close_panel_callback(client: Client, callback_query: CallbackQuery): if closer_id == Var.OWNER_ID: is_allowed = True else: - # Panels are bot-sent, so message.from_user is the BOT -- comparing - # against it locked every non-owner out of their own Close button. - # The person who triggered the panel is its reply target (the - # command/queue message) or, in private chats, the chat peer. + # Panels are bot-sent, so message.from_user is the bot -- the requester + # is the reply target's sender, or the chat peer in private chats. if message.reply_to_message and message.reply_to_message.from_user: is_allowed = closer_id == message.reply_to_message.from_user.id if not is_allowed and message.chat and message.chat.type == enums.ChatType.PRIVATE: diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py index 642f93d..d0c7247 100644 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -51,14 +51,14 @@ from Thunder.utils.tokens import consume from Thunder.vars import Var -# M7: surfaces that interpolate user-controlled values are HTML now; -# every interpolation is html.escape()d. +# M7: surfaces interpolating user-controlled values are HTML; every +# interpolation is html.escape()d. @StreamBot.on_message(filters.command("start") & filters.private) async def start_command(bot: Client, msg: Message): - # M12: /start runs banned + private-mode only so the activation flow - # stays reachable for token-gated users. + # M12: banned + private-mode gates only, so token-gated users can + # still reach the activation flow. if await preflight(bot, msg, gates=GATES_START) is None: return user = msg.from_user @@ -171,8 +171,8 @@ async def send_user_dc(msg: Message, user: User): await reply_safe( msg, text=txt, - # DC templates are HTML (M7): pin the parse mode so pyrofork's - # DEFAULT markdown pre-pass cannot reinterpret user data + # M7: pin HTML parse mode -- pyrofork's markdown pre-pass must not + # reinterpret user data parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns), # type: ignore[arg-type] ) @@ -225,9 +225,8 @@ async def send_file_dc(msg: Message, file_msg: Message): @StreamBot.on_message(filters.command("dc")) async def dc_command(bot: Client, msg: Message): - # Gate chain for /dc (banned -> private-mode, then force-sub). The token - # gate is intentionally NOT applied: /dc is informational, and applying - # it here would lock token-gated users out of diagnostics. + # Gate chain: banned -> private-mode (GATES_START), then force-sub; token gate + # intentionally skipped -- /dc is informational, must stay reachable for token users. if await preflight(bot, msg, gates=GATES_START) is None: return from Thunder.utils.decorators import force_sub_gate diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index 3e1750e..b7f4604 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -121,8 +121,8 @@ async def send_channel_links( reply_to_message_id: int | None = None, ): text = MSG_NEW_FILE_REQUEST.format( - # source_info (display name / chat title) is user-controlled and the - # template renders as HTML under pyrofork's DEFAULT parse mode (M7) + # source_info (display name / chat title) is user-controlled and renders + # as HTML under pyrofork's DEFAULT parse mode (M7) source_info=html.escape(source_info), id_=source_id, online_link=links["online_link"], @@ -201,8 +201,8 @@ async def send_link(msg: Message, links: dict[str, Any]): @StreamBot.on_message(filters.command("link") & ~filters.private) async def link_handler(bot: Client, msg: Message, **kwargs): - # A channel-posted /link has no from_user; key the limiter on the - # sender chat instead of dropping the request with dead air. + # A channel-posted /link has no from_user: key the limiter on the + # sender chat instead of dropping the request. if kwargs.get("rl_user_id") is None and msg.sender_chat and msg.sender_chat.id: kwargs["rl_user_id"] = msg.sender_chat.id @@ -211,8 +211,8 @@ async def _actual_link_handler(client: Client, message: Message, **handler_kwarg if shortener_val is None: return if message.from_user and not await db.is_user_exist(message.from_user.id): - # client.me is always populated after client.start(); the stub union - # is unavoidable at this layer. + # client.me is populated after client.start(); the stub union + # is unavoidable here. invite_link = f"https://t.me/{client.me.username}?start=start" # type: ignore[union-attr] try: await reply_safe( @@ -243,8 +243,8 @@ async def _actual_link_handler(client: Client, message: Message, **handler_kwarg notification_msg = handler_kwargs.get("notification_msg") - # filters.command also matches captions, where .text is None -- - # parse from the caption too or a captioned /link crashes with dead air + # filters.command matches captions too, where .text is None -- + # parse the caption or a captioned /link dies silently parts = (message.text or message.caption or "").split() num_files = 1 if len(parts) > 1: @@ -335,10 +335,8 @@ async def channel_receive_handler(bot: Client, msg: Message): async def _actual_channel_receive_handler(client: Client, message: Message, **handler_kwargs): if not Var.CHANNEL: return - # M12: PRIVATE_MODE promises "owner + authorized users only" -- a - # channel post must not mint public links on a private instance - # (channels have no from_user, so the user gates cannot vouch for - # them; fail closed here). + # M12: PRIVATE_MODE promises "owner + authorized users only" -- channels + # have no from_user for the gates, so fail closed: no public links. if Var.PRIVATE_MODE: logger.debug(f"Ignoring channel post from {message.chat.id} (PRIVATE_MODE).") return @@ -347,11 +345,9 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha is_banned_statically = ( hasattr(Var, "BANNED_CHANNELS") and message.chat.id in Var.BANNED_CHANNELS ) - # flag-cached (one DB hit per channel per TTL instead of per post). - # Fail-open is DELIBERATE here: the action on a hit is leave_chat, - # which is destructive and irreversible -- a Mongo outage must not - # make the bot leave every channel it serves (H7's fail-closed - # applies to the user-ban gate, where denial is cheap and safe). + # Flag-cached (one DB hit per channel per TTL). Fail-open is DELIBERATE: + # a hit triggers leave_chat (irreversible) -- a Mongo outage must not + # mass-leave served channels (H7 fail-closed applies to user-ban gates). is_banned_dynamically = ( await flags.get_or_load( ("banned_channel", message.chat.id), @@ -400,9 +396,8 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha links = await gen_links(stored_msg, shortener=shortener_val) reply_to_message_id = stored_msg.id source_info = message.chat.title or "Unknown Channel" - # When we reused an existing canonical BIN copy, stored_msg is intentionally - # None so send_channel_links falls back to StreamBot.send_message(..., - # reply_to_message_id=...) and keeps the log threaded to the canonical message. + # stored_msg is intentionally None after reusing a canonical BIN copy: + # send_channel_links then threads the log to the canonical message via reply_to_message_id. if notification_msg: try: @@ -592,7 +587,7 @@ async def process_batch( skipped = 0 counters = {"done": 0, "failed": 0} - # ---- pre-fetch phase (chunked, same as the historical behavior) ---- + # ---- pre-fetch phase (chunked) ---- fetched: dict[int, Message | None] = {} fetch_failed: set[int] = set() for chunk_start in range(0, count, BATCH_SIZE): @@ -608,8 +603,8 @@ async def process_batch( else: messages = list(fetched_msgs) except Exception as e: - # a failed chunk is a FAILED fetch, not a benign skip: counting it - # as skipped made whole-chunk outages invisible in the summary + # a failed chunk counts as FAILED, not skipped -- skipping would + # hide whole-chunk outages from the summary logger.error(f"Error getting messages in batch: {e}", exc_info=True) fetch_failed.update(chunk_ids) messages = [] @@ -666,8 +661,8 @@ async def worker(): if counters["done"] % BATCH_UPDATE_INTERVAL == 0 and counters["done"] < count: await progress_edit() - # initial status (guarded: a deleted/undeletable status message must not - # abort the whole batch before it starts) + # initial status (guarded: a deleted/undeletable status message must + # not abort the batch before it starts) try: await edit_safe( status_msg, diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py index 0627e60..453d2c8 100644 --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -9,15 +9,11 @@ from .stream_routes import routes -# H10: access log middleware -- logs method, redacted path (file tokens are -# replaced by their sha256 prefix), status, bytes and duration. -# Modeled on ThunderGo's http/server.go logMiddleware + redactPath. +# H10: access log middleware -- method, redacted path, status, bytes, duration. -# Single combined pass: every path segment is pseudonymized exactly once. -# Sequential re.sub rules would let the id-first rule re-match 8-hex -# pseudonyms produced by the canonical rule (~39% of them end in two -# digits), double-hashing the same file and stamping a misleading "…". +# Single pass: sequential rules would let the id-first rule re-match 8-hex +# pseudonyms from the canonical rule (~39% end in two digits), double-hashing them. _PSEUDONYM_RE = re.compile( r"(?P(?<=/f/)[0-9a-f]{20,32})" r"|(?P(?<=/watch/)[a-zA-Z0-9_-]{6}\d+)" @@ -26,7 +22,7 @@ ) # "…" marks legacy segments whose id suffix was consumed by the hash; -# canonical /f/ and /activate/ tokens keep their bare pseudonym. +# canonical and /activate/ tokens keep their bare pseudonym. _TRUNCATED_GROUPS = frozenset({"legacy", "idfirst"}) @@ -58,8 +54,7 @@ async def access_log_middleware(request: web.Request, handler): finally: duration_ms = (time.perf_counter() - start) * 1000 try: - # response stays None when the handler raised a non-HTTP - # exception; getattr(None, ...) then falls back to 500 + # response is None for non-HTTP exceptions; getattr(None, ...) then yields 500 status = getattr(response, "status", 500) size = getattr(response, "content_length", None) logger.info( @@ -73,8 +68,7 @@ async def access_log_middleware(request: web.Request, handler): async def web_server(): - # client_max_size removed (H4b): this is a GET-only server; the old 50 MiB - # cap only governed request bodies that can never legitimately arrive. + # H4b: GET-only server -- no request bodies, so no client_max_size cap. web_app = web.Application(middlewares=[access_log_middleware]) web_app.add_routes(routes) return web_app diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index a682cd5..724e70c 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -155,10 +155,8 @@ def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]: if start_str: start = int(start_str) end = int(end_str) if end_str else file_size - 1 - # RFC 7233 Β§2.1: a last-byte-pos >= length means "rest of the - # representation" -- clamp instead of rejecting. Download managers - # commonly send a fixed-chunk end computed without knowing the size; - # a hard 416 broke resume/seeking for exactly those clients. + # RFC 7233: last-byte-pos >= length means "rest of the representation" -- + # clamp, not 416; download managers send fixed-chunk ends without knowing the size. end = min(end, file_size - 1) else: if not end_str: @@ -461,10 +459,8 @@ async def canonical_media_delivery(request: web.Request): _resolve_unique_id(file_record) media_ref = int(file_record["canonical_message_id"]) - # M10: resolve the vault message up-front. One fetch serves - # both the self-heal check and the Content-Length verification; - # the Message object is passed on so stream_file does not - # re-fetch it. + # M10: one vault fetch serves both the self-heal check and the + # Content-Length verification; the Message is passed on so stream_file does not re-fetch. try: vault_message = await streamer.get_message(media_ref) except FileNotFound: @@ -472,9 +468,8 @@ async def canonical_media_delivery(request: web.Request): raise FileNotFound( "Vault message missing; record self-healed, re-upload to regenerate the link" ) from None - # TelegramUnavailable (FloodWait-exhaustion / timeout / transport) - # is NOT proof the vault message is gone: it must not delete the - # record. It falls through to the 503 ladder below. + # TelegramUnavailable (FloodWait/timeout/transport) is NOT proof the + # vault message is gone -- must not delete the record; falls through to the 503 ladder. media = get_media(vault_message) if not media: diff --git a/Thunder/template/req.html b/Thunder/template/req.html index 34ad684..b52d32f 100644 --- a/Thunder/template/req.html +++ b/Thunder/template/req.html @@ -11,11 +11,9 @@ - - @@ -24,17 +22,16 @@ content="Stream '{{ file_name }}' in a beautiful, atmospheric environment with professional playback controls."> - - + - + {% if kind == 'video' %} @@ -45,7 +42,6 @@ {% endif %} - - + @@ -200,63 +191,63 @@

{{ file_name }}

Android
- VLC - MX Player - MX Player Pro - Splayer - Next Player - Nova Player - MPV-Android - Just Player - @@ -398,7 +389,7 @@

{{ file_name }}

- - {% endif %} + + + + {% elif kind == 'audio' %} - - - + + + {% endif %} @@ -61,9 +67,10 @@ rel="stylesheet" crossorigin="anonymous"> - - + +