diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d95d1fe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,25 @@ +.git +.gitignore +.gitattributes +.github +.venv +.vscode +__pycache__ +*.py[cod] +.pytest_cache +.ruff_cache +.mypy_cache +*.session* +logs/ +tests/ +htmlcov/ +.coverage +README.md +CONTRIBUTING.md +SECURITY.md +Makefile +config.env +config.env.local +AGENTS.md +uv.lock +requirements.txt diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..857e948 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Normalize all text files to LF in the repository and working tree. +* text=auto eol=lf + +# Binary assets +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.docx binary +*.session binary diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..9fe744d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,20 @@ +# 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 + directory: "/" + schedule: + interval: weekly + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + - package-ecosystem: docker + directory: "/" + schedule: + interval: weekly + - package-ecosystem: pre-commit + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/dockerize.yml b/.github/workflows/dockerize.yml deleted file mode 100644 index a810be6..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..a982283 --- /dev/null +++ b/.github/workflows/quality.yml @@ -0,0 +1,138 @@ +name: Quality Gates + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +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@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + # pinned: lock/export output format and gate semantics must not drift + # between commits (dependabot bumps this like any other dependency) + - name: Install uv + run: python -m pip install "uv==0.12.5" + + # 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 + + - 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 --locked 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 tests/ + uv run ruff format --check Thunder/ update.py tests/ + + - name: Mypy (blocking) + run: uv run mypy Thunder update.py + + - name: Unit tests + run: uv run pytest --cov=Thunder --cov-report=term-missing --cov-fail-under=35 + + # 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 + + - name: Bandit (high-only severity) + run: uv run bandit -c pyproject.toml -r Thunder update.py -ll --skip B101 + + - name: Vulture (dead-code gate) + run: uv run vulture Thunder update.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 7 ]; then + echo "::error::Direct dependency count increased beyond the agreed 7; justify in the PR or remove." + exit 1 + fi + + docker: + # Smoke-build on PRs (no secrets); publish only from main pushes so a + # feature branch can never overwrite fyaz05/thunder:latest. + if: github.repository == 'fyaz05/FileToLink' && (github.event_name == 'pull_request' || (github.event_name == 'push' && github.ref == 'refs/heads/main')) + needs: quality + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - name: Login to Docker Hub + if: github.event_name == 'push' + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0 + with: + username: fyaz05 + password: ${{ secrets.DOCKER_TOKEN }} + + - name: Build and Push + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2 + with: + context: . + push: ${{ github.event_name == 'push' }} + # Attestations stay off: the default docker driver rejects them even + # on no-push smoke builds; revisit with a containerd-backed builder. + tags: | + fyaz05/thunder:latest + fyaz05/thunder:${{ github.sha }} + + integration: + # token CAS / ingest-claim atomicity is only provable against a real + # MongoDB, and this tier never ran in CI before + needs: quality + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5.1.0 + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.13" + + - name: Install uv + run: python -m pip install "uv==0.12.5" + + - 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/.gitignore b/.gitignore index 431eeca..f87c885 100644 --- a/.gitignore +++ b/.gitignore @@ -1,28 +1,38 @@ -*.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 +*.py[cod] +*$py.class +*.so +.venv/ +.Python +config.env +config.env.local +.coverage +htmlcov/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +log.txt +config.env.tmp +config.env.bak +*.tmp +Thunder/logs/ +.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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..b374a56 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,13 @@ +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.16.6 + hooks: + - id: ruff-check + 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..bbcbfd4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,80 +1,131 @@ # 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: files → direct HTTP links (Pyrofork, aiohttp, MongoDB, uvloop). +Enforced by CI (`quality.yml`) and tests — update in the same PR that changes behavior/config. ## Run ```bash -python -m Thunder # Primary entry point -bash thunder.sh # Runs python3 update.py && python3 -m Thunder +python -m Thunder # primary entry point +bash thunder.sh # best-effort self-update (shell-free) + python3 -m Thunder ``` ## Dependencies +`pyproject.toml` owns 7 exact-pinned direct deps (CI fails beyond 7); +`uv.lock` pins the transitive graph (regenerate with `uv lock` on change); +`requirements.lock` is the hashed export Docker installs with `--require-hashes`. +CI fails if either drifts: + +```bash +uv sync --frozen # reproducible env +pip install -r requirements.txt # human installs +pip install --require-hashes -r requirements.lock # what Docker ships +# aiohttp, pyrofork[speedup], pymongo, Jinja2, python-dotenv, psutil, uvloop +``` + +## Development + ```bash -pip install -r requirements.txt -# aiohttp, cloudscraper, Jinja2, pyrofork, pymongo, psutil, python-dotenv, speedtest-cli, tgcrypto, uvloop==0.21.0 +make format # ruff autofix + format +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 ``` +## Test tiers + +- **Unit** (every PR): `uv run pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35` +- **Integration** (opt-in, Docker): `TEST_INTEGRATION=1 uv run pytest -m integration` (testcontainers MongoDB, dev group) +- Characterization tests pin behavior; update deliberately in the behavior PR. + ## Project Structure ```text Thunder/ -├── __init__.py # __version__, StartTime -├── __main__.py # Entry: start_services() via asyncio -├── vars.py # Configuration from env vars +├── __init__.py # __version__, StartTime +├── __main__.py # start_services(), executor pool, sweepers, M13 shutdown +├── vars.py # .env layering, collects ALL validation errors ├── bot/ -│ ├── __init__.py # StreamBot client, multi_clients, work_loads -│ ├── clients.py # Multi-client management -│ └── plugins/ -│ ├── admin.py # Owner commands: /users /broadcast /status /stats /restart /log /authorize /deauthorize /ban /unban /shell /speedtest -│ ├── callbacks.py # Inline keyboard handlers -│ ├── common.py # User commands: /start /help /about /dc /ping -│ └── stream.py # /link (groups), private/channel media handlers -├── 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 # StreamBot, multi_clients, work_loads +│ ├── registry.py # command registry → menu / help / AGENTS.md (M1) +│ ├── clients.py # multi-client + session chmod 0600 (L5) +│ └── plugins/ # admin (owner) / callbacks (M11) / common (user) / stream (/link, M4b) +├── server/ # __init__ (access log, hashed tokens H10) / stream_routes (/health /status /activate /f /watch) / exceptions +├── utils/ # safe_call, flag_cache, media_types + rate_limiter, decorators, tokens, ... +└── template/ # req.html (video/audio/image/other player) ``` +## Commands (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 | (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`) | +| `/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.vars import Var # All env config +from Thunder.utils.logger import logger, redact_secrets # leveled log + token/Mongo redaction (H10) +from Thunder.utils.database import db # AsyncMongoClient singleton, timeoutMS=5000 +from Thunder.utils.safe_call import tg_call # FloodWait-safe RPC + wrappers (H4a) +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 # 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()`) -- Naming: PascalCase classes, snake_case functions/vars, UPPER_SNAKE_CASE constants +- PEP 8, 4-space indent; ruff (E,F,W,I,UP,B,SIM) in CI +- Import order: stdlib → third-party → local; all I/O async (`asyncio.to_thread` for blocking) +- **Never `try/except FloodWait`**: use `tg_call(...)` / `reply_safe` / `send_safe` / `edit_safe` / `delete_safe` / `answer_safe`. Allowed inline: `custom_dl.py` streaming/resume, `rate_limiter.py` worker-requeue, `broadcast.py` classify-only catch, `safe_call.py` itself, the server 503 ladder, `rate_limiter.py` notification catch-and-log. +- Budgets: Mongo `timeoutMS=5000`, TG RPC `TG_RPC_TIMEOUT_SECONDS` (transfers unbounded), shortener/keepalive 10 s +- `html.escape()` all user strings in HTML (M7); fail-closed deny with `MSG_ERROR_TEMP` (H7) +- Owner handlers: `filters.user(Var.OWNER_ID)`; PascalCase / snake_case / UPPER_SNAKE_CASE + +## Access gates (M12 order: banned → private-mode → token; force-sub where applicable; shortener-status is routing, not a gate) + +- Owner bypasses everything, including force-sub; authorized users bypass private-mode + token, but not ban or force-sub. +- `/start` runs only `banned + private-mode` (activation stays reachable). +- `PRIVATE_MODE=True` restricts the whole bot to owner + authorized users. +- No-sender messages (channel posts, anonymous admins) are DENIED wherever a gate is active — fail-closed, never bypass. +- New gate = one `PREFLIGHT_GATES` entry + 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 (`priority_queue` first); queue/wait-estimate UX is protected. +- Charge-at-exec (H6b); FloodWait requeues (max 5); RPS breaker burst 2×, dry requeues (H6c); bounded + swept 5 min (H6a). + +## URL families + +- Canonical `/f/<32-hex>/`, `/watch/f/<32-hex>/` (L4); legacy `/watch/<6-char-hash>/` valid, `ENABLE_LEGACY_LINKS` off → 410 (default on). 20- and 32-hex validate side-by-side forever; legacy pages cached. ## 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` (+ optional `config.env.local` overrides). +Required: `API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `OWNER_ID` (boot refuses), `DATABASE_URL`. +New env vars ship a safe default + annotated sample 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` (10 MiB × 5; `/log` redacted); `GET /health` (dep-free); `GET /status` (no-store: DC, inflight, touch stats); admin `/stats` (limiter). +- CI bar: ruff, mypy, pytest, pip-audit, bandit, vulture, dependency-count — all green. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6cf5729 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing + +## Setup + +```bash +# install uv (https://docs.astral.sh/uv/getting-started/installation/), then: +uv sync --frozen --group dev # exact locked env: runtime + dev tools +cp config_sample.env config.env # fill in your values +pre-commit install # optional: same ruff hooks CI runs, before each commit +``` + +## Workflow + +1. Branch from `main` (`feat/...`, `fix/...`, `refactor/...`). +2. `make format 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 7 direct deps. +6. Touching `pyproject.toml` or `uv.lock`? Run `make lock` -- the same drift + gates CI enforces (uv.lock, requirements.txt, requirements.lock). + +## Commands + +- `make format` — ruff autofix + format +- `make lint` — ruff + mypy (blocking: 0 errors expected) +- `make test` — unit tier +- `make integration` — real-Mongo tier (needs Docker) +- `make lock` — dependency drift gates +- `make audit` — pip-audit + bandit + vulture + dependency-count diff --git a/Dockerfile b/Dockerfile index 5e7ede2..768ee3c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,31 @@ -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@sha256:9d2e5553305c7c7b0097999bb17187c69b921ccd6bc9d40e4bb5ebe652c00285 +# digest-pinned (multi-arch index); dependabot bumps the tag+digest pair + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +# 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 + +# 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 . + +# no `pip install --upgrade pip`: it would be the sole unhashed install; +# 3.13-slim ships a pip that fully supports --require-hashes +RUN pip install --no-cache-dir --require-hashes -r requirements.lock + +COPY --chown=thunder:thunder . . + +RUN mkdir -p /app Thunder/logs && chown -R thunder:thunder /app + +# run as non-root +USER thunder + +# container health follows /health; PORT comes from the environment +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 CMD python3 -c "import os,urllib.request;urllib.request.urlopen('http://127.0.0.1:'+os.getenv('PORT','8080').split('#')[0].strip()+'/health',timeout=5)" + +CMD ["bash", "thunder.sh"] diff --git a/LICENSE b/LICENSE index 29f81d8..261eeb9 100644 --- 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/Makefile b/Makefile new file mode 100644 index 0000000..7eed856 --- /dev/null +++ b/Makefile @@ -0,0 +1,51 @@ +.PHONY: format lint test integration coverage lock audit run clean + +# Developer entry points (see CONTRIBUTING.md) +# 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 + uv run ruff format Thunder/ update.py tests/ + +lint: + uv run ruff check Thunder/ update.py tests/ + uv run ruff format --check Thunder/ update.py tests/ + uv run mypy Thunder update.py --ignore-missing-imports + +test: + uv run pytest -m unit --cov=Thunder --cov-report=term-missing --cov-fail-under=35 + +# CI parity for the opt-in tier (needs Docker): real MongoDB via testcontainers +integration: + TEST_INTEGRATION=1 uv run pytest -m integration + +coverage: + uv run pytest -m unit --cov=Thunder --cov-report=html + +audit: + uv run pip-audit + # -ll = high severity only + uv run bandit -c pyproject.toml -r Thunder update.py -ll --skip B101 + uv run vulture Thunder update.py --min-confidence 80 + @count=$$(grep -cE '^[a-zA-Z0-9_-]+==' requirements.txt); \ + echo "Direct runtime deps: $$count"; \ + if [ "$$count" -gt 7 ]; then \ + echo "ERROR: dependency count increased beyond 7; justify or remove."; \ + exit 1; \ + fi + +# same drift gates CI enforces; run after touching pyproject/uv.lock +lock: + uv lock --check + uv run --locked python -c "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('#')}; sys.exit('requirements.txt drifted from pyproject [project.dependencies]') if deps != listed else None" + uv export --frozen --no-dev --hashes -o /tmp/requirements.lock.check + grep -v '^#' requirements.lock > /tmp/requirements.lock.committed + grep -v '^#' /tmp/requirements.lock.check > /tmp/requirements.lock.exported + diff -u /tmp/requirements.lock.committed /tmp/requirements.lock.exported + +run: + uv run python -m Thunder + +clean: + 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/Procfile b/Procfile deleted file mode 100644 index a74dc6b..0000000 --- a/Procfile +++ /dev/null @@ -1 +0,0 @@ -web: python -m Thunder \ No newline at end of file diff --git a/README.md b/README.md index 78a722e..20fe6c1 100644 --- a/README.md +++ b/README.md @@ -1,582 +1,547 @@ -

- 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) +- [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 (channels require `CHANNEL=True` plus the bot as channel admin; channel posts are ignored under `PRIVATE_MODE` or for banned 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. + +> **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 | +| :--- | :--- | :--- | +| `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 + +Essential knobs are in the table above; everything else with safe defaults is documented once in config_sample.env (single source — do not duplicate values here). + +## 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. In groups the bot must be an admin. 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 (requires ENABLE_SHELL=True). | +| `/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 +``` + +
+ +## 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. + +> **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: + +```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. + +## 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 . +# 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 +``` + +
+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 # direct pins; Docker uses the hash-pinned requirements.lock + +# 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 (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" + ``` + +> **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`, `OWNER_ID`, `DATABASE_URL`) are filled in correctly. +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?** +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! See [CONTRIBUTING.md](CONTRIBUTING.md) for the full workflow — including the local quality gates (`make format lint test`) that CI enforces on every PR. + +## 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/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..32873eb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,15 @@ +# 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. +- HTTP access logs carry 8-hex path pseudonyms, never tokens or hashes. diff --git a/Thunder/__init__.py b/Thunder/__init__.py index 4e32f5d..cf3eaf7 100644 --- a/Thunder/__init__.py +++ b/Thunder/__init__.py @@ -1,6 +1,10 @@ -# Thunder/__init__.py - -import time - -StartTime = time.time() -__version__ = "2.1.0" +import os +import time + +StartTime = time.time() + +# Build-time injectable version -- Docker/PaaS may set APP_VERSION; +# the pyproject.toml [project] version is the default. Exposed by /status and /stats. +# NOTE: snapshots at import, so env-only (Docker -e / systemd) -- config.env +# loads later in vars.py and cannot set this. +__version__ = os.getenv("APP_VERSION", "2.2.0") diff --git a/Thunder/__main__.py b/Thunder/__main__.py index a6c5473..04a9b93 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -1,328 +1,392 @@ -# 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}") +import asyncio +import glob +import importlib.util +import os +import sys +import time +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 MessageNotModified + +from Thunder import __version__ +from Thunder.bot import StreamBot, work_loads +from Thunder.bot.clients import ( + cleanup_clients, + harden_session_files, + initialize_clients, +) +from Thunder.server import web_server +from Thunder.utils.canonical_files import drain_background_touch_tasks, touch_buffer_stats +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.logger import logger +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 + +PLUGIN_PATH = "Thunder/bot/plugins/*.py" +VERSION = __version__ + + +def print_banner(): + banner = f""" +╔═══════════════════════════════════════════════════════════════════╗ +║ ║ +║ ████████╗██╗ ██╗██╗ ██╗███╗ ██╗██████╗ ███████╗██████╗ ║ +║ ╚══██╔══╝██║ ██║██║ ██║████╗ ██║██╔══██╗██╔══╝██╔══██╗ ║ +║ ██║ ███████║██║ ██║██╔██╗ ██║██║ ██║█████╗ ██████╔╝ ║ +║ ██║ ██╔══██║██║ ██║██║╚██╗██║██║ ██║██╔══╝ ██╔══██╗ ║ +║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██████╔╝███████╗██║ ██║ ║ +║ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ║ +║ ║ +║ File Streaming Bot v{VERSION} ║ +╚═══════════════════════════════════════════════════════════════════╝ +""" + print(banner) + + +def schedule_index_ensure() -> asyncio.Task: + 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 asyncio.CancelledError: + return + except Exception as e: + 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(): + print("╠════════════════════ IMPORTING PLUGINS ════════════════════╣") + plugins = sorted(glob.glob(PLUGIN_PATH)) # deterministic registration order + 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} | Failed: {len(failed_plugins)}") + if failed_plugins: + print(f" ▶ Failed plugins: {', '.join(failed_plugins)}") + + return success_count + + +async def start_services(): + start_time = time.monotonic() + background_tasks: list[asyncio.Task] = [] + print_banner() + print("╔════════════════ INITIALIZING BOT SERVICES ════════════════╗") + + print(" ▶ Starting Telegram Bot initialization...") + try: + # session bootstrap (connect + auth) legitimately outlives the + # 30s lightweight-RPC budget + await tg_call(StreamBot.start, timeout=90.0) + bot_info = await tg_call(StreamBot.get_me) + # 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 + background_tasks.append(schedule_index_ensure()) + harden_session_files() + + restart_message_data = await db.get_restart_message() + if restart_message_data: + edited_ok = True + try: + 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, + ) + except MessageNotModified: + pass # a previous boot already wrote it; still clear below + except Exception as e: + logger.error(f"Error processing restart message: {e}", exc_info=True) + edited_ok = False + if edited_ok: + try: + await db.delete_restart_message(restart_message_data["message_id"]) + except Exception as e: + logger.error(f"Error clearing restart marker: {e}", exc_info=True) + + except Exception as e: + logger.error(f" ✖ Failed to initialize Telegram Bot: {e}", exc_info=True) + # the index-ensure task may already be scheduled -- stop it against a + # closing client BEFORE db.close + await _cancel_tasks(background_tasks, timeout=10) + await _safe_teardown_step(StreamBot.stop, "bot (boot failure)") + await _safe_teardown_step(db.close, "database (boot failure)") + # a failed boot must exit non-zero or container restart policies never fire. + 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) + # index-ensure may be live already -- stop it before db.close + await _cancel_tasks(background_tasks, timeout=10) + await _safe_teardown_step(cleanup_clients, "clients (boot failure)") + await _safe_teardown_step(StreamBot.stop, "bot (boot failure)") + await _safe_teardown_step(db.close, "database (boot failure)") + raise SystemExit(1) from e + + await import_plugins() + + print(" ▶ Starting Request Executor initialization...") + try: + # worker pool; registered early so a later boot failure cancels live workers too + executor_tasks = start_executors() + background_tasks.extend(executor_tasks) + 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) + await _cancel_tasks(background_tasks, timeout=30) + await _safe_teardown_step(rate_limiter.shutdown, "rate limiter") + await _safe_teardown_step(drain_background_touch_tasks, "touch buffer") + await _safe_teardown_step(close_shortener, "shortener") + await _safe_teardown_step(cleanup_clients, "clients (boot failure)") + await _safe_teardown_step(StreamBot.stop, "bot (boot failure)") + await _safe_teardown_step(db.close, "database (boot failure)") + raise SystemExit(1) from e + + print(" ▶ Starting Web Server initialization...") + try: + 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) + await site.start() + + 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) + # 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) + await _cancel_tasks(background_tasks, timeout=30) + # touch buffer must flush BEFORE db.close, or _bulk_flush discards increments + await _safe_teardown_step(rate_limiter.shutdown, "rate limiter") + await _safe_teardown_step(drain_background_touch_tasks, "touch buffer") + await _safe_teardown_step(close_shortener, "shortener") + await _safe_teardown_step(cleanup_clients, "clients") + await _safe_teardown_step(db.close, "database") + raise SystemExit(1) from e + + elapsed_time = time.monotonic() - start_time + 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.") + + try: + await idle() + finally: + # ordered teardown with a bounded drain + error aggregation + await shutdown_services(background_tasks, app_runner) + + +async def _cancel_tasks(tasks: list[asyncio.Task], timeout: float) -> None: + """Cancel + bounded wait; re-cancel stragglers so teardown always runs. + + ``asyncio.wait`` alone returns still-running tasks in ``pending`` -- without + the re-cancel a cancellation-ignoring task would skip every step below. + """ + for t in tasks: + t.cancel() + if tasks: + _done, pending = await asyncio.wait(tasks, timeout=timeout) + for t in pending: + t.cancel() + logger.warning(f"Background task {t.get_name()} ignored cancellation; re-cancelled") + + +async def _safe_teardown_step(step, name: str, errors: list | None = None): + try: + await asyncio.wait_for(step(), timeout=30) + except asyncio.CancelledError as e: + # shutdown completeness wins over cancellation propagation: record + # and continue with the remaining steps instead of aborting them + logger.warning(f"{name} cleanup cancelled: {e}") + if errors is not None: + errors.append((name, e)) + 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: + """Restart-marker-safe, bounded drain, aggregated errors.""" + print(" ▶ Shutting down services...") + errors: list = [] + + # 1. stop accepting new work + for task in background_tasks: + if not task.done(): + task.cancel() + + # one bounded wait for the whole batch (per-task waits would stack) + if background_tasks: + done, pending = await asyncio.wait(background_tasks, timeout=10) + for t in pending: + t.cancel() + logger.warning(f"Background task {t.get_name()} ignored cancellation; re-cancelled") + for t in done: + if t.cancelled(): + continue + exc = t.exception() + if exc is not None: + # worker/index failures are fatal; sweeper/keepalive/token + # blips must not force a container restart loop + if t.get_name().startswith(("request_executor", "ensure_")): + errors.append((t.get_name(), exc)) + logger.error(f"Background task {t.get_name()} failed at shutdown: {exc}") + + # 2. stop accepting new HTTP before draining in-flight streams + if app_runner is not None: + try: + await asyncio.wait_for(app_runner.cleanup(), timeout=30) + except Exception as e: + errors.append(("web server", e)) + logger.error(f"Error during web server cleanup: {e}") + + # 3. bounded drain: wait (<= 30 s) for in-flight streams to finish + 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: + logger.warning(f"Drain deadline hit with {remaining} stream(s) still active.") + + # 4. 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) + + await _safe_teardown_step(db.close, "database", errors) + if not errors: + print(" ✓ Database connection closed") + + logger.info(f"Touch buffer final state: {touch_buffer_stats()}") + + # 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(): + 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) + + +async def schedule_limiter_sweep(): + """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__": + # Restrictive umask covers session keys before harden_session_files runs; + # logs/ predates it, but log content is redacted. + os.umask(0o077) + try: + asyncio.run(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 index 1e28c42..fa38684 100644 --- 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( @@ -13,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 index 14cf307..e14d9cf 100644 --- a/Thunder/bot/clients.py +++ b/Thunder/bot/clients.py @@ -1,82 +1,90 @@ -# 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") +import asyncio +import glob +import os + +from pyrogram import Client + +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: + """Session files hold credentials; chmod 0600 best-effort.""" + 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: + # short per-client budget: the outer teardown bounds the total, + # and a hung stop must not eat the whole budget for the rest + await tg_call(client.stop, timeout=10.0) + 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: + 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, + ) + # session bootstrap may exceed the 30s RPC budget. + await tg_call(client.start, timeout=90.0) + 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)) + + harden_session_files() + + if len(multi_clients) > 1: + 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 index 82d0e72..a30e168 100644 --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -1,529 +1,559 @@ -# 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 - +import asyncio +import contextlib +import html +import os +import time +from io import BytesIO +from pathlib import Path +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, User + +from Thunder import StartTime, __version__ +from Thunder.bot import StreamBot, multi_clients, work_loads +from Thunder.bot.clients import cleanup_clients +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 +from Thunder.utils.human_readable import humanbytes +from Thunder.utils.logger import LOG_FILE, logger, redact_secrets +from Thunder.utils.messages import ( + MSG_ADMIN_AUTH_LIST_HEADER, + MSG_ADMIN_AUTH_OWNER_FOOTER, + 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_CANNOT_BAN_SELF, + 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_SIZED, + 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_CAPTION, + 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.vars import Var + owner_filter = filters.private & filters.user(Var.OWNER_ID) -_MARKDOWN_ESCAPE_TRANS = str.maketrans({ - "\\": "\\\\", - "_": "\\_", - "*": "\\*", - "[": "\\[", - "]": "\\]", - "`": "\\`", -}) - - -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) - +# /log tail cap +_LOG_TAIL_BYTES = 5 * 1024 * 1024 + + +def _invalidate_gates() -> None: + """Flush flag cache so admin changes apply immediately.""" + 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")]] + ), + ) + 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: + # code spans are literal under MARKDOWN: only neutralize backticks, + # an html.escape here would leak "<" into the span + 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()) + bot_username = getattr(getattr(client, "me", None), "username", None) or "?" + status_text_str = MSG_SYSTEM_STATUS.format( + uptime=uptime_str, + bot_username=bot_username, + 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) + + disk = await asyncio.to_thread(psutil.disk_usage, ".") + total_disk, used_disk, free_disk = disk.total, disk.used, disk.free + # psutil off the event loop. + disk_percent = disk.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, + 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=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), + 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")]] + ), + ) + 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) + # drain touch buffer here; execv skips finally blocks. + from Thunder.utils.canonical_files import drain_background_touch_tasks + + await drain_background_touch_tasks() + # Abbreviated teardown: execv replaces the process without running the full + # graceful shutdown, so stop the clients + DB best-effort first. + # Bounded: a hung RPC must never wedge the restart. + for step, name in ((cleanup_clients, "clients"), (db.close, "database")): + try: + await asyncio.wait_for(step(), timeout=10) + except Exception as e: + logger.warning(f"Restart teardown: {name} cleanup incomplete: {e}") + # absolute path: exec'ing into thunder.sh must work from any cwd + script = Path(__file__).resolve().parents[3] / "thunder.sh" + os.execv("/bin/bash", ["bash", str(script)]) + + +@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: + # capped redacted tail; IO off the event loop. + 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" + caption = MSG_LOG_FILE_CAPTION_SIZED.format( + tailed=humanbytes(len(doc.getvalue())), + total=humanbytes(os.path.getsize(LOG_FILE)), + ) + await tg_call(message.reply_document, doc, caption=caption, retries=1) + 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) + 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: + 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) + 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: + 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) + + # escape display names; batched get_users avoids N+1 FloodWaits. + id_to_user: dict[int, Any] = {} + try: + all_ids = [u["user_id"] for u in users] + tg_users_all: list = [] + for i in range(0, len(all_ids), 200): + chunk = all_ids[i : i + 200] + tg_users = await tg_call(client.get_users, chunk, retries=1) + if isinstance(tg_users, User): + tg_users = [tg_users] + tg_users_all.extend(tg_users or []) + id_to_user = {u.id: u for u in tg_users_all 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 = _escape_markdown(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, 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"], + ) + + text += MSG_ADMIN_AUTH_OWNER_FOOTER.format(owner_id=Var.OWNER_ID) + + # long auth lists exceed the 4096-char message cap: page by lines + if len(text) <= 3500: + pages = [text] + else: + pages, current = [], "" + for line in text.split("\n"): + if len(current) + len(line) + 1 > 3500: + pages.append(current) + current = "" + current += line + "\n" + if current: + pages.append(current) + + for page in pages: + await reply( + message, + text=page, + parse_mode=ParseMode.HTML, + 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]) + # M7: store raw reason; escape at HTML render. + has_reason = len(message.command) > 2 + 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 message.from_user and target_id == message.from_user.id: + return await reply(message, text=MSG_CANNOT_BAN_SELF) + + if target_id < 0: + 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 has_reason: + text += MSG_CHANNEL_BANNED_REASON_SUFFIX.format(reason=html.escape(reason)) + await reply(message, text=text, parse_mode=ParseMode.HTML) + try: + 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) + _invalidate_gates() + text = MSG_ADMIN_USER_BANNED.format(user_id=target_id) + if has_reason: + text += MSG_BAN_REASON_SUFFIX.format(reason=html.escape(reason)) + await reply(message, text=text, parse_mode=ParseMode.HTML) + try: + 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) + + 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): + _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: + 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 + ) + 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): + # /shell is opt-in. + if not Var.ENABLE_SHELL: + 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) + + command = " ".join(message.command[1:]) + # log who ran what. + 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 ) - - 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}" + + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=60) + except TimeoutError: + with contextlib.suppress(ProcessLookupError): + 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")) + ) + 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 delete_safe(status_msg) + except Exception: + pass + + if len(output) > 4096: + file = BytesIO(output.encode()) + file.name = "shell_output.txt" + await tg_call( + message.reply_document, + file, + caption=MSG_SHELL_OUTPUT_CAPTION.format(command=html.escape(command)), + retries=1, + ) + else: + await reply(message, text=output, parse_mode=ParseMode.HTML) + + except Exception as e: + try: + 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, + ) diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py index ab6d71a..5d91e11 100644 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -1,223 +1,203 @@ -# 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) +import functools +import secrets + +from pyrogram import Client, enums, filters +from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup + +from Thunder.bot import StreamBot +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, + 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_ERROR_UNEXPECTED, +) +from Thunder.utils.safe_call import answer_safe, delete_safe, edit_safe +from Thunder.vars import Var + + +def guard_callback(fn): + """Panic isolation for callbacks; answers query so no stale 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, MSG_ERROR_UNEXPECTED, 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: + 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 + + +@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 = build_help_text(Var.MAX_BATCH_FILES) + try: + await edit_safe( + callback_query.message, + help_text, + reply_markup=InlineKeyboardMarkup(buttons), # type: ignore[arg-type] + disable_web_page_preview=True, + ) + except Exception as e: + 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 edit_safe( + callback_query.message, + MSG_ABOUT, + reply_markup=InlineKeyboardMarkup(buttons), # type: ignore[arg-type] + disable_web_page_preview=True, + ) + except Exception as e: + 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 + 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 edit_safe( + callback_query.message, + MSG_ERROR_BROADCAST_INSTRUCTION, + reply_markup=InlineKeyboardMarkup(buttons), # type: ignore[arg-type] + disable_web_page_preview=True, + ) + except Exception as e: + 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): + closer_id = callback_query.from_user.id if callback_query.from_user else None + message = callback_query.message + + is_allowed = False + if closer_id == Var.OWNER_ID: + is_allowed = True + elif closer_id is not None and message is not None: + 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 + + await answer_safe(callback_query) + if message: + try: + await delete_safe(message) + except Exception as e: + logger.debug( + f"Failed to delete callback query message {getattr(message, 'id', '?')}: {e}" + ) + + +@StreamBot.on_callback_query(filters.regex(r"^cancel_")) +@guard_callback +async def cancel_broadcast(client: Client, callback_query: CallbackQuery): + if not await owner_only(client, callback_query): + return + raw = callback_query.data or "" + if isinstance(raw, bytes): + # stub-typed str|bytes|None; decode defensively, never crash the panel + raw = raw.decode("utf-8", errors="replace") + parts = raw.split("_", 1) + broadcast_id = parts[1] if len(parts) > 1 else "" + entry = broadcast_ids.get(broadcast_id) + if entry is not None: + entry["cancelled"] = True + try: + 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): + """Catch-all for unknown/stale buttons.""" + 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 2b6344d..88a1bc9 100644 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -1,280 +1,312 @@ -# 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 +import html +import time + +from pyrogram import Client, filters +from pyrogram.enums import ParseMode +from pyrogram.errors import MessageNotModified +from pyrogram.types import ( + InlineKeyboardButton, + InlineKeyboardButtonBuy, + 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.commands import build_help_text +from Thunder.utils.decorators import GATES_START, preflight +from Thunder.utils.file_properties import get_fname, get_fsize, parse_fid +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 ( + 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_LINK_PRIVATE_HINT, + MSG_PING_RESPONSE, + MSG_PING_START, + MSG_TOKEN_ACTIVATED, + MSG_TOKEN_FAILED, + MSG_WELCOME, +) +from Thunder.utils.safe_call import edit_safe, reply_safe +from Thunder.utils.tokens import consume +from Thunder.vars import Var + + +@StreamBot.on_message(filters.command("start") & filters.private) +async def start_command(bot: Client, msg: Message): + if await preflight(bot, msg, gates=GATES_START) is None: + 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" and user is not None and Var.TOKEN_ENABLED: + 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." + ), + parse_mode=ParseMode.HTML, + ) + if status == "already": + return await reply_safe( + msg, + text=MSG_TOKEN_FAILED.format(reason="Token has already been activated."), + parse_mode=ParseMode.HTML, + ) + if status == "ok": + return await reply_safe( + msg, + text=MSG_TOKEN_ACTIVATED.format(duration_hours=hours), + parse_mode=ParseMode.HTML, + ) + # no button here: payload is a consumed/foreign/unknown token; the + # "click the button below" copy of MSG_TOKEN_INVALID would be a dead end + return await reply_safe( + msg, + text=MSG_TOKEN_FAILED.format(reason="The token is invalid or has expired."), + parse_mode=ParseMode.HTML, + ) + + txt = MSG_WELCOME.format( + user_name=html.escape(user.first_name or "Unknown") if user else "Unknown", + max_files=Var.MAX_BATCH_FILES, + ) + link, title = await get_force_info(bot) + if link: + txt += "\n\n" + MSG_COMMUNITY_CHANNEL.format(channel_title=html.escape(title or "Channel")) + + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [ + 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)] + ) + + await reply_safe( + msg, text=txt, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns) + ) + + +@StreamBot.on_message(filters.command("help") & filters.private) +async def help_command(bot: Client, msg: Message): + if await preflight(bot, msg, gates=GATES_START) is None: + return + if msg.from_user: + await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) + + txt = build_help_text(Var.MAX_BATCH_FILES) + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [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")]) + await reply_safe( + msg, text=txt, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns) + ) + + +@StreamBot.on_message(filters.command("about") & filters.private) +async def about_command(bot: Client, msg: Message): + if await preflight(bot, msg, gates=GATES_START) is None: + return + if msg.from_user: + await log_newusr(bot, msg.from_user.id, msg.from_user.first_name) + + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [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"), + ], + ] + + await reply_safe( + msg, text=MSG_ABOUT, parse_mode=ParseMode.HTML, reply_markup=InlineKeyboardMarkup(btns) + ) + + +@StreamBot.on_message(filters.command("link") & filters.private) +async def link_private_hint(bot: Client, msg: Message): + # gated like the other info commands: banned users see the ban notice, + # private-mode outsiders are denied, everyone else gets the hint + if await preflight(bot, msg, gates=GATES_START) is None: + return + await reply_safe(msg, text=MSG_LINK_PRIVATE_HINT, parse_mode=ParseMode.MARKDOWN) + + +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: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=url)], + [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")], + ] + await reply_safe( + msg, + text=txt, + parse_mode=ParseMode.HTML, + 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: int | str = MSG_DC_UNKNOWN + fid = parse_fid(file_msg) + if fid: + dc_id = fid.dc_id + + txt = MSG_DC_FILE_INFO.format( + file_name=html.escape(fname, quote=False), + file_size=fsize, + file_type=type_display, + dc_id=dc_id, + ) + + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")] + ] + await reply_safe( + msg, + text=txt, + parse_mode=ParseMode.HTML, + 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 await preflight(bot, msg, gates=GATES_START) is None: + return + from Thunder.utils.decorators import force_sub_gate + + 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) + + 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: + 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 await preflight(bot, msg, gates=GATES_START) is None: + return + from Thunder.utils.decorators import force_sub_gate + + if not await force_sub_gate(bot, msg): + return + start = time.time() + try: + sent = await reply_safe(msg, text=MSG_PING_START) + except Exception: + return + end = time.time() + ms = (end - start) * 1000 + + btns: list[list[InlineKeyboardButton | InlineKeyboardButtonBuy]] = [ + [ + InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"), + InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel"), + ] + ] + + try: + await edit_safe( + sent, + MSG_PING_RESPONSE.format(time_taken_ms=ms), + reply_markup=InlineKeyboardMarkup(btns), + 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 02c3368..0eb5b14 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -1,356 +1,353 @@ -# 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) +import asyncio +import html +import secrets +import time +from typing import Any + +from pyrogram import Client, enums, filters +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.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.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, + 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_BATCH_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 +_BATCH_DEADLINE_BASE = 30 + + +async def fwd_media(m_msg: Message) -> Message | None: + try: + 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: + 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 + 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): + 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: + """One preflight chain for every stream entry point. + + Runtime order: banned → private → token → shortener-status (routing) → force-sub. + """ + shortener_val = await preflight(client, message) + if shortener_val is None: + return None + from Thunder.utils.decorators import force_sub_gate + + if not await force_sub_gate(client, message): + return None + 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=html.escape(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( + await tg_call( + target_msg.reply_text, text, disable_web_page_preview=True, - quote=True + quote=True, + parse_mode=enums.ParseMode.HTML, ) 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, + parse_mode=enums.ParseMode.HTML, ) - 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'] + except Exception as e: + logger.error(f"Error sending channel links: {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=html.escape(chat_title)) + + "\n" + + format_link_message(links) ) - if target_msg: - await target_msg.reply_text( - text, - disable_web_page_preview=True, - quote=True + 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]): + 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) +async def link_handler(bot: Client, msg: Message, **kwargs): + 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: + 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" # type: ignore[union-attr] + try: + 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)]] + ), + ) + 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): + 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 or message.caption or "").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 reply_safe(message, MSG_PROCESSING_REQUEST) + except Exception as e: + logger.error(f"Could not send processing status: {e}", exc_info=True) + return + if num_files == 1: + await process_single( + client, + message, + message.reply_to_message, + status_msg, + shortener_val, + notification_msg=notification_msg, ) 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 + 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 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, +) +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 + # Fail closed: channels have no from_user for gates. + 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 = message.chat.id in Var.BANNED_CHANNELS + # Fail-open is deliberate: leave_chat is irreversible. + is_banned_dynamically = ( + await flags.get_or_load( + ("banned_channel", message.chat.id), + lambda: db.is_channel_banned(message.chat.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 - + is not None + ) + + if is_banned_statically or is_banned_dynamically: + try: + 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." + ) + return + try: + from Thunder.utils.decorators import get_shortener_status + shortener_val = await get_shortener_status(client, message) - canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(message, fwd_media) + 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) + await delete_safe(stored_msg) stored_msg = None if canonical_record: links = await gen_canonical_links( 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,46 +355,36 @@ 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 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. 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=html.escape(source_info), + id_=message.chat.id, + online_link=links["online_link"], + stream_link=links["stream_link"], + ), + disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML, + ) 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,58 +392,67 @@ 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: + 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." ) - - 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 + 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 | None, + shortener_val: bool, + 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) + await delete_safe(stored_msg) stored_msg = None if canonical_record: links = await gen_canonical_links( 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: @@ -464,205 +460,237 @@ async def process_single( stored_msg = await fwd_media(file_msg) if not stored_msg: logger.error(f"Failed to forward media for message {file_msg.id}. Skipping.") + # unstick the status message: without this the user's chat + # keeps saying "Processing your file..." forever + if status_msg: + try: + await edit_safe(status_msg, MSG_ERROR_PROCESSING_MEDIA) + except Exception: + pass 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: + try: + await edit_safe( + notification_msg, + format_link_message(links), + parse_mode=enums.ParseMode.HTML, + disable_web_page_preview=True, + reply_markup=get_link_buttons(links), + ) + except (MessageNotModified, MessageDeleteForbidden, MessageIdInvalid): + pass + except Exception: + 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 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 + await send_channel_links( + links, + source_info, + source_id, + target_msg=stored_msg, + reply_to_message_id=canonical_reply_id, + ) + if status_msg: + await delete_safe(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 edit_safe(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: Message | None = None, +): + """Worker-pooled batch, order-preserving. + + Stops STARTING new items after deadline (30+2n); in-flight unbounded. + """ + total_started = time.monotonic() + deadline = total_started + _BATCH_DEADLINE_BASE + 2 * count + 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] = {} + skipped = 0 + counters = {"done": 0, "failed": 0} + + # ---- pre-fetch phase (chunked) ---- + fetched: dict[int, Message | None] = {} + fetch_failed: set[int] = set() + for chunk_start in range(0, count, BATCH_SIZE): + if time.monotonic() > deadline: + break + chunk_ids = ids[chunk_start : chunk_start + BATCH_SIZE] + try: + 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) + fetch_failed.update(chunk_ids) + messages = [] + for i, mid in enumerate(chunk_ids): + m = messages[i] if i < len(messages) else None + if i >= len(messages): + fetch_failed.add(mid) + fetched[mid] = None + else: + 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: + mid = await queue.get() + if mid is None: + return + if time.monotonic() > deadline: + # deadline-expired counts as failed, not skipped. + results[mid] = None + counters["failed"] += 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 ) - 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 + 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() + + try: + await edit_safe(status_msg, MSG_PROCESSING_BATCH.format(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) + + failed = counters["failed"] + processed = sum(1 for r in results.values() if r) + + links_list = [rec["online_link"] for mid in ids if (rec := results.get(mid))] + chunks = [ + links_list[i : i + LINK_CHUNK_SIZE] for i in range(0, len(links_list), LINK_CHUNK_SIZE) + ] + dm_failed_chunks = 0 + for idx, chunk in enumerate(chunks): + chunk_text = ( + MSG_BATCH_LINKS_READY.format(count=len(chunk)) + + f"\n\n{chr(10).join(chunk)}" + ) + try: + 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: + await send_safe( + bot, + msg.from_user.id, + text=MSG_DM_BATCH_PREFIX.format( + chat_title=html.escape(msg.chat.title or "the chat") + ) + + "\n" + + chunk_text, + disable_web_page_preview=True, + parse_mode=enums.ParseMode.HTML, ) - 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) + except Exception as e: + logger.error(f"Error sending DM in batch: {e}", exc_info=True) + dm_failed_chunks += 1 + if idx + 1 < len(chunks): + await asyncio.sleep(MESSAGE_DELAY) + + if dm_failed_chunks: + await reply_user_err( + msg, + MSG_ERROR_DM_BATCH_FAILED.format( + failed_chunks=dm_failed_chunks, total_chunks=len(chunks) + ), + ) + + try: + await edit_safe( + status_msg, + MSG_PROCESSING_RESULT.format( + processed=processed, + total=count, + skipped=skipped, + failed=failed, + ), + ) + except MessageNotModified: + pass + except Exception as e: + logger.debug(f"Could not finalize batch status: {e}") + if notification_msg: + await delete_safe(notification_msg) diff --git a/Thunder/bot/registry.py b/Thunder/bot/registry.py new file mode 100644 index 0000000..540585e --- /dev/null +++ b/Thunder/bot/registry.py @@ -0,0 +1,60 @@ +"""Single command registry: drives menu, /help, and AGENTS.md (tested).""" + +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 + + +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 redacted 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.""" + return [ + BotCommand(cmd.name, cmd.description[:_MAX_DESC_LEN]) + for cmd in COMMANDS + if not cmd.owner_only + ] + + +def help_command_rows() -> str: + """/help surface: same public commands, same order.""" + rows = "" + for cmd in COMMANDS: + if cmd.owner_only: + 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/server/__init__.py b/Thunder/server/__init__.py index ab450ee..96a6e5e 100644 --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -1,10 +1,74 @@ -# 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 +import re +import time + +from aiohttp import web + +from Thunder.utils.logger import hash_path_token, logger + +from .stream_routes import routes + +# access log middleware -- method, redacted path, status, bytes, duration. + + +# 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. +# The (?=/|$) tail anchor also covers the no-filename legacy shape +# (/AbCdEf12345), whose bare hash+id is the whole link credential. +_PSEUDONYM_RE = re.compile( + r"(?P(?<=/f/)(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{20}))" + 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 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: + return _PSEUDONYM_RE.sub(_pseudonymize, path) + + +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 +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: + response = e + raise + finally: + duration_ms = (time.perf_counter() - start) * 1000 + try: + # 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( + 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: + pass + return response + + +async def web_server(): + # 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/exceptions.py b/Thunder/server/exceptions.py index b25ef7e..871d024 100644 --- a/Thunder/server/exceptions.py +++ b/Thunder/server/exceptions.py @@ -1,7 +1,13 @@ -# Thunder/server/exceptions.py - -class InvalidHash(Exception): - pass - -class FileNotFound(Exception): - pass +class InvalidHash(Exception): + pass + + +class FileNotFound(Exception): + """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 febfd11..23e64ad 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -1,22 +1,28 @@ -# Thunder/server/stream_routes.py - +import contextlib import re import secrets import time -from urllib.parse import quote, unquote +from collections.abc import Mapping +from urllib.parse import quote, quote_plus, unquote from aiohttp import web +from pymongo.errors import PyMongoError +from pyrogram.errors import FloodWait +from pyrogram.types import Message -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.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, PUBLIC_HASH_LENGTH, + forget_stale_record, get_file_by_hash, + touch_buffer_stats, update_cached_file_id, ) -from Thunder.utils.custom_dl import ByteStreamer +from Thunder.utils.custom_dl import CHUNK_SIZE, ByteStreamer from Thunder.utils.file_properties import get_media from Thunder.utils.logger import logger from Thunder.utils.render_template import render_media_page, render_page @@ -25,17 +31,22 @@ routes = web.RouteTableDef() +# Legacy 6-char capability hash family (kept while ENABLE_LEGACY_LINKS=on) SECURE_HASH_LENGTH = 6 -CHUNK_SIZE = 1024 * 1024 -MAX_CONCURRENT_PER_CLIENT = 8 +# 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+)(?:/.*)?$") +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_-]+$") +# 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": "*", @@ -44,7 +55,17 @@ "Access-Control-Expose-Headers": "Content-Length, Content-Range, Content-Disposition", } -streamers = {} +# Every error response carries CORS (readable browser errors) + no-store +# (a cached failure must never outlive the underlying problem). +_ERROR_HEADERS = {**CORS_HEADERS, "Cache-Control": "no-store"} +_RETRY_5 = {**_ERROR_HEADERS, "Retry-After": "5"} + + +def _unavailable(text: str) -> web.HTTPServiceUnavailable: + return web.HTTPServiceUnavailable(text=text, headers=_RETRY_5) + + +streamers: dict[int, "ByteStreamer"] = {} def get_streamer(client_id: int) -> ByteStreamer: @@ -53,56 +74,56 @@ def get_streamer(client_id: int) -> ByteStreamer: return streamers[client_id] -def parse_media_request(path: str, query: dict) -> tuple[int, str]: - clean_path = unquote(path).strip('/') +def parse_media_request(path: str, query: Mapping[str, str]) -> tuple[int, str]: + clean_path = unquote(path).strip("/") + # ids are regex-derived \d+; the guards below only catch absurd 4300+ + # digit ids against int's max_str_digits limit 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)): - return message_id, secure_hash - except ValueError as e: - raise InvalidHash(f"Invalid message ID format in path: {e}") from e + except ValueError: + raise InvalidHash("Invalid message ID format in path") from None + secure_hash = match.group(1) + if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash): + return message_id, secure_hash match = PATTERN_ID_FIRST.match(clean_path) if match: 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)): - return message_id, secure_hash - else: - raise InvalidHash("Invalid or missing hash in query parameter") - except ValueError as e: - raise InvalidHash(f"Invalid message ID format in path: {e}") from e + except ValueError: + raise InvalidHash("Invalid message ID format in path") from None + secure_hash = query.get("hash", "").strip() + if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash): + return message_id, secure_hash + raise InvalidHash("Invalid or missing hash in query parameter") raise InvalidHash("Invalid URL structure or missing hash") 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 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."), - headers=CORS_HEADERS, + raise web.HTTPServiceUnavailable( + text=("No available clients to handle the request. Please try again later."), + headers={**_ERROR_HEADERS, "Retry-After": "2"}, ) 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: + # 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( @@ -111,7 +132,7 @@ def select_optimal_client() -> tuple[int, ByteStreamer]: f"({load_range} active streams). Please retry shortly." ), headers={ - **CORS_HEADERS, + **_ERROR_HEADERS, "Retry-After": str(OVERLOAD_RETRY_AFTER_SECONDS), }, ) @@ -125,37 +146,64 @@ 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: + """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 match = RANGE_REGEX.fullmatch(range_header) if not match: - raise web.HTTPBadRequest(text=f"Invalid range header: {range_header}") + # 400 (not RFC 9110's "ignore") is deliberate: structurally broken + # or multi-range requests are hand-crafted, and an explicit signal + # beats silently shipping the whole body. Constant body: the header + # value is attacker-controlled and must not be reflected. + raise web.HTTPBadRequest(text="Invalid range header", headers=_ERROR_HEADERS) start_str = match.group("start") end_str = match.group("end") if start_str: start = int(start_str) end = int(end_str) if end_str else file_size - 1 + # 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: - raise web.HTTPBadRequest(text=f"Invalid range header: {range_header}") + raise web.HTTPBadRequest(text="Invalid range header", headers=_ERROR_HEADERS) suffix_len = int(end_str) if suffix_len <= 0: raise web.HTTPRequestRangeNotSatisfiable( - headers={"Content-Range": f"bytes */{file_size}"}) + headers={**_ERROR_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: + if start >= file_size or start > end: + # 416 discipline with Content-Range raise web.HTTPRequestRangeNotSatisfiable( - headers={"Content-Range": f"bytes */{file_size}"} + headers={**_ERROR_HEADERS, "Content-Range": f"bytes */{file_size}"} ) return start, end +def _resolve_filename(file_info: dict, mime_type: str) -> str: + filename = file_info.get("file_name") + 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 = ext_map.get(ext, ext) + 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: @@ -163,15 +211,81 @@ def _resolve_unique_id(file_info: dict) -> str: return unique_id -def _resolve_filename(file_info: dict, mime_type: str) -> str: - filename = file_info.get("file_name") - if filename: - return filename +async def _fetch_file_record(secure_hash: str) -> dict | None: + """Canonical-record lookup with brownout mapping: a DB transport + failure is NOT absence -- 503 + Retry-After so clients/CDNs retry + instead of caching a permanent-looking 404.""" + try: + return await get_file_by_hash(secure_hash) + except (PyMongoError, TimeoutError) as e: + logger.error(f"Canonical record lookup failed (transport): {e}", exc_info=True) + raise _unavailable("File index temporarily unavailable; please retry shortly.") from e - 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}" + +@contextlib.asynccontextmanager +async def _route_ladder(label: str): + """Shared outer ladder: client errors -> 404, Telegram unavailability + (FloodWait/transport, P2-6) -> uniform 503 + Retry-After, HTTP + pass-through, the rest -> error-id 500 (no internals in the body).""" + try: + yield + except (InvalidHash, FileNotFound) as e: + logger.debug(f"{label}: {type(e).__name__} - {e}") + raise web.HTTPNotFound( + text="Resource not found", + headers=_ERROR_HEADERS, + ) from e + except (TelegramUnavailable, FloodWait) as e: + logger.warning(f"{label}: Telegram unavailable ({type(e).__name__})") + raise _unavailable("Telegram is temporarily unavailable; please retry shortly.") from e + except web.HTTPException as e: + logger.warning(f"HTTP exception in {label}: {e}") + raise + except Exception as e: + error_id = secrets.token_hex(6) + logger.error(f"{label} error {error_id}: {e}", exc_info=True) + raise web.HTTPInternalServerError( + text=f"An unexpected server error occurred: {error_id}", + headers=_ERROR_HEADERS, + ) from e + + +@contextlib.asynccontextmanager +async def _admission_ladder(client_id: int, label: str): + """Shared post-admission ladder: the slot is released on every exit + path; Telegram unavailability maps to 503 + Retry-After with a constant + body (the exception text carries internal state).""" + try: + yield + except (FileNotFound, InvalidHash): + work_loads[client_id] -= 1 + raise + except TelegramUnavailable as e: + work_loads[client_id] -= 1 + logger.warning(f"{label}: Telegram unavailable: {e}") + raise _unavailable("Telegram is temporarily unavailable; please retry shortly.") from e + except FloodWait as e: + # defense in depth: get_message converts exhaustions already, but any + # raw FloodWait reaching admission must shed as 503, never 500 + work_loads[client_id] -= 1 + logger.warning(f"{label}: Telegram FloodWait: {e.value}s") + raise _unavailable("Telegram is temporarily unavailable; please retry shortly.") from e + except web.HTTPException as e: + work_loads[client_id] -= 1 + logger.debug(f"Client HTTP error in {label}: {e}") + raise + except Exception as e: + work_loads[client_id] -= 1 + error_id = secrets.token_hex(6) + logger.error(f"{label} error {error_id}: {e}", exc_info=True) + raise web.HTTPInternalServerError( + text=f"Server error during streaming: {error_id}", headers=_ERROR_HEADERS + ) from e + except BaseException: + # cancellation (client disconnect) must still release the slot, + # or abandoned tickets pile up into false 503s + work_loads[client_id] -= 1 + raise async def _serve_media_response( @@ -180,11 +294,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 | Message, ): - 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 +307,30 @@ 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", + # sandbox: attacker-uploaded SVG/HTML served inline must not execute + # in site origin; players fetch bytes cross-origin, unaffected + "Content-Security-Policy": "sandbox", } 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 +341,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 +363,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,183 +372,240 @@ async def root_redirect(request): raise web.HTTPFound("https://github.com/fyaz05/FileToLink") +@routes.get("/health", allow_head=True) +async def health_endpoint(request): + """Zero-dependency liveness endpoint (keepalive now targets this).""" + return web.json_response( + {"status": "ok"}, + headers={"Cache-Control": "no-store"}, + ) + + +_ACTIVATION_TOKEN_RE = re.compile(r"[A-Za-z0-9_-]{43}") + + +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)) + + +def _telegram_activate_url(username: str, token: str) -> str: + """Build the t.me deep link with the token percent-encoded. + + ``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 "https://t.me/" + username + "?start=" + quote_plus(token, safe="") + + +@routes.get("/activate/{token}") +async def activate_endpoint(request: web.Request): + """Web entry for activation -- shorteners can produce real URLs.""" + token = request.match_info.get("token", "").strip() + username = getattr(StreamBot, "username", None) + if not token or not _is_activation_token(token): + raise web.HTTPBadRequest(text="Malformed activation token", headers=_ERROR_HEADERS) + if not username: + raise web.HTTPServiceUnavailable( + text="Bot is still starting; try again shortly.", + headers=_RETRY_5, + ) + raise web.HTTPFound( + _telegram_activate_url(username, token), + headers={"Cache-Control": "no-store"}, + ) + + @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) + "username": f"@{getattr(StreamBot, 'username', None) or 'unknown'}", + "active_clients": len(multi_clients), + "dc_id": dc_id, }, "resources": { "total_workload": total_load, - "workload_distribution": workload_distribution - } + "workload_distribution": workload_distribution, + "touch_buffer": touch_buffer_stats(), + }, + }, + headers={ + # status is dynamic -- never serve it from cache. CORS stays so + # browser dashboards can read it; nothing here is secret. + "Access-Control-Allow-Origin": "*", + "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" - }) - - @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) async def canonical_media_preview(request: web.Request): - try: + async with _route_ladder("canonical preview"): secure_hash = validate_public_hash(request.match_info["secure_hash"]) - file_record = await get_file_by_hash(secure_hash, raise_on_error=False) + file_record = await _fetch_file_record(secure_hash) if not file_record: raise FileNotFound("Canonical file not found") 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, + mime_type=file_record.get("mime_type"), + size_bytes=file_record.get("file_size") or None, + ) 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", - } + # player pages are per-file dynamic; keep them unindexed + "X-Robots-Tag": "noindex, nofollow", + }, ) response.enable_compression() return response - except (InvalidHash, FileNotFound) as e: - logger.debug(f"Canonical preview error: {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"Canonical preview error {error_id}: {e}", exc_info=True) - 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): - try: + # 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.", + headers=_ERROR_HEADERS, + ) + async with _route_ladder("preview"): 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, - 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) - 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 - @routes.get(r"/f/{secure_hash}/{name:.+}", allow_head=True) async def canonical_media_delivery(request: web.Request): - try: + async with _route_ladder("canonical stream"): secure_hash = validate_public_hash(request.match_info["secure_hash"]) - file_record = await get_file_by_hash(secure_hash, raise_on_error=False) + file_record = await _fetch_file_record(secure_hash) if not file_record: raise FileNotFound("Canonical file not found") client_id, streamer = select_optimal_client() work_loads[client_id] += 1 - try: + async with _admission_ladder(client_id, "canonical stream"): _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 - ) + + # 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: + await forget_stale_record(file_record) + raise FileNotFound( + "Vault message missing; record self-healed, re-upload to regenerate the link" + ) from None + # TelegramUnavailable (FloodWait/timeout/transport) is NOT proof the + # vault message is gone -- must not delete the record; the ladder + # maps it to 503. + + 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): + # 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}; " + "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 a canonical file: {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 - raise - except web.HTTPException as e: - work_loads[client_id] -= 1 - logger.debug(f"Client HTTP error in canonical stream: {e}") - raise - except Exception as e: - work_loads[client_id] -= 1 - 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 - 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 - except web.HTTPException as e: - logger.warning(f"HTTP exception in canonical stream: {e}") - raise - except Exception as e: - 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 @routes.get(r"/{path:.+}", allow_head=True) async def media_delivery(request: web.Request): - try: + # 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.", + headers=_ERROR_HEADERS, + ) + async with _route_ladder("media stream"): path = request.match_info["path"] message_id, secure_hash = parse_media_request(path, request.query) @@ -452,45 +613,19 @@ async def media_delivery(request: web.Request): work_loads[client_id] += 1 - try: - file_info = await streamer.get_file_info(message_id) + async with _admission_ladder(client_id, "media stream"): + # one vault fetch serves both the info derivation and the stream: + # passing the Message on means stream_file does not re-fetch + vault_message = await streamer.get_message(message_id) + file_info = streamer.get_file_info_sync(vault_message) 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=vault_message, ) - - except (FileNotFound, InvalidHash): - work_loads[client_id] -= 1 - raise - except web.HTTPException as e: - work_loads[client_id] -= 1 - logger.debug(f"Client HTTP error in media stream: {e}") - raise - 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) - raise web.HTTPInternalServerError( - 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) - raise web.HTTPNotFound(text="Resource not found") from e - except web.HTTPException as e: - logger.warning(f"HTTP exception in media stream: {e}") - raise - except Exception as e: - 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 diff --git a/Thunder/template/dl.html b/Thunder/template/dl.html deleted file mode 100644 index a0e3bb4..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/template/req.html b/Thunder/template/req.html index c538902..f4ec917 100644 --- a/Thunder/template/req.html +++ b/Thunder/template/req.html @@ -1,359 +1,406 @@ - - - - - - - {{ heading }} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
-
-

{{ file_name }}

-
- - - - - Loading... - - - - - - Loading... - -
-
- -
- - -
-
-
- - - - If video not playing - Use External Player -
- - - - -
-
- - - - - - - - -
- -
-
-
- Space - Play / Pause -
-
- - Seek -
-
- - Volume -
-
- F - Fullscreen -
-
- M - Mute -
-
- <> - Speed -
-
-
- - - -
-
- - -
- - - - - - - - + + + + + + + {{ heading }} + + + + + + + + + + + + + + + + + + + + + {% if kind == 'video' %} + + + {% elif kind == 'audio' %} + + + {% endif %} + + + + + + {% if kind == 'video' %} + + + + {% elif kind == 'audio' %} + + + + {% endif %} + + + + + + + + + + + + + + + + + +
+
+
+

{{ file_name }}

+
+ {% if size_formatted %} + + + + + {{ size_formatted }} + + {% endif %} + + + + + + + +
+
+ +
+ +
+
+ {% if kind == 'audio' %} + + {% endif %} + {% if kind in ('video', 'audio') %} +
+ + + + If video not playing + Use External Player +
+ {% endif %} + {% if kind == 'video' %} + + + + + {% elif kind == 'audio' %} + + + + + {% elif kind == 'image' %} +
+ {{ file_name }} +
+ {% else %} + +
+

This file type ({{ mime_type }}) can't be played in the browser.

+ Download +
+ {% endif %} + +
+
+ + + + + +
+
+
+
+ Space + Play / Pause +
+
+ + Seek +
+
+ + Volume +
+
+ F + Fullscreen +
+
+ M + Mute +
+
+ <> + Speed +
+
+
+ + +
+ + +
+ +
+ + + + + + + + + diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py index 10bf3c0..a804768 100644 --- a/Thunder/utils/bot_utils.py +++ b/Thunder/utils/bot_utils.py @@ -1,21 +1,26 @@ -# Thunder/utils/bot_utils.py - import asyncio -from typing import Any, Dict, Optional +import html +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 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.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_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,42 +29,63 @@ 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. + + ``media_name`` is user-controlled and MSG_LINKS is HTML -- escape it + so a crafted file name cannot inject markup into the link message. + """ + text = MSG_LINKS.format( + file_name=html.escape(str(links["media_name"])), + file_size=links["media_size"], + # shortener output is external data: escape it or entity parsing breaks + download_link=html.escape(str(links["online_link"])), + stream_link=html.escape(str(links["stream_link"])), + ) + 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) + ) + 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}" - 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 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}") - 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,72 +94,66 @@ 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_own(cli: Client, txt: str): + targets = [Var.OWNER_ID] + if isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0: + targets.append(Var.BIN_CHANNEL) + + async def _notify(chat_id: int) -> None: + try: + await send_safe(cli, chat_id, text=txt) + except Exception as e: + logger.warning(f"Could not notify chat {chat_id}: {e}") + + # _notify never raises, so plain gather suffices + await asyncio.gather(*(_notify(t) for t in targets)) + -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}") - - -async def gen_links(fwd_msg: Message, shortener: bool = True) -> Dict[str, str]: +async def reply_user_err(msg: Message, err_txt: str): + try: + 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): + try: + is_new = await db.add_user(uid) + if not is_new: + return + if isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0: + try: + await send_safe( + cli, + Var.BIN_CHANNEL, + # MSG_NEW_USER is HTML; first_name is user-controlled + 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}") + 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) - 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,57 +162,63 @@ 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) - - -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 + # user_name lands in an HTML label; escape it (attacker-controlled) + return MSG_DC_USER_INFO.format( + 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: + # @username stays a str; numeric strings become ints -- one shared lookup path + if isinstance(qry, str) and not qry.startswith("@") and qry.isdigit(): + qry = int(qry) + if isinstance(qry, (str, int)): + 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 + return None + + +async def is_admin(cli: Client, chat_id_val: int) -> bool: + try: + # cli.me is populated after client.start(); id 0 matches no chat member + 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: + return False + return member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER] + + +async def reply(msg: Message, **kwargs): + 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", +] diff --git a/Thunder/utils/broadcast.py b/Thunder/utils/broadcast.py index 044dcd4..f0d6985 100644 --- a/Thunder/utils/broadcast.py +++ b/Thunder/utils/broadcast.py @@ -1,189 +1,318 @@ -# 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()) +import asyncio +import os +import time +from typing import Any + +from pyrogram.client import Client +from pyrogram.enums import ParseMode +from pyrogram.errors import ( + ChannelInvalid, + ChatWriteForbidden, + FloodWait, + InputUserDeactivated, + PeerIdInvalid, + RPCError, + 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_BROADCAST_CANCELLED_PREFIX, + MSG_BROADCAST_COMPLETE, + MSG_BROADCAST_FAILED_USERS, + MSG_BROADCAST_INTERRUPTED_PREFIX, + MSG_BROADCAST_NO_USERS, + MSG_BROADCAST_PROGRESS, + MSG_BROADCAST_START, + MSG_BUTTON_CANCEL_BROADCAST, + 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: dict[str, dict[str, Any]] = {} + +# Errors that mean the recipient will never be reachable again. +_PERMANENT_ERRORS = ( + UserDeactivated, + UserIsBlocked, + PeerIdInvalid, + ChatWriteForbidden, + ChannelInvalid, + 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 +_BROADCAST_PACE_SECONDS = 0.2 +_PROGRESS_EVERY = 25 + +# strong refs: CPython only weakly references tasks; unreferenced ones +# can be garbage-collected mid-run +_BROADCAST_TASKS: set[asyncio.Task] = set() + + +async def broadcast_message(client: Client, message: Message, mode: str = "all"): + if not message.reply_to_message: + try: + 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 + + broadcast_id = os.urandom(3).hex() + stats = {"total": 0, "success": 0, "failed": 0, "deleted": 0, "cancelled": False} + # unreachable ids; deletes happen after the summary, never in workers + prune_ids: list[int] = [] + broadcast_ids[broadcast_id] = stats + + try: + 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 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 tg_call( + status_msg.edit_text, MSG_BROADCAST_FAILED_USERS.format(mode=mode), retries=0 + ) + except Exception: + pass + del broadcast_ids[broadcast_id] + return + + if stats["total"] == 0: + try: + await tg_call(status_msg.edit_text, MSG_BROADCAST_NO_USERS.format(mode=mode), retries=0) + except Exception: + pass + del broadcast_ids[broadcast_id] + return + + async def do_broadcast(): + # bounded-queue worker pool; cursor streamed, never materialized; + # sends paced, progress edits throttled, cancel stays responsive + queue: asyncio.Queue = asyncio.Queue(maxsize=200) + + async def producer(): + try: + async for user in cursor: + if stats["cancelled"]: + break + await queue.put(user) + except Exception as e: + logger.error(f"Broadcast cursor error: {e}", exc_info=True) + finally: + # every worker needs exactly one pill or it blocks on get() + # forever: retry (workers drain concurrently) but bail out if + # all workers are already done + for _ in range(worker_count): + while True: + try: + queue.put_nowait(None) # poison pills + break + except asyncio.QueueFull: + if all(w.done() for w in workers): + break + await asyncio.sleep(0.05) + + 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, prune_ids) + # idempotent modulo _PROGRESS_EVERY: concurrent workers may + # skip or duplicate a progress edit; the final completion + # message is the source of truth + if stats["success"] and stats["success"] % _PROGRESS_EVERY == 0: + await _edit_progress(status_msg, stats) + finally: + if user is not None: + await asyncio.sleep(_BROADCAST_PACE_SECONDS) + + worker_count = Var.BROADCAST_WORKERS + 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") + + completed_normally = False + worker_crashed = False + try: + 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}") + worker_crashed = True + 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 tg_call(status_msg.delete, retries=0) + 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)), + mode=mode, + total_users=stats["total"], + successes=stats["success"], + failures=stats["failed"], + deleted_accounts=stats["deleted"], + ) + + if worker_crashed: + completion_msg = MSG_BROADCAST_INTERRUPTED_PREFIX + completion_msg + elif stats["cancelled"]: + completion_msg = MSG_BROADCAST_CANCELLED_PREFIX + completion_msg + + 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) + + if prune_ids: + # summary is out; prune in the background so sequential + # deletes never stall workers + prune_task = asyncio.create_task(_prune_collected(prune_ids)) + _BROADCAST_TASKS.add(prune_task) + prune_task.add_done_callback(_BROADCAST_TASKS.discard) + + task = asyncio.create_task(do_broadcast()) + # hold a strong ref so CPython cannot GC the task mid-run + _BROADCAST_TASKS.add(task) + task.add_done_callback(_BROADCAST_TASKS.discard) + + +async def _prune_collected(user_ids: list[int]) -> None: + """Best-effort background prune of unreachable recipients.""" + for user_id in user_ids: + try: + await db.delete_user(user_id) + except Exception as e: + logger.error(f"Background prune failed for {user_id}: {e}", exc_info=True) + + +async def _send_one( + client: Client, message: Message, user_id: int, stats: dict, prune_ids: list[int] +) -> None: + # transient errors get 3 attempts with attempt-squared backoff (1s, 4s); + # permanent and FloodWait outcomes return immediately (tg_call already + # retried FloodWaits with bounded sleeps) + for attempt in range(1, 4): + try: + # fail-count fast on sustained throttle: a per-recipient fan-out + # must not ride out a 600s ingest-style wait per user + await tg_call(message.reply_to_message.copy, user_id, retries=1, max_flood_sleep=30.0) + stats["success"] += 1 + return + except _PERMANENT_ERRORS as e: + recipient_type, reason = _PERMANENT_ERROR_REASONS.get( + type(e), ("Recipient", "unreachable") + ) + + logger.warning(f"{recipient_type} {user_id} removed due to {reason}") + try: + # raise_on_error=True: a Mongo brownout is not "unauthorized" -- the + # fail-soft default would prune live authorized users; counted as a + # plain failure below. + is_authorized = await db.is_user_authorized(user_id, raise_on_error=True) + if not is_authorized: + prune_ids.append(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 + return + except FloodWait as e: + # allowed: classify-only, no sleep (tg_call already retried) + logger.warning(f"FloodWait persisted for user {user_id}, last wait: {e.value}s") + stats["failed"] += 1 + return + except TimeoutError: + # transient transport stall: backoff below, fail after 3 attempts + pass + except RPCError as e: + if getattr(e, "code", 0) not in (500, 502, 503, 504): + logger.error(f"Non-retryable RPC error for user {user_id}: {e}", exc_info=True) + stats["failed"] += 1 + return + # else: transient server-side error, backoff below + except Exception as e: + logger.error(f"Error copying message to user {user_id}: {e}", exc_info=True) + stats["failed"] += 1 + return + if attempt >= 3: + logger.error(f"Transient error persisted for user {user_id}; giving up") + stats["failed"] += 1 + return + await asyncio.sleep(attempt * attempt) + + +async def _edit_progress(status_msg: Message, stats: dict) -> None: + try: + 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 367171d..4d04d55 100644 --- a/Thunder/utils/canonical_files.py +++ b/Thunder/utils/canonical_files.py @@ -1,463 +1,496 @@ -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 collections.abc import Awaitable, Callable +from contextlib import asynccontextmanager +from typing import Any + +from pymongo.errors import DuplicateKeyError +from pyrogram.types import Message + +from Thunder.utils.database import db +from Thunder.utils.file_properties import get_fname, get_media, get_uniqid +from Thunder.utils.logger import hash_path_token, 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 + +# new hashes are 32 hex chars; legacy 20-char hashes stay valid forever. +PUBLIC_HASH_LENGTH = 32 +LEGACY_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 + +# bounded touch buffer; overflow drops (counted), flush is one BulkWrite. +_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() +_cache_by_hash: "OrderedDict[str, 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 +# value = (latest record, reuse_delta, seen_delta): deltas so N touches of +# one hash flush as N increments instead of a single merged $inc: 1 +_pending_touches: "OrderedDict[str, tuple[dict[str, Any], int, int]]" = OrderedDict() +_flush_task: asyncio.Task | None = None + + +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 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 + return ext_and_mime_for_class(type(media).__name__.lower())[1] + + +def build_file_record( + stored_message: Message, + *, + 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.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 +) -> dict[str, Any] | None: + 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") + + _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) + return record + + +def _forget(record: dict[str, Any]) -> None: + file_unique_id = record.get("file_unique_id") + public_hash = record.get("public_hash") + + if file_unique_id: + _cache_by_unique_id.pop(file_unique_id, None) + if public_hash: + _cache_by_hash.pop(public_hash, None) + + +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 + 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 +) -> dict[str, Any] | None: + 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 forget_stale_record(record: dict[str, Any]) -> bool: + """Self-healing: 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 {hash_path_token(public_hash)} (deleted={deleted})" + ) + return deleted + + +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: + cancelled = True + finally: + 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 during this flush escaped its snapshot; re-arm + # or they sit unflushed until process exit + _flush_task = asyncio.create_task(_flush_pending_touches()) + + +async def _bulk_flush() -> None: + global _dropped_touches + items = list(_pending_touches.items()) + _pending_touches.clear() + if not items: + return + try: + await db.bulk_touch_file_records( + [(h, reuse_delta, seen_delta) for h, (_, reuse_delta, seen_delta) in items] + ) + except Exception as e: + # merge back so the next flush retries; NOTE: a part-way failed + # ordered=False bulk re-applies some increments (accepted over-count) + logger.error(f"Failed to bulk-flush {len(items)} touches: {e}", exc_info=True) + for h, payload in items: + old = _pending_touches.get(h) + if old is None: + _pending_touches[h] = payload + else: + _pending_touches[h] = (payload[0], old[1] + payload[1], old[2] + payload[2]) + # the cap binds here too: repeated failures must not grow the buffer + # without limit. Oldest entries drop first (counted, like the + # schedule-site overflow). + while len(_pending_touches) > _TOUCH_BUFFER_MAX: + _pending_touches.popitem(last=False) + _dropped_touches += 1 + + +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.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: + _, reuse_delta, seen_delta = _pending_touches[public_hash] + _pending_touches[public_hash] = (record, reuse_delta + int(reused), seen_delta + 1) + elif len(_pending_touches) >= _TOUCH_BUFFER_MAX: + # 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, int(reused), 1) + + 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 + + +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() + try: + await _flush_task + except asyncio.CancelledError: + pass + + 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: + 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], client) -> Message | None: + canonical_message_id = record.get("canonical_message_id") + if canonical_message_id is None: + return None + + try: + 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 + ) + 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, 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, +) -> 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, client) + except Exception as e: + # RPC failure is NOT proof the vault message is gone; only a definitive + # None / unique-id mismatch declares staleness (else re-copy storms) + logger.warning( + f"Canonical validation errored for {file_unique_id}; keeping cached record: {e}", + exc_info=True, + ) + return existing, None + + if is_valid: + return existing, None + + _forget(existing) + return None, existing + + +async def _wait_for_other_worker_canonical_record( + file_unique_id: str, client +) -> 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, client) + 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") + ) + # preserve existing public_hash: re-hashing rewrites legacy 20-char hashes + # to 32-hex and breaks published links + preserved_hash = existing.get("public_hash") + if preserved_hash: + refreshed["public_hash"] = preserved_hash + 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[Message | None]], + client, +) -> tuple[dict[str, Any] | None, Message | None, 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, client + ) + if reusable_record: + schedule_touch_file_record(reusable_record, reused=True) + return reusable_record, None, True + + claim_owner = await db.acquire_file_ingest_claim( + file_unique_id, ttl_seconds=_INGEST_CLAIM_TTL_SECONDS + ) + if not claim_owner: + 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, client + ) + 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, client + ) + 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 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, 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/commands.py b/Thunder/utils/commands.py index d483149..0cad940 100644 --- a/Thunder/utils/commands.py +++ b/Thunder/utils/commands.py @@ -1,38 +1,26 @@ -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 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_INTRO, MSG_HELP_TIPS +from Thunder.utils.safe_call import tg_call +from Thunder.vars import Var + + +def build_help_text(max_files: int) -> str: + """Assemble /help from its three parts.""" + 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 = bot_commands() + if commands: + await tg_call(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 index d1bb0b4..1615fef 100644 --- a/Thunder/utils/config_parser.py +++ b/Thunder/utils/config_parser.py @@ -1,36 +1,21 @@ -# 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 {} +import os + + +class TokenParser: + def parse_from_env(self) -> dict[int, str]: + # sort key cannot raise: digit filter yields "" at worst, `or 0` -> 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), + ) + + return {index + 1: token for index, (_, token) in enumerate(sorted_tokens)} diff --git a/Thunder/utils/custom_dl.py b/Thunder/utils/custom_dl.py index 22b0de4..df66fb2 100644 --- a/Thunder/utils/custom_dl.py +++ b/Thunder/utils/custom_dl.py @@ -1,38 +1,52 @@ -# 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 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 +# Caps the total FloodWait pinning of one stream handler before a 503. +_MAX_STREAM_FLOODWAIT_SECONDS = 60.0 + +# Shared media chunk size (1 MiB): also used by the HTTP streaming routes to +# align the byte-skip math with stream_file's chunk offsets. +CHUNK_SIZE = 1024 * 1024 + class ByteStreamer: - __slots__ = ('client', 'chat_id') + __slots__ = ("client", "chat_id") def __init__(self, client: Client) -> None: self.client = client 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 + # bounded FloodWait handling via tg_call; transient failures + # raise TelegramUnavailable, NEVER FileNotFound -- the delivery route + # self-heals (deletes the record) on FileNotFound. + try: + message = await tg_call( + self.client.get_messages, self.chat_id, message_id, retries=2, timeout=60 + ) + except FloodWait as 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 TelegramUnavailable(f"Telegram fetch failed for message {message_id}") from e - 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 @@ -41,95 +55,72 @@ 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]: - chunk_offset = offset // (1024 * 1024) + ) -> AsyncGenerator[bytes]: + chunk_offset = offset // CHUNK_SIZE 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]: + chunk_limit = ((limit + CHUNK_SIZE - 1) // CHUNK_SIZE) + 1 + + # fetch the target ONCE, outside the retry loop: per-retry re-fetches + # cost an extra RPC per FloodWait and turn hiccups into spurious 404s + 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: + # stream_media is an async generator; stubs union it with + # file_ref types, hence the ignore below + async for chunk in self.client.stream_media( # type: ignore[union-attr] + target, offset=chunk_offset, limit=chunk_limit + ): + yield chunk + chunks_done += 1 + return + except FloodWait as e: + # resume from where the consumer is: restarting from the + # original offset re-sends bytes and corrupts the download + if chunks_done: + chunk_offset += chunks_done + if chunk_limit: + chunk_limit = max(chunk_limit - chunks_done, 0) + chunks_done = 0 + # bound total pinned time on sustained floods + 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 TelegramUnavailable(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"} + # the delivery ladder maps FileNotFound -> 404; an unreadable + # "error" marker dict had no reader anywhere + raise FileNotFound(f"Message {message.id} has 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]: - 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)} diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py index d0bbe9a..bf8f8f1 100644 --- a/Thunder/utils/database.py +++ b/Thunder/utils/database.py @@ -1,453 +1,670 @@ -# 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) +import datetime +import uuid +from typing import Any + +from pymongo import AsyncMongoClient, UpdateOne +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.errors import DuplicateKeyError, ExecutionTimeout, OperationFailure + +from Thunder.utils.flag_cache import flags +from Thunder.utils.logger import hash_path_token, logger +from Thunder.vars import Var + +# every Mongo op 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, **kwargs): + # tz_aware=True: pymongo's default returns naive UTC datetimes, which + # raise TypeError against aware now(UTC) (broke token activation once) + 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 + 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 + # 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]] = [ + {"$sort": {"join_date": 1}}, + {"$group": {"_id": "$id", "doc_id": {"$first": "$_id"}}}, + {"$project": {"_id": "$doc_id"}}, + ] + keep_ids = [] + # 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}}) + if result.deleted_count > 0: + logger.warning(f"Deduplicated {result.deleted_count} duplicate user documents.") + + 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: + # 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: + 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 _get_backfill_cursor(self) -> Any: + """Persisted resume position: without it every boot re-walks the + already-stamped prefix and the migration never converges on large + vaults.""" + try: + doc = await self.migration_flags_col.find_one({"_id": "file_last_seen_backfill_cursor"}) + return doc.get("last_id") if doc else None + except Exception: + return None + + async def _save_backfill_cursor(self, last_id: Any) -> None: + try: + await self.migration_flags_col.update_one( + {"_id": "file_last_seen_backfill_cursor"}, + {"$set": {"last_id": last_id}}, + upsert=True, + ) + except Exception as e: + logger.warning(f"Could not persist backfill cursor: {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. + + ``_id``-paged micro-batches filtered to unstamped rows keep each + statement inside the 5s ``timeoutMS`` (a single ``update_many`` would + COLLSCAN and abort the remaining index ensures). The resume cursor is + persisted per batch, so a capped or interrupted run continues where + it stopped -- the filter shrinks as rows get stamped, guaranteeing + convergence. Must never raise. + """ + batch_size = 500 + max_batches_per_boot = 100 # 50k unstamped rows per boot; converges across boots + last_id: Any = await self._get_backfill_cursor() + stamped = 0 + try: + for _ in range(max_batches_per_boot): + page_filter: dict[str, Any] = {"last_seen_at": {"$exists": False}} + if last_id is not None: + page_filter["_id"] = {"$gt": last_id} + page = ( + await self.files_col.find(page_filter, {"_id": 1}) + .sort("_id", 1) + .to_list(batch_size) + ) + if not page: + await self._mark_backfill_done() + if stamped: + logger.info(f"Backfilled last_seen_at on {stamped} legacy file records.") + return + last_id = page[-1]["_id"] + # per-batch stamp: a frozen boot-time value would backdate rows + # stamped by later batches (and skew their TTL window start) + stamp = datetime.datetime.now(datetime.UTC) + await self.files_col.update_many( + {"_id": {"$in": [doc["_id"] for doc in page]}}, + {"$set": {"last_seen_at": stamp}}, + ) + stamped += len(page) + await self._save_backfill_cursor(last_id) + logger.warning( + "last_seen_at backfill hit the per-boot batch cap " + f"({max_batches_per_boot} batches); continuing from the cursor next boot." + ) + except Exception as e: + # The migration must never abort the remaining index ensures. + logger.warning(f"last_seen_at backfill interrupted (resumes from cursor): {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: + # first build on a large vault can exceed the budget; the + # server-side build continues and is idempotent, 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 TTL via createIndexes; an operator's + # FILE_TTL_DAYS change must not abort the remaining unique ensures + 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, OperationFailure) as e: + # a concurrent recreate (or a server still building) must not + # abort the remaining unique ensures + logger.warning(f"File TTL index rebuild failed; re-checked on next boot: {e}") + + async def _ensure_index(self, col: AsyncCollection, keys: Any, **opts: Any) -> None: + """Best-effort: one failed ensure (e.g. a build exceeding the client + timeout on a large vault) must not abort the remaining ones -- the + server-side build continues and the next boot re-runs it.""" + try: + await col.create_index(keys, **opts) + except ExecutionTimeout as e: + logger.warning(f"Index ensure on {col.name} skipped; re-checked on next boot: {e}") + except OperationFailure as e: + logger.error(f"Index ensure on {col.name} failed; re-checked on next boot: {e}") + + async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool: + try: + # backfill before the TTL index exists so pre-existing rows + # get a full window instead of vanishing on activation (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 first so they 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") + except Exception: + pass + + await self._ensure_index(self.banned_users_col, "user_id", unique=True) + await self._ensure_index(self.banned_channels_col, "channel_id", unique=True) + await self._ensure_index(self.token_col, "token", unique=True) + # /start + generate() look tokens up by user; without this the + # per-user scans walk the whole collection + await self._ensure_index( + self.token_col, [("user_id", 1), ("activated", 1), ("expires_at", -1)] + ) + await self._ensure_index(self.authorized_users_col, "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._ensure_index(self.col, "id", unique=True) + await self._ensure_index(self.token_col, "expires_at", expireAfterSeconds=0) + await self._ensure_index(self.token_col, "activated") + await self._ensure_index(self.restart_message_col, "message_id", unique=True) + await self._ensure_index(self.restart_message_col, "timestamp", expireAfterSeconds=3600) + await self._ensure_index(self.files_col, "file_unique_id", unique=True) + await self._ensure_index(self.files_col, "public_hash", unique=True) + await self._ensure_index(self.files_col, "canonical_message_id", unique=True) + await self._ensure_index(self.files_col, "created_at") + await self._ensure_index(self.file_ingest_locks_col, "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: + return {"id": user_id, "join_date": datetime.datetime.now(datetime.UTC)} + + 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): + # find() only builds a cursor server-side; it cannot fail here + return self.col.find({}) + + async def get_authorized_users_cursor(self): + return self.authorized_users_col.find({}) + + async def get_regular_users_cursor(self): + auth_ids = await self.authorized_users_col.distinct("user_id") + return self.col.find({"id": {"$nin": auth_ids}}) + + 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: int | None = None, reason: str | None = None + ): + try: + ban_data = { + "user_id": user_id, + "banned_at": datetime.datetime.now(datetime.UTC), + "banned_by": banned_by, + "reason": reason, + } + await self.banned_users_col.update_one( + {"user_id": user_id}, {"$set": ban_data}, upsert=True + ) + # the ban gate is flag-cached: 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) + raise + + async def remove_banned_user(self, user_id: int) -> bool: + try: + result = await self.banned_users_col.delete_one({"user_id": user_id}) + # invalidate unconditionally (a miss is a no-op): a stale cached + # ban entry would keep a re-banned user denied until TTL expiry + flags.invalidate(("banned_user", 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, *, 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 + + async def add_banned_channel( + 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.UTC), + "banned_by": banned_by, + "reason": reason, + } + 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( + 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}) + flags.invalidate(("banned_channel", 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, *, 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 + + 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 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.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) -> dict[str, Any] | None: + 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, *, raise_on_error: bool = False) -> bool: + """Authorized-user existence check. With ``raise_on_error=True`` a Mongo + failure raises so fail-closed callers can deny instead of silently + treating the outage as "not authorized" (mirrors ``is_user_banned``).""" + try: + user = await self.authorized_users_col.find_one({"user_id": user_id}, {"_id": 1}) + return bool(user) + except Exception as e: + if raise_on_error: + raise + 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) -> dict[str, Any] | None: + 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 {hash_path_token(file_unique_id)}: {e}", + exc_info=True, + ) + return None + + async def get_file_by_hash( + 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: + # no capability material in logs: public_hash identifies the link + logger.error( + f"Error getting file by hash {hash_path_token(public_hash)}: {e}", exc_info=True + ) + if raise_on_error: + raise + 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 " + f"{hash_path_token(str(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 " + f"{hash_path_token(str(file_record.get('file_unique_id')))}: {e}", + exc_info=True, + ) + raise + + async def bulk_touch_file_records( + self, items: list[tuple[str, int, int]], *, raise_on_error: bool = False + ) -> bool: + """Batched touch: one BulkWrite for the whole flush cycle. + + ``items`` is a list of ``(public_hash, reuse_delta, seen_delta)`` + triples; deltas accumulate per hash so N touches flush as N, not 1. + """ + if not items: + return True + now = datetime.datetime.now(datetime.UTC) + ops: list[UpdateOne] = [] + for public_hash, reuse_delta, seen_delta in items: + inc: dict[str, int] = {"seen_count": seen_delta} + if reuse_delta: + inc["reuse_count"] = reuse_delta + 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.""" + 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 {hash_path_token(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 + ) -> bool: + try: + await self.files_col.update_one( + {"public_hash": public_hash}, + {"$set": {"file_id": file_id, "last_seen_at": datetime.datetime.now(datetime.UTC)}}, + ) + return True + except Exception as e: + logger.error( + f"Error updating file_id for {hash_path_token(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 + ) -> 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 owner + 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 owner if result else None + except Exception as e: + logger.error( + f"Error updating ingest claim for {hash_path_token(file_unique_id)}: {e}", + exc_info=True, + ) + raise + except Exception as e: + logger.error( + f"Error acquiring ingest claim for {hash_path_token(file_unique_id)}: {e}", + exc_info=True, + ) + raise + + 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: + 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 {hash_path_token(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.UTC)}}, + {"_id": 1}, + ) + return bool(claim) + except Exception as e: + logger.error( + f"Error checking ingest claim for {hash_path_token(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 index b1209f1..18de240 100644 --- a/Thunder/utils/decorators.py +++ b/Thunder/utils/decorators.py @@ -1,181 +1,332 @@ -# 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 +"""Access gates. + +One preflight chain replaces the three ad-hoc per-plugin gate orders. +Documented ordering (see AGENTS.md): + + banned -> private-mode -> token (+ force-sub where applicable; + shortener-status is a routing value, not a gate) + +* ``preflight`` runs only ``banned -> private-mode -> token`` and returns + the shortener status (which is a value, not a gate); +* force-sub is NOT in ``PREFLIGHT_GATES``: it must be called explicitly + after ``preflight`` via :func:`force_sub_gate` (see + ``validate_request_common`` in ``bot/plugins/stream.py``); +* owner bypasses everything; authorized users bypass private-mode + token, + but not the ban check or force-sub; +* /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. +""" + +import html +from urllib.parse import quote_plus + +from pyrogram.enums import ParseMode +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_ANONYMOUS_SENDER, + MSG_ERROR_TEMP, + MSG_ERROR_TOKEN_LINK_FAILED, + MSG_ERROR_UNAUTHORIZED, + MSG_ERROR_UNEXPECTED, + 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) -> bool: + """Ban gate -- cached, fail-closed.""" + try: + if not message.from_user: + return True + user_id = message.from_user.id + if user_id == Var.OWNER_ID: + return True + + try: + ban_details = await flags.get_or_load( + ("banned_user", user_id), + # raise_on_error=True: otherwise Mongo outages become None, + # negative-cached 5 min as "not banned" -- 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 + 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") + 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 reply_safe( + message, + MSG_DECORATOR_BANNED.format( + # reason is owner-set free text; escaped + explicit HTML + # parse mode so it cannot reflow into markup + reason=html.escape(ban_details.get("reason", "Not specified")), + ban_time=ban_time, + ), + parse_mode=ParseMode.HTML, + ) + 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: owner + authorized users only.""" + if not Var.PRIVATE_MODE: + return True + if not message.from_user: + # 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 + 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) -> bool: + """Token-activation gate (cached checks, fail-closed).""" + try: + # TOKEN_ENABLED short-circuit comes FIRST: with the feature off the + # gate must no-op even for anonymous senders, or anonymous /link breaks + if not Var.TOKEN_ENABLED: + return True + + if not message.from_user: + # anonymous senders cannot hold a token: fail-closed + 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: + return True + + 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}: {e}", exc_info=True + ) + try: + await reply_safe(message, MSG_ERROR_TOKEN_LINK_FAILED) + except Exception: + pass + return False + + # generate() returns a token or raises; no empty-string path exists + + try: + 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, 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, MSG_ERROR_UNEXPECTED) + except Exception: + pass + return False + deep_link = ( + "https://t.me/" + me.username + "?start=" + quote_plus(temp_token_string, safe="") + ) + 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 reply_safe( + message, + MSG_TOKEN_INVALID, + 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: + 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 + ) + return False + + +async def get_shortener_status(client, message: Message) -> bool: + try: + user_id = message.from_user.id if message.from_user else None + use_shortener = Var.SHORTEN_MEDIA_LINKS + 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}: {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 Var.SHORTEN_MEDIA_LINKS + + +# unified preflight chain + +# gate registry -- order is the documented contract; adding a new gate is a +# 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") +# info commands (/help, /about, /dc, /ping) run GATES_START on purpose: the +# token gate must not block them for token-pending users + + +async def preflight( + client, + message: Message, + *, + 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. 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: + 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) + + +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"): + 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 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 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/file_properties.py b/Thunder/utils/file_properties.py index c83f7ae..c70eb44 100644 --- a/Thunder/utils/file_properties.py +++ b/Thunder/utils/file_properties.py @@ -1,99 +1,68 @@ -# 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)) +from datetime import datetime as dt +from typing import Any + +from pyrogram.file_id import FileId +from pyrogram.types import Message + +from Thunder.utils.media_types import canonical_media_type, ext_for + + +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) -> str | None: + 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) -> FileId | None: + 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: + # single media-type map: attribute -> canonical key -> ext + for attr in ("photo", "audio", "voice", "video", "animation", "video_note", "sticker"): + if getattr(msg, attr, None) is not None: + 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 diff --git a/Thunder/utils/flag_cache.py b/Thunder/utils/flag_cache.py new file mode 100644 index 0000000..0e5182a --- /dev/null +++ b/Thunder/utils/flag_cache.py @@ -0,0 +1,121 @@ +"""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() + 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] + 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) + + # 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: + task = asyncio.current_task() + try: + value = await loader() + except BaseException: + # only drop our own registration: a successor loader registered + # after an invalidate() must survive our failure + if self._inflight.get(key) is task: + self._inflight.pop(key, None) + raise + # Fence: invalidate()/clear() may have dropped our registration + # while the loader was in flight; re-storing the pre-mutation value + # would re-cache a stale gate answer for a full TTL. Store only if we + # are still the registered loader for this key. + if self._inflight.get(key) is task: + self._data[key] = (value, time.monotonic()) + self._data.move_to_end(key) + while len(self._data) > self.max_items: + self._data.popitem(last=False) + self._inflight.pop(key, None) + return value + + def invalidate(self, *keys: Hashable) -> None: + for key in keys: + self._data.pop(key, None) + self._inflight.pop(key, None) + + def clear(self) -> None: + self._data.clear() + self._inflight.clear() + + 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: + self.sweep() + 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", "DEFAULT_MAX_ITEMS"] diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py index 698ef18..f7a1619 100644 --- a/Thunder/utils/force_channel.py +++ b/Thunder/utils/force_channel.py @@ -1,89 +1,136 @@ -# 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 +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 + +from Thunder.utils.flag_cache import FlagCache +from Thunder.utils.logger import logger +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 + +_force_link = None +_force_title = None +_force_resolved = False +_resolved_at = 0.0 +_RESOLVED_TTL_SECONDS = 300.0 +_negative_until = 0.0 +_NEGATIVE_TTL_SECONDS = 60.0 + +# Membership cache: one get_chat_member RPC per gated message was the +# last uncached hot-path read. Short symmetric TTL bounds the RPC volume; +# either direction is at most 60s stale (a join waits, a leave lingers) -- +# asymmetric caching would only move the fail-open window, not close it. +_membership_cache = FlagCache(ttl_seconds=60, max_items=4096, name="force_member") + + +async def _is_member(client: Client, user_id: int) -> bool: + try: + member = await tg_call( + client.get_chat_member, + Var.FORCE_CHANNEL_ID, + user_id, + retries=1, + ) + return member is not None + except UserNotParticipant: + return False + # any other error propagates: the gate stays fail-closed + + +async def get_force_info(bot: Client): + global _force_link, _force_title, _force_resolved, _resolved_at, _negative_until + + if not Var.FORCE_CHANNEL_ID: + return None, None + + # positive cache with a TTL: invite links/titles can rotate, so a + # resolved-once entry would serve stale join buttons forever + if _force_resolved and time.monotonic() - _resolved_at <= _RESOLVED_TTL_SECONDS: + 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) + if chat: + # numeric channel id: get_chat always resolves a full Chat + # (ChatPreview only comes from link resolution), hence the ignores + _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" + # cache even the no-link outcome, or it re-resolves per message + _force_resolved = True + _resolved_at = time.monotonic() + 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 + + +async def force_channel_check(client: Client, message: Message): + if not Var.FORCE_CHANNEL_ID: + return True + + if message.from_user is not None and message.from_user.id == Var.OWNER_ID: + # owner bypasses everything, including force-sub + return True + + if message.from_user is None: + # fail-closed: channel posts / anonymous admins have no verifiable + # user id, so membership cannot be checked -- deny like the token + # gate's MSG_ERROR_ANONYMOUS_SENDER path (callers deny on False) + logger.debug("Denied unattributable sender (force-sub, no from_user).") + return False + + try: + is_member = await _membership_cache.get_or_load( + (Var.FORCE_CHANNEL_ID, message.from_user.id), + lambda: _is_member(client, message.from_user.id), + ) + except Exception as e: + logger.error(f"Error checking force channel: {e}", exc_info=True) + try: + 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 + + if is_member: + return True + + link, title = await get_force_info(client) + if link and title: + try: + await reply_safe( + message, + 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(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, MSG_FORCE_SUB_REQUIRED) + except Exception as e: + logger.warning(f"Could not send force-sub notice: {e}") + return False diff --git a/Thunder/utils/human_readable.py b/Thunder/utils/human_readable.py index 0a7ee46..af90a5b 100644 --- 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" +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 + value: float = size + while value >= 1024 and n < len(_UNITS) - 1: + value /= 1024 + n += 1 + 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 index d7089c0..f60f072 100644 --- a/Thunder/utils/keepalive.py +++ b/Thunder/utils/keepalive.py @@ -1,22 +1,52 @@ -# 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) +import asyncio +import os + +import aiohttp + +from Thunder.utils.logger import logger +from Thunder.vars import Var + + +def _health_url() -> str: + """Ping /health on ourselves. + + 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 "").strip() + if fqdn and "://" in fqdn: + # full public URL for PaaS anti-sleep: external traffic keeps free + # tiers awake, so use it verbatim instead of rebuilding loopback + return f"{fqdn.rstrip('/')}/health" + fqdn = fqdn 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" + # loopback unless KEEPALIVE_HOST overrides + return f"http://{fqdn}:{Var.PORT}/health" + + +async def ping_server(): + try: + 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(url) as resp: + body = await resp.text() + if resp.status != 200: + 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 6609a39..192a95c 100644 --- a/Thunder/utils/logger.py +++ b/Thunder/utils/logger.py @@ -1,39 +1,118 @@ -# 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'] +import atexit +import hashlib +import json +import logging +import os +import queue +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") +os.makedirs(LOG_DIR, exist_ok=True) +LOG_FILE = os.path.join(LOG_DIR, "bot.txt") + +logging._srcfile = None +logging.logThreads = False +logging.logProcesses = False + +# shared secret redaction -- the access-log middleware and /log upload +# both go through these 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)?://[^:]+:[^@]+@") +# intentionally broad -- scrubber-only, do not reuse for user-facing text. +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, 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 + + +def hash_path_token(token: str) -> str: + """Stable short pseudonym for a file token in access logs.""" + 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[logging.LogRecord] = queue.Queue(maxsize=10000) + +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(file_formatter) + +console_handler = logging.StreamHandler(stream=sys.__stdout__) +console_handler.setFormatter(console_formatter) +# reconfigure only exists on io.TextIOWrapper; guard wrapped/replaced streams. +_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() + +logger = logging.getLogger("ThunderBot") +logger.setLevel(_log_level) +logger.propagate = False +logger.addHandler(QueueHandler(log_queue)) + +atexit.register(listener.stop) + +__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..89523ab --- /dev/null +++ b/Thunder/utils/media_types.py @@ -0,0 +1,82 @@ +"""Single source of truth for media-type -> extension / mime maps. + +``common.send_file_dc`` keeps its own display-name map (presentation, not a +mime/ext concern). Both naming families are keyed: pyrogram class names are +lower-cased with no underscore (``videonote``) while message attribute names +use ``video_note`` -- both are accepted everywhere. +""" + +from Thunder.utils.logger import logger + +# 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 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: + # unknown classes fall back instead of raising (download path must + # not die on a new pyrogram type); debug-logged for visibility + logger.debug(f"Unknown media class for ext/mime lookup: {class_name_lower!r}") + return DEFAULT_EXT, DEFAULT_MIME + return _MEDIA_EXT_MIME.get(key, (DEFAULT_EXT, DEFAULT_MIME)) + + +__all__ = [ + "canonical_media_type", + "ext_for", + "ext_and_mime_for_class", + "DEFAULT_EXT", + "DEFAULT_MIME", +] diff --git a/Thunder/utils/messages.py b/Thunder/utils/messages.py index feb8f04..f16308e 100644 --- a/Thunder/utils/messages.py +++ b/Thunder/utils/messages.py @@ -1,399 +1,404 @@ -# 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." +# ====== ERROR MESSAGES ====== + +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." + +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_LINK_PRIVATE_HINT = ( + "ℹ️ **/link is for Groups**\n\n" + "In private chat, just send me a file directly — no command needed." +) +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_BATCH_FAILED = ( + "⚠️ **Partial DM Delivery**\n\n" + "> 📭 I couldn't deliver {failed_chunks} of {total_chunks} batch chunk(s) to you in private chat.\n" + "> This usually means you haven't started a private chat with me, or you've blocked me." +) + +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." +) +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." +) + +MSG_ERROR_PROCESSING_MEDIA = "⚠️ **Oops!** Something went wrong while processing your media. Please try again. If the issue persists, contact support." + +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}
""" + +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_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 surfaces are HTML: reason is user-controlled, escape at render. +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_SELF = "❌ **You cannot ban yourself.**" +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.**" + +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}`" + • User ID: {user_id} + • Authorized by: {authorized_by} + • Date: {auth_time}\n\n""" +MSG_ADMIN_AUTH_LIST_HEADER = "🔐 Authorized Users List\n\n" +MSG_ADMIN_AUTH_OWNER_FOOTER = ( + "\n👑 Owner: {owner_id} (implicit, all access)" +) + +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_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." + +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_SIZED = "📄 **System Logs** (last {tailed} of {total})" + +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) ====== + +# 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" + "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 (up to {max_files} at once: /link 5).\n" + "3. Stream links support seeking in any browser.\n\n" + "» Use /help for all commands and detailed information.\n\n" + "🚀 Send a file to begin!" +) + +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 <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" + "> • 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" +) + +# 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" + "> • 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 <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!" +) + +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`" +) + +MSG_DC_USER_INFO = ( + "📍 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}" +) + +MSG_DC_UNKNOWN = "Unknown" + +# Link messages are HTML: file names are user-controlled, escape at render. +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." +) + +# 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}\n' + "> 🆔 User ID: {user_id}\n\n" +) +MSG_COMMUNITY_CHANNEL = "📢 {channel_title}: 🔒 Join this channel to use the bot." + +# ====== PROCESSING MESSAGES ====== + +MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**" +MSG_PROCESSING_FILE = "⏳ **Processing your file...**" +MSG_NEW_FILE_REQUEST = ( + '> 👤 Source: {source_info}\n' + "> 🆔 ID: {id_}\n\n" + "🚀 Download: {online_link}\n\n" + "🖥️ Stream: {stream_link}" +) + +# skipped counts non-media files +MSG_PROCESSING_BATCH = "♻️ **Processing {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, " + "{skipped} skipped, {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" + "📊 **Mode:** `{mode}`\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." +) +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_INTERRUPTED_PREFIX = ( + "⚠️ **Broadcast Interrupted** (worker failure; counts cover processed users only)\n\n" +) +MSG_BROADCAST_PROGRESS = "📣 **Broadcasting...** ✅ {success} / {total} delivered" + +# ====== 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." +MSG_ERROR_CLOSE_NOT_ALLOWED = ( + "⚠️ Only the person who triggered this panel (or the owner) can close it." +) + +# ====== 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" +) + +MSG_RATE_LIMIT_DROPPED = ( + "⚠️ Service is busy and your request could not be completed. Please try again in a few minutes." +) + +# 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 ====== +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:** `@{bot_username}`\n" + "> 🤖 **Bot Instances:** `{active_bots}`\n" + "> 📊 **Total Workload:** `{total_workload}`\n\n" + "📜 **Workload Distribution:**\n\n" + "{workload_items}\n" + "> ♻️ **Version:** `{version}`" +) + +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\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 e7ca117..4ae205f 100644 --- a/Thunder/utils/rate_limiter.py +++ b/Thunder/utils/rate_limiter.py @@ -1,437 +1,697 @@ -# 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) +"""Queue + rate limiting; users keep the queue / wait-estimate UX. + +Bounded bookkeeping + periodic sweep. A worker pool executes queued +requests -- the sliding window is charged at execution time (not enqueue), +and FloodWait requeues the request instead of sleeping the worker. A global +RPS token-bucket breaker shapes bursts through the queue: a dry bucket +routes the request into the queue (workers consume tokens at exec time) +instead of dropping it. +""" + +import asyncio +import math +import time +from collections import deque +from collections.abc import Callable + +from pyrogram import Client +from pyrogram.errors import FloodWait, RPCError +from pyrogram.types import Message + +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, +) +from Thunder.utils.safe_call import edit_safe, send_safe +from Thunder.utils.tokens import allowed +from Thunder.vars import Var + +# max FloodWait requeues before a request is dropped and the user notified; +# breaker/user-limit requeues are bounded by their own wait math instead +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).""" + + 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 + + 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): + self.request_queue: deque[dict] = deque() + self.priority_queue: deque[dict] = deque() + + self.request_event: asyncio.Event = asyncio.Event() + self.request_lock: asyncio.Lock = asyncio.Lock() + + 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]] = {} + self.average_processing_time: float = 1.0 + + 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: + 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 Var.GLOBAL_RPS_LIMIT and not self.global_rate_limit_enabled: + # surface dead knobs -- the RPS cap only bites when the + # breaker 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.") + 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: + try: + return await allowed(user_id) + except Exception as e: + # live: allowed() raises on DB failure (raise_on_error=True default), + # so an outage lands here and is treated as unauthorized (fail-closed) + 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 + + # ---------------- sweep ---------------- + + 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: + # evict least-recently-active first; dict-order eviction could reset an active user's window + 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 + 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 + # insertion-order (not recency like users): file entries carry no + # timestamps, only estimates -- dropping the oldest estimator is fine + 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 + + return { + "user_windows": dropped_users, + "global_entries": dropped_global, + "file_entries": dropped_files, + } + + def occupancy(self) -> dict[str, float | int]: + """Limiter occupancy for /stats.""" + 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(self.breaker.available(), 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: + # Requeues always carry a future not_before (FloodWait/breaker/ + # user-limit waits); _process_one rotates deferred items to the + # back, so queue order is start order by not_before. + if queue_type == "priority": + self.priority_queue.append(request_data) + else: + self.request_queue.append(request_data) + self.request_event.set() + # a pure requeue must re-park the pool or workers spin 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)." + ) + + 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, + "attempts": 0, + "not_before": 0.0, + } + + 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" + + logger.debug( + f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}" + ) + self.request_event.set() + + # ---------------- executor ---------------- + + 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) + 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 must not re-charge, or one upload can burn a user's entire + # window on server-side failures. Decide advisory-first, then charge + # only when admission is certain (breaker state cannot change the + # window in between: both checks are synchronous). + if not self.is_owner(user_id): + record = not request_data.get("charged") + if not await self.check_limits(user_id, record=False): + 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.breaker.rate > 0 and not self.breaker.allow(): + # breaker active via GLOBAL_RPS_LIMIT or the derived per-minute rate: + # same exec-time shaping and 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 + if record: + await self.check_limits(user_id, record=True) + request_data["charged"] = True + + 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) + + except FloodWait as e: + # allowed: pool path -- tg_call already retried inside the queued + # handler, so requeue with an attempt counter instead of stalling a worker. + 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)." + ) + 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: + # cancellation: propagate; the item is already popped, nothing to release. + raise + except Exception as e: + logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) + # never leave the user on a stale "In Queue" notice for a dead item + await self._notify_drop(request_data) + 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, 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: + """Park the pool when every queued request is deferred. + + Python 3.13 pitfall: ``Event.wait()`` on a set event and an + uncontended ``Lock.acquire()`` return WITHOUT yielding, so a pool + rotating only deferred items freezes the loop until the earliest + ``not_before``. Parking clears the wakeup event and arms a timer for + the earliest deferred request; any new enqueue re-sets the event. + 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.EXECUTOR_WORKERS` -- see start_executors().""" + logger.debug("Request executor worker started.") + while True: + try: + await self.request_event.wait() + handled = await self._process_one() + if not handled: + await asyncio.sleep(0.05) + except asyncio.CancelledError: + 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) + 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.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) ---------------- + + 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: 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: + 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: 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 + ) + 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() + + +def start_executors() -> list[asyncio.Task]: + """Start the worker pool -- callers keep the tasks for shutdown.""" + workers: list[asyncio.Task] = [] + for i in range(Var.EXECUTOR_WORKERS): + 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 + + 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 + + # the immediate path consumes a breaker token too -- bursts of + # within-window users never touched the bucket; a dry bucket queues, not drops. + # Decide first (no consumption), then charge the window only for requests + # that actually execute now. + 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: + 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: 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 + ) + + 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 + except (FloodWait, RPCError) as e: + # allowed: catch-and-log after send_safe/tg_call already retried -- + # a notification failure must never fail the queued request itself. + 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 + + +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 = {} + 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: 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..20bc1a9 100644 --- a/Thunder/utils/render_template.py +++ b/Thunder/utils/render_template.py @@ -1,54 +1,140 @@ -# Thunder/utils/render_template.py - -import asyncio -import urllib.parse +import time +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.bot_utils import quote_media_name +from Thunder.utils.file_properties import get_fname, get_fsize, get_media, get_uniqid +from Thunder.utils.human_readable import humanbytes 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 +# NOTE: lazy import of Thunder.server.exceptions inside render_page() avoids +# a circular import (server/__init__ -> stream_routes -> here). + +# resolve templates relative to the package, not the process CWD. +_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: + """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, + mime_type: str | None = None, + size_bytes: int | 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", + "download_src": f"{src}?disposition=attachment", + "kind": _page_kind(mime_type, file_name), + "mime_type": mime_type or "application/octet-stream", + "size_formatted": humanbytes(size_bytes) if size_bytes else None, + } return await template.render_async(**context) -async def render_page(message_id: int, secure_hash: str, requested_action: str | None = None) -> str: +# TTL+LRU cache so repeat legacy /watch views don't re-fetch the vault message. +_legacy_cache: "OrderedDict[tuple[int, str], tuple[float, str, str | None, int]]" = OrderedDict() +_LEGACY_CACHE_TTL_SECONDS = 600 +_LEGACY_CACHE_MAX_ITEMS = 1024 + + +def _legacy_cache_get(key) -> tuple[str, str | None, int] | None: + cached = _legacy_cache.get(key) + if not cached: + return None + ts, file_name, mime_type, size_bytes = 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, mime_type, size_bytes + + +def _legacy_cache_put( + key, file_name: str, mime_type: str | None = None, size_bytes: int = 0 +) -> None: + _legacy_cache[key] = (time.monotonic(), file_name, mime_type, size_bytes) + _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) -> str: + key = (int(message_id), str(secure_hash)) + cached = _legacy_cache_get(key) + if cached is not None: + file_name, mime_type, size_bytes = cached + quoted_filename = quote_media_name(file_name) + src = f"{Var.URL.rstrip('/')}/{secure_hash}{message_id}/{quoted_filename}" + return await render_media_page( + file_name, src, mime_type=mime_type, size_bytes=size_bytes or None + ) + try: + from Thunder.bot import StreamBot # layering break: lazy import + from Thunder.server.exceptions import InvalidHash, TelegramUnavailable + 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) + message = await tg_call( + StreamBot.get_messages, + chat_id=int(Var.BIN_CHANNEL), + message_ids=int(message_id), + retries=1, + timeout=60, + ) + except TimeoutError as e: + # vault timeout is transient, never absence: map to 503 (not + # FileNotFound self-heal, not ladder 500) like the delivery path + raise TelegramUnavailable(f"vault lookup timed out: {e}") from e 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) @@ -56,12 +142,22 @@ 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}') - return await render_media_page(file_name, src, requested_action) + media = get_media(message) + mime_type = getattr(media, "mime_type", None) or None + if not mime_type and media is not None: + mime_type = ext_and_mime_for_class(type(media).__name__.lower())[1] + + _legacy_cache_put(key, file_name, mime_type, file_size := get_fsize(message)) + + quoted_filename = quote_media_name(file_name) + src = f"{Var.URL.rstrip('/')}/{secure_hash}{message_id}/{quoted_filename}" + return await render_media_page( + file_name, src, mime_type=mime_type, size_bytes=file_size or None + ) except Exception as e: + # the capability hash is a credential: never log it (bot.txt is uploaded via /log) logger.error( - f"Error in render_page for message_id {message_id} and hash {secure_hash}: {e}", - exc_info=True + 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 new file mode 100644 index 0000000..b977ec0 --- /dev/null +++ b/Thunder/utils/safe_call.py @@ -0,0 +1,155 @@ +"""Central FloodWait-safe call helpers. + +Every Telegram RPC goes through :func:`tg_call` or a thin wrapper. On +``FloodWait`` the call sleeps ``min(e.value, cap)`` -- where the cap is +30 s for lightweight RPCs and :data:`MAX_FLOODWAIT_SLEEP_MEDIA_SECONDS` +(600 s, the pre-branch pyrogram auto-sleep ceiling) for file-transfer +shapes -- and retries at most ``retries`` times, then the exception +propagates unchanged. Wall-clock budgets (H8): lightweight RPCs get a +default timeout so a hung call cannot pin a handler forever; +file-transfer paths default to *no* timeout (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; env-overridable via TG_RPC_TIMEOUT_SECONDS. +DEFAULT_RPC_TIMEOUT_SECONDS = 30.0 + +# cap FloodWait sleeps so a lightweight RPC stays near its advertised wall-clock +# budget (worst case per attempt: budget + cap + a final budgeted attempt). +MAX_FLOODWAIT_SLEEP_SECONDS = 30.0 + +# File-transfer shapes may sleep the full FloodWait up to this ceiling (the +# pyrogram auto-sleep budget): sustained throttling on the ingest path must +# ride out long waits instead of hard-failing; beyond it, surface to caller. +MAX_FLOODWAIT_SLEEP_MEDIA_SECONDS = 600.0 + +# Call shapes allowed to run unbounded by default (large media transfers); matched by name. +_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", +} + + +_env_timeout_cache: float | None = None + + +def _env_timeout() -> float: + # static per process: resolve once instead of 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 + + _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: + 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, + max_flood_sleep: float | 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). ``max_flood_sleep`` + overrides the shape-based sleep cap (e.g. fan-out callers that must + fail-count fast instead of riding out a long throttle). + """ + 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 + if max_flood_sleep is not None: + cap = max_flood_sleep + else: + cap = ( + MAX_FLOODWAIT_SLEEP_MEDIA_SECONDS + if getattr(fn, "__name__", "") in _UNBOUNDED_SHAPES + else MAX_FLOODWAIT_SLEEP_SECONDS + ) + sleep_for = min(e.value, cap) + logger.debug( + f"FloodWait in {getattr(fn, '__name__', fn)}, " + f"sleeping {sleep_for}s (asked {e.value}s, attempt {attempt}/{retries})" + ) + await asyncio.sleep(sleep_for) + # every other exception propagates unchanged (fail-closed callers + # classify the error; broadcast counts it; routes map it) + + +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", + "MAX_FLOODWAIT_SLEEP_SECONDS", + "MAX_FLOODWAIT_SLEEP_MEDIA_SECONDS", +] diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py index 8406133..83409c9 100644 --- a/Thunder/utils/shortener.py +++ b/Thunder/utils/shortener.py @@ -1,13 +1,26 @@ -# Thunder/utils/shortener.py +"""URL shortener. + +aiohttp HTTP layer. 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: Bearer header; path/query-key +providers keep their documented schemes). +""" 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 ShortenerPlugin(ABC): @@ -17,67 +30,122 @@ 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.""" + try: + # 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 + + @staticmethod + def _host_matches(domain: str, *bases: str) -> bool: + """Exact-host or subdomain match against the provider's hostnames. + + Substring checks accept lookalikes (``bitly.com.evil.com``); parsing + the hostname closes them. 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, 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 cls._host_matches(domain, "bitly.com", "bit.ly") - 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"] + allow_redirects=False, # a 30x can never pass for a short URL + ) as resp: + if resp.status == 200: + data = await resp.json() + short = data.get("link") + # Bitly answers on its own hosts (bit.ly / bitly.com), never on + # the configured site: validate against the provider hosts + if ( + short + and short != url + and any(self._validate_short_url(short, d) for d in ("bit.ly", "bitly.com")) + ): + return short return url class OuoIoPlugin(ShortenerPlugin): @classmethod 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 + return cls._host_matches(domain, "ouo.io") + + 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}, allow_redirects=False + ) as resp: + if resp.status == 200: + text = (await resp.text()).strip() + if text and self._validate_short_url(text, domain): + return text return url class CuttLyPlugin(ShortenerPlugin): @classmethod 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"] + return cls._host_matches(domain, "cutt.ly") + + 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}, + allow_redirects=False, + ) 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,80 +154,156 @@ 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: + # provider-mandated wire format: shrinkme-style generic APIs take the + # key as a query param (cuttly/ouo keep their own documented schemes) + async with session.get( + f"https://{domain}/api", + params={"api": api_key, "url": url}, + allow_redirects=False, + ) 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] = {} + self._init_lock = asyncio.Lock() + + @staticmethod + def _normalize_site(site: str) -> str: + """Operators paste full URLs; plugins need a bare host (port kept).""" + cleaned = site.strip() + if "://" not in cleaned: + cleaned = f"https://{cleaned}" + parsed = urlparse(cleaned) + host = parsed.hostname or "" + if parsed.port: + host = f"{host}:{parsed.port}" + return host 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 + # lock: concurrent first use 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 + 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 = ShortenerSystem._normalize_site(Var.URL_SHORTENER_SITE) + api_key = Var.URL_SHORTENER_API_KEY - if not (site and api_key): - return False + 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 rejects + # allow_redirects on the session constructor (TypeError). + self.domain = site + plugin_class = self._get_plugin_class(site) + self.plugin = plugin_class() + self.ready = True + logger.info(f"Shortener ready (plugin={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: - self.session = await asyncio.to_thread( - cloudscraper.create_scraper, - browser={ - 'browser': 'chrome', - 'platform': 'windows', - 'desktop': True, - 'mobile': False - }, - delay=1 + short = await self.plugin.shorten( + self.session, url, Var.URL_SHORTENER_API_KEY, self.domain ) - - plugin_class = self._get_plugin_class(site) - self.plugin = plugin_class() - self.plugin.session = self.session - self.plugin.domain = site - self.ready = True - return True + 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"Failed to initialize ShortenerSystem: {e}", exc_info=True) - return False + 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 BaseException as e: + # CancelledError is BaseException: without this, a cancelled runner hangs every waiter. + if not future.done(): + 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) + + async def close(self) -> None: + if self.session and not self.session.closed: + await self.session.close() + # drop everything: a post-close call must re-initialize, not reuse a + # dead session or silently serve stale cache entries + self.session = None + self.plugin = None + self.domain = "" + self.ready = False _system = ShortenerSystem() +async def close_shortener() -> None: + """Shutdown hook: close the shared aiohttp session.""" + await _system.close() + + async def shorten(url: str) -> str: if not _system.ready: await _system.initialize() diff --git a/Thunder/utils/speedtest.py b/Thunder/utils/speedtest.py deleted file mode 100644 index bdd94cc..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/Thunder/utils/time_format.py b/Thunder/utils/time_format.py index 6900312..d50fd21 100644 --- a/Thunder/utils/time_format.py +++ b/Thunder/utils/time_format.py @@ -1,17 +1,16 @@ -# 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" +from Thunder.utils.logger import logger + +_TIME_PERIODS = (("d", 86400), ("h", 3600), ("m", 60), ("s", 1)) + + +def get_readable_time(seconds: int | float) -> 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 index 7602680..155eb44 100644 --- a/Thunder/utils/tokens.py +++ b/Thunder/utils/tokens.py @@ -1,160 +1,232 @@ -# 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 +import asyncio +import random +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any + +from Thunder.utils.database import db +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)) + + +def _as_aware_utc(value: Any) -> datetime | None: + """Normalize a DB-loaded expiry to aware UTC. + + Legacy rows may carry naive datetimes (pre-tz_aware driver decodes of + UTC instants) or non-datetime junk. Naive → assume UTC (BSON stores UTC + millis); anything else → None so the caller fails closed instead of + raising TypeError mid-activation (no operator data migration needed). + """ + if isinstance(value, datetime): + if value.tzinfo is None: + return value.replace(tzinfo=UTC) + return value + return None + + +async def check(user_id: int) -> bool: + """Token/authorization gate (cached, fail-closed).""" + try: + if not Var.TOKEN_ENABLED: + return True + if user_id == Var.OWNER_ID: + return True + # cached authorized-user lookup (5 min TTL) + if await allowed(user_id): + return True + return await flags.get_or_load( + ("token_ok", user_id), + lambda: _load_token_ok(user_id), + ) + 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.now(UTC)}, "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.now(UTC)}, + }, + {"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) + max_retries = 3 + base_delay = 0.5 + for attempt in range(max_retries): + try: + ttl_hours = Var.TOKEN_TTL_HOURS + created_at = datetime.now(UTC) + 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 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 + # The loop always returns or raises on its final attempt; crash loudly + # 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 + + +async def consume(token: str, user_id: int) -> tuple[str, float]: + """Atomically activate a token. + + ``find_one_and_update`` conditioned on ``activated != True``: exactly one + concurrent activation can win. Returns ``(status, hours_valid)`` with + status one of ``"ok" | "already" | "wrong_user" | "invalid"``. + """ + now = datetime.now(UTC) + try: + 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 + if doc.get("expires_at"): + expires_at_loaded = _as_aware_utc(doc["expires_at"]) + if expires_at_loaded is None: + # corrupt expiry shape: fail closed, never activate, never 500 + logger.warning(f"Ignoring token with non-datetime expires_at for user {user_id}.") + return "invalid", 0.0 + if expires_at_loaded <= now: + # a stale deep-link must not activate a token that expired before the CAS + 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}, + "expires_at": {"$gt": now}, + }, + { + "$set": { + "activated": True, + "activated_at": now, + "created_at": now, + "expires_at": expires_at, + } + }, + return_document=True, + ) + if activated_doc is None: + # Lost the CAS race: distinguish a concurrent activation (doc now + # activated) from a doc that expired between pre-check and 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)") + return "ok", hours + except Exception as e: + logger.error(f"Error in consume for user {user_id}: {e}", exc_info=True) + raise + + +async def allowed(user_id: int, *, raise_on_error: bool = True) -> bool: + """Cached authorized-user check. With ``raise_on_error=True`` (default) + a DB failure raises so fail-closed callers deny; with False it returns False.""" + return await flags.get_or_load( + ("allowed", user_id), + # delegate: two copies of the same existence check would drift + lambda: db.is_user_authorized(user_id, raise_on_error=raise_on_error), + ) + + +async def authorize(user_id: int, authorized_by: int) -> bool: + try: + auth_data = { + "user_id": user_id, + "authorized_by": authorized_by, + "authorized_at": datetime.now(UTC), + } + await db.authorized_users_col.update_one( + {"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 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 cleanup_expired_tokens() -> int: + try: + 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") + 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 index 75090ac..6a5db7e 100644 --- a/Thunder/vars.py +++ b/Thunder/vars.py @@ -1,102 +1,257 @@ -# 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")) +"""Central configuration. + +Loads ``config.env`` then ``config.env.local`` (local layer wins), validates +all variables and reports every problem together before failing, enforces +bounds, and hard-fails on missing ``OWNER_ID`` (H7). The ``Var`` facade is +kept so no import site changes. +""" + +import os + +from dotenv import dotenv_values + +from Thunder.utils.logger import logger + + +def _load_env_layers() -> None: + """Load ``config.env`` then ``config.env.local`` with real precedence. + + Precedence: real environment > config.env.local > config.env. + + ``THUNDER_SKIP_CONFIG_FILES=1`` disables both files so test tiers stay + hermetic against a developer's own config.env. + """ + if os.environ.get("THUNDER_SKIP_CONFIG_FILES") == "1": + return + 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) + if "APP_VERSION" in merged: + # __init__ snapshots APP_VERSION at import, before this loader runs -- + # a file value is silently ignored, so say so instead of lying + logger.warning("APP_VERSION in a config file is ignored; set it in the real environment.") + + +_load_env_layers() + + +def str_to_bool(val: str) -> bool: + # strip: raw env values can carry trailing whitespace/CR + return str(val).strip().lower() in ("true", "1", "t", "y", "yes") + + +_config_errors: list[str] = [] +_config_warnings: list[str] = [] +# vars whose parse already failed: _require must not pile a second, +# misleading error onto the fallback value +_parse_failed: set[str] = set() + + +def str_to_int_set(val: str) -> set[int]: + if not val: + return set() + result: set[int] = set() + bad: list[str] = [] + for x in val.split(): + try: + result.add(int(x)) + except (TypeError, ValueError): + # collect-all-errors: junk tokens surface as one error, not N + bad.append(x) + continue + if bad: + _config_errors.append(f"BANNED_CHANNELS={val!r} has non-integer entries: {bad!r}") + return result + + +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()) + except (TypeError, ValueError): + _config_errors.append(f"{name}={raw!r} is not a valid integer") + _parse_failed.add(name) + 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_optional_int(name: str) -> int | None: + raw = os.getenv(name, "").strip() + if not raw: + return None + try: + return int(raw) + except (TypeError, ValueError): + _config_errors.append(f"{name}={raw!r} must be an integer") + return None + + +def _get_float(name: str, default: str, *, min_val: float | None = 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") + _parse_failed.add(name) + 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 name in _parse_failed: + return # parse error already reported; don't double-count the fallback + 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_HASH, "API_HASH", "app hash from my.telegram.org") + _require(BOT_TOKEN, "BOT_TOKEN", "bot token from @BotFather") + + NAME: str = os.getenv("NAME", "ThunderF2L") + # pyrofork auto-sleeps FloodWait <= this threshold inside the RPC; waits + # above it surface to tg_call's bounded retry. Keep well below + # TG_RPC_TIMEOUT_SECONDS or auto-slept waits blow the per-RPC budget. + SLEEP_THRESHOLD: int = _get_int("SLEEP_THRESHOLD", "10", 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") # 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")) + + # missing OWNER_ID is fatal -- every owner check would match nobody. + OWNER_ID: int = _get_int("OWNER_ID", "0", min_val=1) + + FQDN: str = os.getenv("FQDN", "").strip() or BIND_ADDRESS + if not os.getenv("FQDN", "").strip(): + _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", "")) + + FORCE_CHANNEL_ID: int | None = _get_optional_int("FORCE_CHANNEL_ID") + + 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) + + # ---- Feature knobs ---- + + # allowlist mode -- only owner + authorized users may use the bot. + PRIVATE_MODE: bool = str_to_bool(os.getenv("PRIVATE_MODE", "False")) + + # legacy /watch/{hash}{id} URL family (default on; removal planned). + ENABLE_LEGACY_LINKS: bool = str_to_bool(os.getenv("ENABLE_LEGACY_LINKS", "True")) + + # /shell kill-switch -- powerful owner command is opt-in. + ENABLE_SHELL: bool = str_to_bool(os.getenv("ENABLE_SHELL", "False")) + + # optional expiry for file records (0 = keep forever). + FILE_TTL_DAYS: int = _get_int("FILE_TTL_DAYS", "0", min_val=0, max_val=3650) + + # queue executor worker pool. + EXECUTOR_WORKERS: int = _get_int("EXECUTOR_WORKERS", "5", min_val=1, max_val=32) + + # 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) + + # per-client admission cap for the streaming server. + MAX_CONCURRENT_STREAMS: int = _get_int("MAX_CONCURRENT_STREAMS", "8", min_val=1) + + # 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) + + # default wall-clock budget for lightweight Telegram RPCs. + TG_RPC_TIMEOUT_SECONDS: float = _get_float("TG_RPC_TIMEOUT_SECONDS", "30", min_val=0) + + +if Var.TG_RPC_TIMEOUT_SECONDS > 0 and Var.SLEEP_THRESHOLD >= Var.TG_RPC_TIMEOUT_SECONDS: + _config_warnings.append( + "SLEEP_THRESHOLD should stay below TG_RPC_TIMEOUT_SECONDS or auto-slept " + "waits blow the per-RPC budget." + ) + + +# with both gates on, the private-mode gate rejects a token activation +# deep-link before /start's consume path runs -- token 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:") + 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.debug(f"Gate mode: {mode_note}; legacy links: {'on' if Var.ENABLE_LEGACY_LINKS else 'off'}") diff --git a/app.json b/app.json new file mode 100644 index 0000000..ed64996 --- /dev/null +++ b/app.json @@ -0,0 +1,48 @@ +{ + "name": "Thunder FileToLink", + "description": "Telegram files to direct HTTP links.", + "repository": "https://github.com/fyaz05/FileToLink", + "stack": "container", + "formation": { + "web": { + "quantity": 1, + "size": "basic" + } + }, + "image": "fyaz05/thunder", + "env": { + "API_ID": { + "description": "Telegram API ID (my.telegram.org).", + "required": true + }, + "API_HASH": { + "description": "Telegram API hash.", + "required": true + }, + "BOT_TOKEN": { + "description": "Bot token (@BotFather).", + "required": true + }, + "BIN_CHANNEL": { + "description": "Storage channel ID (bot must be admin).", + "required": true + }, + "OWNER_ID": { + "description": "Owner user ID (boot refuses without it).", + "required": true + }, + "DATABASE_URL": { + "description": "MongoDB connection string (no Heroku addon; use external).", + "required": true + }, + "FQDN": { + "description": "Public domain for generated links.", + "required": false + }, + "HAS_SSL": { + "description": "HTTPS enabled.", + "value": "True", + "required": false + } + } +} diff --git a/config_sample.env b/config_sample.env index b83b434..20d0581 100644 --- a/config_sample.env +++ b/config_sample.env @@ -1,125 +1,177 @@ -# ============================================================= -# 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. +# A config.env.local overrides config.env for local-only tweaks. + +## REQUIRED SETTINGS + +# Telegram API credentials (from https://my.telegram.org/apps) +API_ID=0 # Example: 1234567 (min 1; 0 placeholder fails boot validation by design) +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 ID (get it from @userinfobot). Required: boot refuses without it. +OWNER_ID=0 # Example: 123456789 (min 1; 0 placeholder fails boot validation by design) + +# Database connection string +DATABASE_URL="" # Example: "mongodb+srv://user:pass@host/db" + +# Deployment configuration +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 (1-65535) +NO_PORT="True" # Hide port in URLs ("True" or "False") + +## OPTIONAL SETTINGS + +MAX_BATCH_FILES=50 # 1-100 + +# 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 (empty = unset; non-integer fails boot) + +# 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; empty = none; non-integer fails boot) + +# Multiple bot tokens: MULTI_TOKEN1, MULTI_TOKEN2, ... (any MULTI_TOKEN* prefix, +# sorted by numeric suffix; empty/whitespace values ignored). +MULTI_TOKEN1="" + +## ACCESS GATES + +# Allowlist mode. When True only OWNER_ID + authorized users can use +# the bot (default False = public). Boot log states the active gate mode. +# Do not enable both PRIVATE_MODE and TOKEN_ENABLED — boot refuses +# (token activation links are unreachable under private-mode). +PRIVATE_MODE="False" + +# Token system: require activation before use (True/False) +TOKEN_ENABLED="False" + +# Token validity in hours (min 1) +TOKEN_TTL_HOURS="24" + +## URL SHORTENER SETTINGS + +# Both URL_SHORTENER_SITE + URL_SHORTENER_API_KEY are required when either +# SHORTEN_* is True; otherwise a warning is logged and links are not shortened. +# 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" + +# Max requests across all users, per minute (min 1) +MAX_GLOBAL_REQUESTS_PER_MINUTE=4 + +# 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 + +# Enable rate limiting (True/False) +RATE_LIMIT_ENABLED="False" + +MAX_FILES_PER_PERIOD=2 # (min 1) + +# Window, minutes (min 1) +RATE_LIMIT_PERIOD_MINUTES=1 + +# Max queued requests (min 1) +MAX_QUEUE_SIZE=100 + +# Queue executor workers, processed concurrently (1-32) +EXECUTOR_WORKERS=5 + +# Worker pools (1-16 each) +BROADCAST_WORKERS=4 +BATCH_WORKERS=5 + +## STREAM SERVER SETTINGS + +# Max concurrent streams per Telegram client (min 1); extra requests get +# 503 + Retry-After instead of queueing on an overloaded client. +MAX_CONCURRENT_STREAMS=8 + +## FILE LIFECYCLE + +# Expire file records after N days of inactivity (TTL index, 0-3650); re-upload recreates. +# 0 = keep forever. +FILE_TTL_DAYS=0 + +# Touch-buffer flush cadence + cap (bounded memory) +TOUCH_FLUSH_SECONDS=3 # 1-60 +TOUCH_BUFFER_MAX=1000 # min 100 + +## SECURITY SWITCHES + +# /shell command is DISABLED by default. Set True only on trusted +# deployments; it stays owner-only regardless. +ENABLE_SHELL="False" + +# Legacy /watch/<6-char-hash> URLs. Default True keeps historical links +# working; False forces the canonical /f// family (legacy URLs 410). +ENABLE_LEGACY_LINKS="True" + +## UPDATE SETTINGS + +# Git repository for updates (read by update.py; best-effort — guards skip +# silently, never hard-fail boot; passed to `git pull --ff-only` as an argv +# element, never through a shell) +UPSTREAM_REPO="" # Example: "https://github.com/fyaz05/FileToLink" (empty = skip self-update) + +# Branch to update from (read by update.py; best-effort, never hard-fails boot) +UPSTREAM_BRANCH="main" # Default branch for updates + +## OBSERVABILITY + +# Log level (DEBUG/INFO/WARNING/ERROR) and format (plain/json). +# /log uploads are redacted (bot tokens, Mongo URIs) before leaving. +# Unknown LOG_LEVEL falls back to INFO; unknown LOG_FORMAT falls back to plain. +# env-only (Docker -e / systemd); config.env cannot set this — snapshots before config load +LOG_LEVEL="INFO" +LOG_FORMAT="plain" + +# Wall-clock budget (seconds) for lightweight Telegram RPCs (min 0). +# File transfers (copy/upload/stream) are unbounded by default. +TG_RPC_TIMEOUT_SECONDS=30 + +## ADVANCED SETTINGS (modify with caution) + +# Application name +NAME="ThunderF2L" # Bot application name + +# Performance settings +SLEEP_THRESHOLD=10 # pyrofork auto-sleep ceiling for FloodWait (s, min 0); keep below TG_RPC_TIMEOUT_SECONDS +WORKERS=8 # Number of worker processes (1-64) + +# Web server configuration +BIND_ADDRESS="0.0.0.0" # Listen on all network interfaces +PING_INTERVAL=840 # Ping interval in seconds (health check based, min 30) + +# Keepalive probe target override: only set when /health lives on a different +# host/interface. PaaS anti-sleep: point it at your PUBLIC URL (e.g. +# your-app.onrender.com) so the periodic ping generates external traffic. +#KEEPALIVE_HOST="127.0.0.1" + +# Build-time version stamp (optional; injected by CI, safe to leave unset). +# env-only (Docker -e / systemd); config.env cannot set this — snapshots before config load +#APP_VERSION="2.2.0" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a3394d1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,25 @@ +# Local dev stack: app + MongoDB for manual testing. +# Unit/integration tiers use testcontainers instead; this file is for humans. +# Needs config.env next to this file (copy config_sample.env first). +services: + mongo: + image: mongo:7.0 + restart: unless-stopped + volumes: + - mongo-data:/data/db + + app: + build: . + restart: unless-stopped + depends_on: + - mongo + ports: + - "8080:8080" + env_file: + - config.env + environment: + DATABASE_URL: mongodb://mongo:27017/thunder + # BIND_ADDRESS / PORT come from config.env; keep PORT=8080 here. + +volumes: + mongo-data: diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..89043bc --- /dev/null +++ b/docs/API.md @@ -0,0 +1,62 @@ +# HTTP API + +Base URL: `Var.URL` (built from `FQDN`/`HAS_SSL`/`PORT`/`NO_PORT`). All file +URLs are capability links — anyone with the URL can download. + +## Routes + +| Method | Path | Description | +|---|---|---| +| `GET`, `HEAD` | `/health` | Liveness, dependency-free (`{"status":"ok"}`, `no-store`) | +| `GET`, `HEAD` | `/` | Redirect to the project repo | +| `GET`, `HEAD` | `/status` | Telemetry (`Cache-Control: no-store`): version, uptime, bot username, client count, per-client inflight + DC | +| `GET` | `/activate/{token}` | Redirects to `https://t.me/?start=`; never consumes (burn happens in `/start`) | +| `GET`, `HEAD` | `/watch/f/{hash}/{name}` | Player page (20- or 32-hex hash, side-by-side forever) | +| `GET`, `HEAD` | `/watch/{path}` | Player page (legacy links, name optional; `ENABLE_LEGACY_LINKS=False` → `410`) | +| `GET`, `HEAD` | `/f/{hash}/{name}` | Byte stream (canonical); `HEAD` holds an admission ticket so probes reflect load | +| `GET`, `HEAD` | `/{path}` | Byte stream (legacy links; same `410` gate) | +| `OPTIONS` | `/{path}` | CORS preflight (`200`; path must be non-empty) | + +## Link families + +- Canonical: `/f/<32-hex>/` (+ `/watch/f/...` player). 20-hex hashes from + older uploads validate side-by-side forever. +- Legacy: `/watch//` (player) and `//` + (bytes). Disable via `ENABLE_LEGACY_LINKS=False`. +- Filenames are slash-normalized (`/` → `_`) then percent-encoded; +- `?disposition=inline` streams in-browser, anything else (or absent) downloads + as `attachment` (`filename` + RFC 5987 `filename*`). + +## Ranges (RFC 7233) + +`Range: bytes=-` → `206` + `Content-Range`. Oversized ends clamp; +suffix ranges (`bytes=-N`) serve the tail; full-file `0-(size-1)` normalizes to +`200` without `Content-Range`. Garbage/multi-range → `400` (deliberate, stricter +than RFC 9110's ignore); unsatisfiable → `416` + `Content-Range: bytes */`. + +## Errors + +| Status | When | Headers | +|---|---|---| +| `400` | malformed range / token (bad hash shape → `404`) | CORS, `no-store` | +| `404` | unknown hash, evicted stale record, zero-size file | `no-store`, CORS | +| `410` | legacy links with `ENABLE_LEGACY_LINKS=False` | `no-store`, CORS | +| `416` | unsatisfiable range | `Content-Range`, CORS, `no-store` | +| `500` | unexpected failure (error-id body, no internals) | CORS, `no-store` | +| `503` | no free client (`Retry-After: 2`) / pool saturated (`Retry-After: 2`) / index, Telegram, or startup brownout (`Retry-After: 5`) | `no-store`, CORS | + +Bodies are fixed wordings per site (capacity vs brownout variants; `500`s carry +a support error-id, never internals) — retries key off status + `Retry-After`, +never body text. Transient vault/Telegram failures never delete file records +(only a confirmed absence self-heals to `404`). Mid-stream transport failures +truncate the body (headers already sent) — resume with `Range`. + +## Caching / CORS / robots + +- Bytes: `Cache-Control: public, max-age=31536000` + `Accept-Ranges: bytes`. + Every error is `no-store` so caches never pin a failure. +- `Access-Control-Allow-Origin: *` (allowed: `Range, Content-Type, *`; + `Content-Length, Content-Range, Content-Disposition` exposed). + No credentials — the link itself is the secret. +- Player pages: `` + + `X-Robots-Tag: noindex, nofollow`. diff --git a/docs/TUNING.md b/docs/TUNING.md new file mode 100644 index 0000000..a0f307c --- /dev/null +++ b/docs/TUNING.md @@ -0,0 +1,49 @@ +# Tuning + +Start with defaults. Touch knobs only with a measured reason (see +`scripts/bench.py` for the local harness). + +## Capacity rule + +Peak Telegram RPC ≈ `active streams × per-stream concurrency`. Size +`MAX_CONCURRENT_STREAMS` so `clients × cap` fits the host's file descriptors +and upstream patience — not the other way round. + +| Host | EXECUTOR / BATCH / BROADCAST | Streams/client | Notes | +|---|---|---|---| +| Small VPS (1 vCPU, 1GB) | `5 / 5 / 4` (defaults) | `8` (default) | Defaults are the small preset | +| Medium VPS (2 vCPU, 4GB) | `8 / 8 / 6` | `12` | Raise only after watching `FLOOD_WAIT` | +| Big box (4+ vCPU, 8GB+) | `12 / 10 / 8` | `16` | Watch Mongo `timeoutMS=5000` pressure first | + +Scale gradually: floods mean back off, not up. A `FloodWait` storm after a +raise is the signal to revert, not to retry harder. + +## Flood guidance + +- Sleeps belong in `tg_call` (bounded, `min(value, 30)`); never sleep a pool + worker inline. Sustained floods should surface as 503 + `Retry-After`, not + silent stalls — check `Retry-After` headers before raising limits. +- Keep pyrofork auto-sleep ceiling (`SLEEP_THRESHOLD`) below + `TG_RPC_TIMEOUT_SECONDS` so waits surface to `tg_call` instead of hiding + inside the library. + +## Rate limiting + +Enable only under abuse: `RATE_LIMIT_ENABLED` + `MAX_FILES_PER_PERIOD` / +`RATE_LIMIT_PERIOD_MINUTES`. Prefer the global breaker (`GLOBAL_RPS_LIMIT`, +burst = 2× rate) for provider-level protection — it shapes instead of +dropping. Queue UX (wait estimates) is protected behavior; don't tune it away. + +## Appendix: validation errors (boot refuses until all are fixed) + +| Symptom (log line) | Cause | Fix | +|---|---|---| +| `API_ID=0 … must be >= 1` / `is required` | placeholder never set | Set real `API_ID` from my.telegram.org | +| `OWNER_ID … min 1` / boot refusal | no owner configured | Set `OWNER_ID` (owner checks would match nobody) | +| `BIN_CHANNEL` / `DATABASE_URL … is required` | missing values | Fill both; links and users need them | +| `… must be >= / <= …` | knob outside its bound | Read the bound in `config_sample.env`, adjust | +| `… is not a valid integer/number` | junk in a numeric knob | Use plain digits (no quotes needed, no units) | +| `PRIVATE_MODE and TOKEN_ENABLED cannot both be enabled` | gates unreachable together | Pick one mode | +| `Shortener enabled but … missing` (warning) | `SHORTEN_*` on without site + key | Set both or turn the feature off | +| `FQDN is not set` (warning) | links fall back to bind address | Set public domain/IP for reachable links | +| `BANNED_CHANNELS=… has non-integer entries` | junk token in the set | Space-separated `-100…` IDs only | diff --git a/heroku.yml b/heroku.yml index 24efeb8..8eec25b 100644 --- a/heroku.yml +++ b/heroku.yml @@ -1,3 +1,3 @@ -build: - docker: - web: Dockerfile +build: + docker: + web: Dockerfile diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..93a263a --- /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", + "pyrofork[speedup]==2.3.69", # upstream pyrofork was archived 2026-09 -- plan the exit to a maintained fork + "pymongo==4.18.1", + "Jinja2==3.1.6", + "python-dotenv==1.2.3", + "psutil==7.2.2", + "uvloop==0.22.1", +] + +[dependency-groups] +dev = [ + "pytest>=8.4", # pytest-asyncio 1.4 requires >=8.4 + "pytest-asyncio>=0.24", + "pytest-cov>=5.0", + "ruff>=0.8", + "mypy>=1.13", + "bandit>=1.8", + "vulture>=2.14", + "pip-audit>=2.7", + # Integration tier only (tests/integration): real MongoDB via Docker. + # >=4.15: tests import testcontainers.community.mongodb (moved in 4.15). + "testcontainers[mongodb]>=4.15", +] + +[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 = true +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" +# Coverage flags are passed by `make test`/CI so the opt-in integration tier +# (`pytest -m integration`) runs without the unit tier's --cov-fail-under threshold. + +[tool.coverage.run] +source = ["Thunder"] +omit = ["Thunder/bot/plugins/*", "Thunder/__main__.py"] + +[tool.vulture] +min_confidence = 80 +paths = ["Thunder"] + +[tool.bandit] +exclude_dirs = ["tests", ".venv"] diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..c7be71c --- /dev/null +++ b/render.yaml @@ -0,0 +1,25 @@ +# Render Blueprint: Docker web service. Set the secrets below in the dashboard. +# No managed MongoDB on Render — point DATABASE_URL at an external cluster. +services: + - type: web + name: thunder-filetolink + runtime: docker + dockerfilePath: ./Dockerfile + healthCheckPath: /health + envVars: + - key: API_ID + sync: false + - key: API_HASH + sync: false + - key: BOT_TOKEN + sync: false + - key: BIN_CHANNEL + sync: false + - key: OWNER_ID + sync: false + - key: DATABASE_URL + sync: false + - key: FQDN + sync: false + - key: HAS_SSL + value: true diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..6e5ae77 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,539 @@ +# This file was autogenerated by uv via the following command: +# uv export --frozen --no-dev --hashes -o requirements.lock --python 3.13 +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.1 \ + --hash=sha256:016d21e7fbf55ac89f419daa48da95314896205d902ed9aa4058f26a436e789c \ + --hash=sha256:088e78ab7340d7ab4c14cd7a96852f9ea3b51c25fc9fb02244715d21f57ec2e5 \ + --hash=sha256:0c3f52a1a793262ef1af15b36535220d194b3ad5ce7564f98c15f3881ffbc269 \ + --hash=sha256:134a8f787360c3cea18cbbc126bf51cd233f1789db8099f7081ace5b1cff7e22 \ + --hash=sha256:173a69caa539731284a815ee2fd616442431d12e8c84d970f3cc555d9ac5ef81 \ + --hash=sha256:1a78dec1c10eaae298aefc6cd7ad916ca2ceeb51f8ff7946dddbca97adf9ed0c \ + --hash=sha256:1eaed0c72bba39fa42e6167636d56587e30cdab13ec3178a68ac85bee093d44b \ + --hash=sha256:305877cd97e4344fde699c8721e4cc9890a689990bd1a1783eb5d0946e093d13 \ + --hash=sha256:4541273796aa3022b39de892e265aac4a437adcc6e4894396c23258d990f8c97 \ + --hash=sha256:4a787cd29b6bc5735ced10aba4ec052d4ef2d2a82b4875efa8539ecd635e720b \ + --hash=sha256:4b8f7b212ab86552db2f35b2779cc290561a341b73fba1ab1030ed9d48e1b1e5 \ + --hash=sha256:4ebb5a6a42a1512a86f774ca517b4b56aacd0ccbde535d787cfd9789c5905e2a \ + --hash=sha256:55309ea9f6d8f697618f12f1ea2b643f26df434de043cd5de6f96426093011b3 \ + --hash=sha256:58c3392585c5823ad90e318b6e7c0fb0709763631fb06d81de45166a1844ea18 \ + --hash=sha256:5907ccdcc2f7925100b4bed8069acd0d93156e5cf013a8e15aa5932819ce3d5e \ + --hash=sha256:67d56552a940d3553024fdf39535975c4de2ff5b82cf20287ba1ffef028f004b \ + --hash=sha256:8b46ef2de0b077a2c708790e9ced5c8afb5b9deef1cacba28e8d9a90027e86d2 \ + --hash=sha256:960c61ed86a316d3636b1f184006d3cc2b972b8546186ae8e2c8324e397632ff \ + --hash=sha256:9bf92d46b15fb8fcfcd517442b0b90055924d835fef77e574ff4678b40be49ce \ + --hash=sha256:9da74b79e382bb37311b9bd5beb89f1369a1c3e27e7c97bae79661cfdf3893aa \ + --hash=sha256:a9ae9c28e6c290c2524c3288a0a6e27dca958daeeaba2f36a6a4ee97c44e5f49 \ + --hash=sha256:ab33db087dc52f8f28bdb8680682ebc8fc5fb0be84e89a0853a8dc059fcc06a8 \ + --hash=sha256:adebc03de2d1520bb163953361779288316e4e9c4b66fe4ce9e91d4183e313b8 \ + --hash=sha256:ae18db68105a0ba7adc190a13cbd73e6c87593bc55a336719f70ad042aa66aaf \ + --hash=sha256:aff3582edd807980e0f910cc20b3f588b8c5533556b0aeb3f33d88c7681f9b21 \ + --hash=sha256:b2ef166249993afd94b704bd25af27104fccb259580960a41355f8d7869496ed \ + --hash=sha256:d04134ff4ea0e7d14b6e2ef23dbfe06112a99318c9578b213d2dee9060b23dce \ + --hash=sha256:d1e63d2f8770f0cfb74c7c3fbe35c6b2828f27a16395a7c4019c81451e61489e \ + --hash=sha256:d9be9221bf2db1562d490bb67522ce2f7378513bc8bcc157e824a0bb3d87eba0 \ + --hash=sha256:f0a6b59282941d2f6f62acf35fb75bae371d3194ab041f0358446668f9df10be \ + --hash=sha256:fbef9ef64cdae97e85b27cf00e203a26a016f7c803dc9bd0ccd48b3adade1232 + # 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 pyrofork +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 + # pyrofork + # 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 diff --git a/requirements.txt b/requirements.txt index 0ab99f4..0fc0c97 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,9 @@ -aiohttp -cloudscraper -Jinja2 -pyrofork -pymongo -psutil -python-dotenv -speedtest-cli -tgcrypto -uvloop==0.21.0 +# Human-readable export of pyproject [project.dependencies] (H1); CI keeps this +# set-equal to pyproject. Docker consumes requirements.lock (hash-pinned uv.lock export). +aiohttp==3.14.3 +pyrofork[speedup]==2.3.69 +pymongo==4.18.1 +Jinja2==3.1.6 +python-dotenv==1.2.3 +psutil==7.2.2 +uvloop==0.22.1 diff --git a/scripts/bench.py b/scripts/bench.py new file mode 100644 index 0000000..c20aafa --- /dev/null +++ b/scripts/bench.py @@ -0,0 +1,67 @@ +"""Throughput micro-benchmarks (hermetic: no network, no Mongo, no Telegram). + +Not wired into CI — numbers vary by machine. Run from the repo root: + + .venv\\Scripts\\python.exe scripts\\bench.py + +Compares hot-path costs across refactors; absolute values matter less than +ratios. Keep each bench under a few seconds. +""" + +import asyncio +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +os.environ.update( + { + "API_ID": "1", + "API_HASH": "x" * 32, + "BOT_TOKEN": "1:abc", + "BIN_CHANNEL": "-1001", + "OWNER_ID": "1", + "DATABASE_URL": "mongodb://localhost:27017/x", + "THUNDER_SKIP_CONFIG_FILES": "1", + } +) + +from Thunder.server.stream_routes import parse_range_header, validate_public_hash # noqa: E402 +from Thunder.utils.human_readable import humanbytes # noqa: E402 +from Thunder.utils.render_template import render_media_page # noqa: E402 + + +def bench(name, fn, iters=20000): + fn() # warmup + start = time.perf_counter() + for _ in range(iters): + fn() + dt = time.perf_counter() - start + print(f"{name:28s} {iters / dt:12,.0f} ops/s") + return iters / dt + + +def main() -> None: + print("Thunder micro-benchmarks (hermetic)") + bench("parse_range_header", lambda: parse_range_header("bytes=0-1048575", 10 * 1024 * 1024)) + bench("validate_public_hash", lambda: validate_public_hash("a" * 32)) + bench("humanbytes", lambda: humanbytes(10 * 1024 * 1024), iters=50000) + + async def _page(): + return await render_media_page( + "v.mp4", "https://h/f/x/v.mp4", mime_type="video/mp4", size_bytes=1024 + ) + + async def _pages(n=200): + for _ in range(n): + await _page() + + start = time.perf_counter() + asyncio.run(_pages()) + dt = time.perf_counter() - start + print(f"{'render_media_page':28s} {200 / dt:12,.0f} ops/s") + + +if __name__ == "__main__": + main() 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..210eb44 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,65 @@ +"""Bootstrap a valid fake environment BEFORE any Thunder import.""" + +import os +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(REPO_ROOT)) + +# Hard overrides (not setdefault): must beat any CI/host-leaked env for hermeticity. +_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", + "PRIVATE_MODE": "False", + "TOKEN_ENABLED": "False", + "SHORTEN_ENABLED": "False", + "SHORTEN_MEDIA_LINKS": "False", + "FILE_TTL_DAYS": "0", + "RATE_LIMIT_ENABLED": "False", + "GLOBAL_RATE_LIMIT": "False", +} +for _key, _value in _required.items(): + os.environ[_key] = _value + +# Hermeticity: never read the developer's config.env in-process (optional knobs +# like PRIVATE_MODE would leak into assertions); subprocess precedence tests opt back out. +os.environ["THUNDER_SKIP_CONFIG_FILES"] = "1" + + +@pytest.fixture(autouse=True) +def _restore_singletons(): + """Snapshot mutable singletons so one test's tuning can't leak into the next.""" + import copy + + # lazy imports: avoid import cycles at conftest import time + import Thunder.utils.rate_limiter as _rl_mod + import Thunder.vars as _vars_mod + + _rl = _rl_mod.rate_limiter + _saved_rl_dict = copy.copy(_rl.__dict__) + _saved_user_requests = {k: copy.copy(v) for k, v in _rl.user_requests.items()} + _saved_global_requests = copy.copy(_rl.global_requests) + _saved_breaker_dict = copy.copy(_rl.breaker.__dict__) + _saved_errors_len = len(_vars_mod._config_errors) + try: + yield + finally: + _rl.__dict__.clear() + _rl.__dict__.update(_saved_rl_dict) + _rl.user_requests.clear() + _rl.user_requests.update(_saved_user_requests) + _rl.global_requests.clear() + _rl.global_requests.extend(_saved_global_requests) + _rl.breaker.__dict__.clear() + _rl.breaker.__dict__.update(_saved_breaker_dict) + del _vars_mod._config_errors[_saved_errors_len:] 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..bf5ae80 --- /dev/null +++ b/tests/integration/test_mongo.py @@ -0,0 +1,158 @@ +"""Real-MongoDB tier via testcontainers; opt in with `pytest -m integration` (needs Docker).""" + +import os +from datetime import UTC + +import pytest + +pytestmark = pytest.mark.integration + +docker_unavailable = True + +try: # pragma: no cover - environment-dependent + # source: testcontainers>=4.15 moved the MongoDB container here (see pyproject dev group) + from testcontainers.community.mongodb import MongoDbContainer as MongoContainer + + docker_unavailable = False +except ImportError: + MongoContainer = None + + +@pytest.fixture(scope="module") +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: + yield mongo + + +@pytest.fixture(scope="module") +def db(mongo_container): + import Thunder.utils.database as database_module + import Thunder.utils.tokens as tokens_module + + # Rebind a fresh Database: unit-tier imports cache Thunder.vars in sys.modules + # and `from ... import db` copies froze the old instance in every consumer. + 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 + assert await db.ensure_indexes(raise_on_error=True) is True + + # 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.now(UTC), + "expires_at": datetime.now(UTC) + timedelta(hours=1), + } + ) + 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: a FILE_TTL_DAYS change between boots must drop+recreate + the TTL index without aborting the remaining unique-index ensures, and legacy rows + must be stamped with last_seen_at before the index first activates.""" + 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() + + +# slow: TTL-monitor ~60s (polls up to 90s for Mongo's ~60s TTL pass) +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() 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_access_log.py b/tests/test_unit/test_access_log.py new file mode 100644 index 0000000..e1ed94f --- /dev/null +++ b/tests/test_unit/test_access_log.py @@ -0,0 +1,120 @@ +"""Access-log middleware: path pseudonymization + log-forging escape.""" + +import pytest + +import Thunder.server as server_mod +from Thunder.server import _escape_control_chars, _redact_path + +# 6 alnum + 2 trailing digits: the old id-first rule used to re-match this +# shape and hash it 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_bare_legacy_segment_suffixed(fake_hash): + # Regression: the no-filename legacy URL is served by the + # catch-all route; its bare hash+id is the whole link credential + out = _redact_path("/AbCdEf12345") + assert out == f"/{_FAKE_PSEUDONYM}…" + assert fake_hash == ["AbCdEf12345"] + + +@pytest.mark.unit +def test_bare_id_only_segment_suffixed(fake_hash): + # id-first family without filename (hash rides in the query string) + out = _redact_path("/12345678") + assert out == f"/{_FAKE_PSEUDONYM}…" + assert fake_hash == ["12345678"] + + +@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 diff --git a/tests/test_unit/test_backfill.py b/tests/test_unit/test_backfill.py new file mode 100644 index 0000000..b4752b9 --- /dev/null +++ b/tests/test_unit/test_backfill.py @@ -0,0 +1,111 @@ +"""Review fix regression: the last_seen_at backfill must converge across +boots (persisted cursor + unstamped-only pages), not restart every boot.""" + +import pytest + +from Thunder.utils.database import Database + + +class _FakeCursor: + def __init__(self, docs: list[dict]): + self._docs = docs + + def sort(self, *args, **kwargs): # noqa: ARG002 - pymongo chain shape + return self + + async def to_list(self, limit: int) -> list[dict]: + return self._docs[:limit] + + +class _FakeFilesCol: + """Minimal AsyncCollection stand-in: find + update_many only.""" + + name = "files" + + def __init__(self, docs: list[dict]): + self.docs = docs + + def find(self, flt: dict, projection=None): # noqa: ARG002 + unstamped_only = ( + isinstance(flt.get("last_seen_at"), dict) + and flt["last_seen_at"].get("$exists") is False + ) + gt = flt.get("_id", {}).get("$gt") if isinstance(flt.get("_id"), dict) else None + out = [] + for d in self.docs: + if unstamped_only and "last_seen_at" in d: + continue + if gt is not None and not d["_id"] > gt: + continue + out.append({"_id": d["_id"]}) + return _FakeCursor(out) + + async def update_many(self, flt: dict, update: dict) -> int: + ids = set(flt["_id"]["$in"]) + n = 0 + for d in self.docs: + if d["_id"] in ids: + d["last_seen_at"] = update["$set"]["last_seen_at"] + n += 1 + return n + + +class _FakeFlagsCol: + name = "migration_flags" + + def __init__(self): + self.docs: dict[str, dict] = {} + + async def find_one(self, flt: dict) -> dict | None: + return self.docs.get(flt["_id"]) + + async def update_one(self, flt: dict, update: dict, upsert: bool = False) -> None: + doc = self.docs.setdefault(flt["_id"], {"_id": flt["_id"]}) + doc.update(update["$set"]) + + +def _make_db(docs: list[dict]) -> Database: + # __new__: the backfill path touches only the two collections below + db = Database.__new__(Database) + db.files_col = _FakeFilesCol(docs) # type: ignore[assignment] + db.migration_flags_col = _FakeFlagsCol() # type: ignore[assignment] + return db + + +@pytest.mark.unit +async def test_backfill_converges_across_boots(): + # 50_600 unstamped rows vs a 50k-per-boot cap: boot 1 must stop at the + # cap WITHOUT losing its place, boot 2 must finish and mark done + docs = [{"_id": i} for i in range(50_600)] + db = _make_db(docs) + + await db._backfill_file_last_seen() + assert not await db._backfill_done() + assert sum(1 for d in docs if "last_seen_at" in d) == 50_000 + assert await db._get_backfill_cursor() == 49_999 + + await db._backfill_file_last_seen() + assert await db._backfill_done() + assert all("last_seen_at" in d for d in docs) + + +@pytest.mark.unit +async def test_backfill_completes_and_marks_done_in_one_boot(): + docs = [{"_id": i} for i in range(1_200)] # not a batch-size multiple + db = _make_db(docs) + + await db._backfill_file_last_seen() + assert await db._backfill_done() + assert all("last_seen_at" in d for d in docs) + + +@pytest.mark.unit +async def test_backfill_skips_already_stamped_rows(): + docs = [{"_id": 0, "last_seen_at": 1}, {"_id": 1}] + db = _make_db(docs) + + await db._backfill_file_last_seen() + assert await db._backfill_done() + # the stamped row keeps its original value; only the legacy row is touched + assert docs[0]["last_seen_at"] == 1 + assert "last_seen_at" in docs[1] diff --git a/tests/test_unit/test_canonical_claim.py b/tests/test_unit/test_canonical_claim.py new file mode 100644 index 0000000..4c37d93 --- /dev/null +++ b/tests/test_unit/test_canonical_claim.py @@ -0,0 +1,71 @@ +"""Ingest-claim locks (fake-backed): acquire / release / steal-expired / owner-check.""" + +from types import SimpleNamespace + +import pytest +from pymongo.errors import DuplicateKeyError + +from Thunder.utils.database import Database + + +class _FakeLocksCol: + """Minimal stand-in for file_ingest_locks_col: insert_one + find_one_and_update.""" + + def __init__(self): + self.store: dict[str, dict] = {} + + async def insert_one(self, doc: dict): + if doc["_id"] in self.store: + raise DuplicateKeyError("dup") + self.store[doc["_id"]] = dict(doc) + + async def find_one_and_update(self, flt: dict, update: dict, **_kwargs): + from datetime import datetime + + current = self.store.get(flt["_id"]) + if current is None: + return None + expires_at = current.get("expires_at") + if expires_at is not None and expires_at > datetime.now(expires_at.tzinfo): + return None # live claim held by someone else + old = dict(current) + current.update(update["$set"]) + return old + + async def delete_one(self, flt: dict): + current = self.store.get(flt["_id"]) + if current is not None and current.get("owner") == flt.get("owner"): + del self.store[flt["_id"]] + return SimpleNamespace(deleted_count=1) + return SimpleNamespace(deleted_count=0) + + +def _make_claim_db() -> Database: + db = Database.__new__(Database) + db.file_ingest_locks_col = _FakeLocksCol() # type: ignore[assignment] + return db + + +@pytest.mark.unit +async def test_acquire_release_cycle(): + db = _make_claim_db() + owner = await db.acquire_file_ingest_claim("file-1") + assert owner # opaque owner token + # live claim blocks a second worker + assert await db.acquire_file_ingest_claim("file-1") is None + # owner-checked release frees it + assert await db.release_file_ingest_claim("file-1", owner) is True + assert await db.release_file_ingest_claim("file-1", owner) is False + # free again: a new worker can claim + assert await db.acquire_file_ingest_claim("file-1") + + +@pytest.mark.unit +async def test_expired_claim_is_stolen(): + db = _make_claim_db() + owner = await db.acquire_file_ingest_claim("file-2", ttl_seconds=-1) # already expired + thief = await db.acquire_file_ingest_claim("file-2") + assert thief and thief != owner + # the old owner's release must not delete the new worker's claim + assert await db.release_file_ingest_claim("file-2", owner) is False + assert await db.release_file_ingest_claim("file-2", thief) is True diff --git a/tests/test_unit/test_canonical_files.py b/tests/test_unit/test_canonical_files.py new file mode 100644 index 0000000..cfa415b --- /dev/null +++ b/tests/test_unit/test_canonical_files.py @@ -0,0 +1,90 @@ +"""Hash building (dual lengths) + merge precedence.""" + +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(): + # 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 + + +@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.""" + 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 new file mode 100644 index 0000000..dbb539a --- /dev/null +++ b/tests/test_unit/test_config.py @@ -0,0 +1,153 @@ +"""Config validation surfaces all problems; booleans/sets parse.""" + +import os +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(): + import Thunder.vars as vars_mod + + assert str_to_int_set("") == set() + assert str_to_int_set("-100111 -100222") == {-100111, -100222} + before = len(vars_mod._config_errors) + try: + assert str_to_int_set("1 junk 2") == {1, 2} + # junk is surfaced, never silently skipped + assert len(vars_mod._config_errors) == before + 1 + assert "junk" in vars_mod._config_errors[-1] + finally: + del vars_mod._config_errors[before:] + + +@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(): + """Missing OWNER_ID must refuse to boot (nobody had owner access).""" + env = { + k: v + for k, v in os.environ.items() + if not k.startswith( + ("API_", "BOT_TOKEN", "BIN_", "OWNER_", "DATABASE_", "PRIVATE_", "TOKEN_", "SHORTEN_") + ) + } + 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(): + """Three bad vars -> all three named before exit (not first-fail).""" + env = { + k: v + for k, v in os.environ.items() + if not k.startswith( + ( + "API_", + "BOT_TOKEN", + "BIN_", + "OWNER_", + "DATABASE_", + "MAX_BATCH", + "PRIVATE_", + "TOKEN_", + "SHORTEN_", + ) + ) + } + 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" + + +@pytest.mark.unit +def test_version_fallback_matches_pyproject(): + """The __version__ fallback literal must track pyproject on every bump.""" + import tomllib + + import Thunder + + with open(REPO_ROOT / "pyproject.toml", "rb") as f: + assert Thunder.__version__ == tomllib.load(f)["project"]["version"] 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..1f2cb69 --- /dev/null +++ b/tests/test_unit/test_config_env_layers.py @@ -0,0 +1,69 @@ +"""Config.env.local must override config.env (precedence: env > local > base).""" + +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): + # Exercises the config-file layers, so opt back OUT of the conftest's + # THUNDER_SKIP_CONFIG_FILES hermeticity switch before spawning the probe. + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("PRIVATE_", "MAX_BATCH")) and k != "THUNDER_SKIP_CONFIG_FILES" + } + 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..58ef761 --- /dev/null +++ b/tests/test_unit/test_custom_dl_exceptions.py @@ -0,0 +1,65 @@ +"""Transient Telegram failures must NOT surface as FileNotFound (self-heal deletes records).""" + +from types import SimpleNamespace + +import pytest +from pyrogram.errors import FloodWait + +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) + + +@pytest.mark.unit +async def test_stream_file_floodwait_midstream_resumes(): + """A FloodWait mid-stream resumes from the consumer's position (no + re-sent bytes) instead of surfacing as a failure.""" + + class _C: + def __init__(self): + self.calls = 0 + + async def stream_media(self, target, offset=0, limit=0): + self.calls += 1 + if self.calls == 1: + yield b"x" + raise FloodWait(value=0) # sleep(0): hermetic, exercises resume math + yield b"y" + + streamer = ByteStreamer(_C()) + chunks = [chunk async for chunk in streamer.stream_file(SimpleNamespace(), offset=0)] + assert chunks == [b"x", b"y"] + + +@pytest.mark.unit +async def test_stream_file_sustained_floodwait_caps_at_60s(): + """A FloodWait exceeding the 60s total cap maps to TelegramUnavailable + WITHOUT sleeping (hermetic: the cap trips before any sleep).""" + + class _C: + async def stream_media(self, target, offset=0, limit=0): + raise FloodWait(value=61) + yield b"never" # unreachable: keeps the async-generator shape + + streamer = ByteStreamer(_C()) + with pytest.raises(TelegramUnavailable, match="Sustained Telegram flood"): + [chunk async for chunk in streamer.stream_file(SimpleNamespace(), offset=0)] diff --git a/tests/test_unit/test_flag_cache.py b/tests/test_unit/test_flag_cache.py new file mode 100644 index 0000000..5fd7dbe --- /dev/null +++ b/tests/test_unit/test_flag_cache.py @@ -0,0 +1,129 @@ +"""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(): + calls = {"n": 0} + + async def boom(): + calls["n"] += 1 + raise RuntimeError("db down") + + cache = FlagCache() + with pytest.raises(RuntimeError): + await cache.get_or_load("k", boom) + # nothing cached on failure: a retry must call the loader again + with pytest.raises(RuntimeError): + await cache.get_or_load("k", boom) + assert calls["n"] == 2 + + +@pytest.mark.unit +async def test_lru_bound(): + calls: dict[str, int] = {} + + async def loader(v): + calls[v] = calls.get(v, 0) + 1 + return v + + cache = FlagCache(ttl_seconds=60, max_items=2) + 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")) + # oldest evicted: reloading "a" re-invokes the loader, "c" stays cached + await cache.get_or_load("a", lambda: loader("a")) + assert calls["a"] == 2 + await cache.get_or_load("c", lambda: loader("c")) + assert calls["c"] == 1 + + +@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 = cache.sweep() + assert dropped == 1 + assert cache.sweep() == 0 # public-API check: nothing left to drop + + +@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 + # load-bearing private read: no public API exposes single-flight bookkeeping; + # a leaked task here would stampede the backend on the next cold key + assert cache._inflight == {} # bookkeeping cleaned up + + +@pytest.mark.unit +async def test_invalidate_mid_load_defeats_stale_store(): + """Regression: invalidate() while a loader is in flight must not let + that loader re-cache its pre-mutation value for a full TTL.""" + import asyncio + + release = asyncio.Event() + loads = {"n": 0} + + async def slow_loader(): + loads["n"] += 1 + await release.wait() + return "pre-mutation" + + cache = FlagCache(ttl_seconds=60) + task = asyncio.create_task(cache.get_or_load("k", slow_loader)) + for _ in range(100): # poll: one yield may not schedule the loader yet + if "k" in cache._inflight: + break + await asyncio.sleep(0) + assert "k" in cache._inflight # loader registered before we invalidate + cache.invalidate("k") + release.set() + assert await task == "pre-mutation" # the in-flight caller still gets a value... + assert cache._data.get("k") is None # ...but nothing was cached diff --git a/tests/test_unit/test_human_readable.py b/tests/test_unit/test_human_readable.py new file mode 100644 index 0000000..59d1eca --- /dev/null +++ b/tests/test_unit/test_human_readable.py @@ -0,0 +1,44 @@ +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(): + # quirk: round() keeps the float repr, so decimal_places=0 still renders "2.0" + assert humanbytes(1536, decimal_places=0) == "2.0 KB" + assert humanbytes(1536, decimal_places=3) == "1.5 KB" + + +@pytest.mark.unit +def test_humanbytes_none_is_zero_bytes(): + # characterization: None is falsy, so the `if not size` guard maps it to + # "0 B" (not "N/A" -- only truthy garbage reaches the except branch) + assert humanbytes(None) == "0 B" + assert humanbytes("garbage") == "N/A" + + +@pytest.mark.unit +def test_humanbytes_huge_does_not_raise(): + assert humanbytes(10**30).endswith("YB") diff --git a/tests/test_unit/test_leak_regression.py b/tests/test_unit/test_leak_regression.py new file mode 100644 index 0000000..fe202e8 --- /dev/null +++ b/tests/test_unit/test_leak_regression.py @@ -0,0 +1,76 @@ +"""Leak regression tests: cancellation must release, never strand. + +Hermetic (no network, no Mongo): fresh RateLimiter instances, a fake db for +the broadcast prune path, and task snapshots around worker lifecycles. +""" + +import asyncio +import time + +import pytest + +import Thunder.utils.broadcast as broadcast_module +from Thunder.utils.broadcast import _prune_collected +from Thunder.utils.rate_limiter import RateLimiter + +pytestmark = pytest.mark.unit + + +def _deferred_item(delay=60.0): + return {"user_id": 7, "not_before": time.time() + delay, "charged": False} + + +@pytest.mark.unit +async def test_park_arms_timer_and_shutdown_releases_it(): + """Parked pool (event cleared, timer armed) must unwind fully on shutdown: + timer cancelled + nulled, queues drained, event cleared.""" + rl = RateLimiter() + async with rl.request_lock: + rl.request_queue.append(_deferred_item()) + rl._park_if_all_deferred() + assert not rl.request_event.is_set() + assert rl._deferred_timer is not None + + await rl.shutdown() + assert rl._deferred_timer is None + assert len(rl.request_queue) == 0 and len(rl.priority_queue) == 0 + assert not rl.request_event.is_set() + + +@pytest.mark.unit +async def test_executor_worker_cancel_terminates(): + """Cancelling a parked executor worker must end the task (no hang).""" + rl = RateLimiter() + before = {t for t in asyncio.all_tasks() if t is not asyncio.current_task()} + worker = asyncio.create_task(rl.request_executor()) + for _ in range(10): + await asyncio.sleep(0) # let it reach event.wait() + worker.cancel() + try: + await worker + except asyncio.CancelledError: + pass # expected: worker breaks its loop on cancel + assert worker.done() + after = {t for t in asyncio.all_tasks() if t is not asyncio.current_task()} + assert not (after - before - {worker}), "leaked tasks after worker cancel" + await rl.shutdown() + + +@pytest.mark.unit +async def test_prune_collected_best_effort_and_bounded(monkeypatch): + """Background prune attempts every id, survives single failures, ends.""" + attempted: list[int] = [] + + class _FakeDB: + async def delete_user(self, user_id: int) -> None: + attempted.append(user_id) + if user_id == 2: + raise RuntimeError("mongo down") + + monkeypatch.setattr(broadcast_module, "db", _FakeDB()) + before = {t for t in asyncio.all_tasks() if t is not asyncio.current_task()} + task = asyncio.create_task(_prune_collected([1, 2, 3])) + await asyncio.wait_for(task, timeout=5) + assert attempted == [1, 2, 3] + after = {t for t in asyncio.all_tasks() if t is not asyncio.current_task()} + assert not (after - before), "leaked tasks after prune" diff --git a/tests/test_unit/test_media_types.py b/tests/test_unit/test_media_types.py new file mode 100644 index 0000000..1e85662 --- /dev/null +++ b/tests/test_unit/test_media_types.py @@ -0,0 +1,49 @@ +import pytest + +from Thunder.utils.media_types import ( + canonical_media_type, + ext_and_mime_for_class, + ext_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 +@pytest.mark.parametrize( + "media_type,ext", + [ + ("photo", "jpg"), + ("nope", "bin"), + ], +) +def test_ext_for(media_type, ext): + assert ext_for(media_type) == ext diff --git a/tests/test_unit/test_parser_properties.py b/tests/test_unit/test_parser_properties.py new file mode 100644 index 0000000..14d77a5 --- /dev/null +++ b/tests/test_unit/test_parser_properties.py @@ -0,0 +1,97 @@ +"""Property tests for security-adjacent parsers (stdlib random, fixed seed). + +Deterministic (seeded) fuzzing over input spaces: range headers, canonical +hashes, media names. No network, no Mongo, no new dependencies. +""" + +import random +import string + +import pytest +from aiohttp import web + +from Thunder.server.exceptions import InvalidHash +from Thunder.server.stream_routes import parse_range_header, validate_public_hash +from Thunder.utils.bot_utils import quote_media_name + +pytestmark = pytest.mark.unit + +_rng = random.Random(20260918) +_HEX = string.digits + "abcdef" +_SIZES = [1, 2, 99, 1023, 1024, 1025, 10 * 1024 * 1024, 2**31 - 1] + + +def _rand_size(): + return _rng.choice(_SIZES) + + +@pytest.mark.unit +def test_range_valid_spans_stay_in_bounds(): + """Any well-formed span resolves inside [0, size-1] with start <= end.""" + for _ in range(300): + size = _rand_size() + start = _rng.randint(0, size - 1) + end = _rng.randint(start, size + 5000) # oversized ends must clamp + header = f"bytes={start}-{end}" + got_start, got_end = parse_range_header(header, size) + assert got_start == start + assert got_end == min(end, size - 1) + assert 0 <= got_start <= got_end < size + assert got_end - got_start + 1 <= size + + +@pytest.mark.unit +def test_range_open_and_suffix_forms(): + for _ in range(200): + size = _rand_size() + start = _rng.randint(0, size - 1) + assert parse_range_header(f"bytes={start}-", size) == (start, size - 1) + n = _rng.randint(1, size + 100) + got_start, got_end = parse_range_header(f"bytes=-{n}", size) + assert (got_start, got_end) == (max(size - n, 0), size - 1) + + +@pytest.mark.unit +def test_range_garbage_is_400_and_oob_is_416(): + for bad in ("bytes=abc", "bytes=1-2,3-4", "bytes=", "items=0-1", "bytes=-", "bytes=5"): + with pytest.raises(web.HTTPBadRequest): + parse_range_header(bad, 1024) + for _ in range(100): + size = _rand_size() + with pytest.raises(web.HTTPRequestRangeNotSatisfiable): + parse_range_header(f"bytes={size}-{size + 10}", size) + with pytest.raises(web.HTTPRequestRangeNotSatisfiable): + parse_range_header("bytes=5-3", size) + + +@pytest.mark.unit +def test_416_carries_content_range(): + """Every 416 names the resource size so clients can retry correctly.""" + for _ in range(50): + size = _rand_size() + with pytest.raises(web.HTTPRequestRangeNotSatisfiable) as exc: + parse_range_header(f"bytes={size}-", size) + assert exc.value.headers["Content-Range"] == f"bytes */{size}" + + +@pytest.mark.unit +def test_canonical_hash_shapes(): + """20/32-hex (any case, padded) normalize to lowercase; else InvalidHash.""" + for _ in range(200): + raw = "".join(_rng.choice(_HEX) for _ in range(_rng.choice((20, 32)))) + mixed = "".join(c.upper() if _rng.random() < 0.5 else c for c in raw) + assert validate_public_hash(f" {mixed} ") == raw + for bad in ("", "xyz", "a" * 19, "b" * 21, "c" * 31, "d" * 33, "e" * 32 + "!", "0x" + "1" * 30): + with pytest.raises(InvalidHash): + validate_public_hash(bad) + + +@pytest.mark.unit +def test_quoted_media_name_never_splits_path(): + """Quote_media_name output must never contain a path separator.""" + alphabet = string.ascii_letters + string.digits + " /._-()<>&\"'%\u00e9\u4e2d" + for _ in range(300): + name = "".join(_rng.choice(alphabet) for _ in range(_rng.randint(0, 120))) + out = quote_media_name(name or "x") + assert isinstance(out, str) and out + assert "/" not in out diff --git a/tests/test_unit/test_player_page.py b/tests/test_unit/test_player_page.py new file mode 100644 index 0000000..0c8d038 --- /dev/null +++ b/tests/test_unit/test_player_page.py @@ -0,0 +1,129 @@ +"""Player page contract: the Jinja port of ThunderGo's cinema UI. + +Pins the exact surface player.min.js (CDN) reads, so a template edit that +renames an id, drops a config key, or breaks a branch fails here — not in a +browser. Hermetic: render_media_page takes an explicit src (no Var, no bot). +""" + +import re + +import pytest + +from Thunder.utils.render_template import render_media_page + +pytestmark = pytest.mark.unit + + +async def _render(name="v.mp4", mime="video/mp4", size=10 * 1024 * 1024, src=None): + return await render_media_page( + name, + src or f"https://files.example/f/abc123/{name}", + mime_type=mime, + size_bytes=size, + ) + + +async def test_cinema_config_contract(): + """Player.min.js reads src/mimeType/fileName/isVideo/isAudio — all present.""" + out = await _render() + for key in ("src:", "mimeType:", "fileName:", "isVideo:", "isAudio:"): + assert key in out + assert "isVideo: true" in out and "isAudio: false" in out + out = await _render("a.mp3", "audio/mpeg", 1024) + assert "isVideo: false" in out and "isAudio: true" in out + + +async def test_cdn_theme_assets(): + out = await _render() + assert "ThunderGo/player.min.css" in out + assert "ThunderGo/player.min.js" in out + assert "Obsidian" not in out + + +async def test_unsupported_format_plumbing(): + """The codec-failure box the JS unhides must exist with its live region + on the reason element itself (not merely anywhere on the page).""" + out = await _render() + assert 'id="unsupportedFormatMessage"' in out + assert '

' in out + + +async def test_kind_branches(): + video = await _render() + assert 'view-type="video"' in video + assert 'class="cinema"' in video + audio = await _render("a.mp3", "audio/mpeg", 1024) + assert 'view-type="audio"' in audio + assert "cinema--audio" in audio and "audio-face" in audio + image = await _render("p.png", "image/png", 1024) + assert "image-viewer" in image and "media-player" not in image + other = await _render("z.zip", "application/zip", 1024) + assert "non-media" in other and "cinema--fallback" in other + assert "media-player" not in other + + +async def test_download_uses_attachment(): + video = await _render() + assert video.count("disposition=attachment") == 1 # download button only + other = await _render("z.zip", "application/zip", 1024) + assert other.count("disposition=attachment") == 2 # button + card + assert "disposition=inline" in video # player + intents stay inline + + +async def test_drawer_upgrade_keys(): + """All 19 static links carry data-player keys the JS rewrites with + Play-Store fallbacks; static hrefs remain the no-JS fallback. The key + NAMES matter (unknown keys render dead hrefs), not just the count.""" + out = await _render() + keys = set(re.findall(r'data-player="([^"]+)"', out)) + assert keys == { + "android-vlc", + "android-mx", + "android-mx-pro", + "android-splayer", + "android-next", + "android-nova", + "android-mpv", + "android-just", + "android-nplayer", + "ios-vlc", + "ios-infuse", + "ios-nplayer", + "ios-outplayer", + "ios-oplayer", + "desktop-vlc", + "desktop-potplayer", + "desktop-iina", + "desktop-mpv", + "desktop-kmplayer", + } + assert "intent:" in out and "vlc-x-callback" in out + + +async def test_meta_tags(): + out = await _render() + assert "10.0 MB" in out # size_formatted + assert "skeleton-text" in out + assert "noindex" in out + nosize = await _render("n.mp4", "video/mp4", None) + assert "metaSize" not in nosize + + +async def test_embed_tags(): + """Rich-embed tags per kind; never on image/download pages.""" + video = await _render() + assert 'property="og:video"' in video + assert 'property="og:audio"' not in video + audio = await _render("a.mp3", "audio/mpeg", 1024) + assert 'property="og:audio"' in audio + assert 'property="og:video"' not in audio + image = await _render("p.png", "image/png", 1024) + assert "og:video" not in image and "og:audio" not in image + other = await _render("z.zip", "application/zip", 1024) + assert "og:video" not in other and "og:audio" not in other + + +async def test_no_go_template_remnants(): + out = await _render() + for remnant in ("{{.", "{{if ", "{{end}}", "printf", "ThunderGo/logo"): + assert remnant not in out diff --git a/tests/test_unit/test_preflight.py b/tests/test_unit/test_preflight.py new file mode 100644 index 0000000..13ba02c --- /dev/null +++ b/tests/test_unit/test_preflight.py @@ -0,0 +1,101 @@ +"""Unified preflight chain -- gate presets, ordering, fail-closed ids.""" + +import pytest + +from Thunder.utils.decorators import ( + GATES_STANDARD, + GATES_START, + PREFLIGHT_GATES, + check_private_mode, + preflight, + require_token, +) +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"), + ): + 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(monkeypatch): + """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() + + # pin the knob off: with it env-overridden on, the old `or` fallback + # turned the assertion below into a tautology + monkeypatch.setattr(Var, "SHORTEN_MEDIA_LINKS", False) + result = await preflight(object(), _Msg(), gates=GATES_START) + assert result is False + + +@pytest.mark.unit +async def test_owner_bypasses_force_sub(monkeypatch): + """Owner skips the entire chain, including force-sub (no RPC made).""" + import Thunder.utils.force_channel as force_channel_module + from Thunder.utils.force_channel import force_channel_check + + class _User: + id = Var.OWNER_ID + + class _Msg: + from_user = _User() + + calls = {"n": 0} + + async def _boom(*args, **kwargs): + calls["n"] += 1 + raise AssertionError("owner path must not touch the network") + + monkeypatch.setattr(force_channel_module, "_is_member", _boom) + monkeypatch.setattr(Var, "FORCE_CHANNEL_ID", -100123) + assert await force_channel_check(object(), _Msg()) is True + assert calls["n"] == 0 + + +@pytest.mark.unit +async def test_anonymous_sender_denied_by_private_mode_and_token_gates(monkeypatch): + """Messages with no attributable sender (channel posts, anonymous admins) + are DENIED fail-closed -- never bypass.""" + + class _Msg: + from_user = None + + monkeypatch.setattr(Var, "PRIVATE_MODE", True) + assert await check_private_mode(object(), _Msg()) is False + + monkeypatch.setattr(Var, "TOKEN_ENABLED", True) + assert await require_token(object(), _Msg()) is False diff --git a/tests/test_unit/test_rate_limiter.py b/tests/test_unit/test_rate_limiter.py new file mode 100644 index 0000000..1b6f9b3 --- /dev/null +++ b/tests/test_unit/test_rate_limiter.py @@ -0,0 +1,190 @@ +"""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: + 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 + + 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; CI timing jitter: retry briefly before failing + for _ in range(20): + if bucket.allow(): + break + time.sleep(0.05) + else: + pytest.fail("bucket did not refill after ~1s of waiting") + + 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 + + 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) + + +@pytest.mark.unit +async def test_immediate_path_consumes_breaker_token(monkeypatch): + """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_rate_limiter_park.py b/tests/test_unit/test_rate_limiter_park.py new file mode 100644 index 0000000..93afd72 --- /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 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_redaction.py b/tests/test_unit/test_redaction.py new file mode 100644 index 0000000..e241d77 --- /dev/null +++ b/tests/test_unit/test_redaction.py @@ -0,0 +1,52 @@ +"""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 + + +@pytest.mark.unit +@pytest.mark.parametrize( + "text,leaked", + [ + # API_HASH_PATTERN: api_hash[:=] + 32 hex (contextual so file hashes still log) + ("config api_hash = '0123456789abcdef0123456789abcdef' done", "0123456789abcdef"), + # SESSION_STRING_PATTERN: session_string[:=] + 40+ base64url chars + ("login session_string='AbC123_-xYzAbC123_-xYzAbC123_-xYzAbC123_-xYz' ok", "AbC123_-xYz"), + # ACTIVATION_TOKEN_PATTERN: ?start= + 43 urlsafe chars + ("open https://t.me/MyBot?start=" + "a" * 43 + " now", "a" * 43), + ], +) +def test_secret_patterns_redacted(text, leaked): + out = redact_secrets(text) + assert leaked not in out + assert "***REDACTED***" in out diff --git a/tests/test_unit/test_registry.py b/tests/test_unit/test_registry.py new file mode 100644 index 0000000..a7bd919 --- /dev/null +++ b/tests/test_unit/test_registry.py @@ -0,0 +1,39 @@ +"""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) + assert {"start", "help", "link", "ping", "dc", "about"} <= menu_names + + +@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(): + """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..54c5a4b --- /dev/null +++ b/tests/test_unit/test_safe_call.py @@ -0,0 +1,129 @@ +"""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_lightweight_shape_caps_floodwait_sleep(monkeypatch): + """Non-media RPCs cap the FloodWait sleep at 30s (wall-clock budget).""" + slept = [] + + async def fake_sleep(s): + slept.append(s) + + monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep) + + async def get_me(): + raise FloodWait(value=90) + + with pytest.raises(FloodWait): + await tg_call(get_me, retries=1, timeout=0) + assert slept == [30.0] + + +@pytest.mark.unit +async def test_media_shape_sleeps_full_floodwait(monkeypatch): + """File-transfer shapes (e.g. copy) ride out long FloodWaits up to the + 600s media ceiling -- sustained throttle must not fail the ingest path.""" + slept = [] + + async def fake_sleep(s): + slept.append(s) + + monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep) + + async def copy(*args, **kwargs): + raise FloodWait(value=90) + + with pytest.raises(FloodWait): + await tg_call(copy, retries=1) + assert slept == [90.0] + + +@pytest.mark.unit +async def test_media_shape_floodwait_ceiling(monkeypatch): + """Waits beyond the 600s media ceiling are clamped, never unbounded.""" + slept = [] + + async def fake_sleep(s): + slept.append(s) + + monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep) + + async def copy(*args, **kwargs): + raise FloodWait(value=3600) + + with pytest.raises(FloodWait): + await tg_call(copy, retries=1) + assert slept == [600.0] + + +@pytest.mark.unit +async def test_max_flood_sleep_overrides_shape_cap(monkeypatch): + """Fan-out callers cap the sleep explicitly: a media-shaped copy with + max_flood_sleep=30 sleeps 30s on a 3600s flood, then raises.""" + slept = [] + + async def fake_sleep(s): + slept.append(s) + + monkeypatch.setattr("Thunder.utils.safe_call.asyncio.sleep", fake_sleep) + + async def copy(*args, **kwargs): + raise FloodWait(value=3600) + + with pytest.raises(FloodWait): + await tg_call(copy, retries=1, max_flood_sleep=30.0) + assert slept == [30.0] diff --git a/tests/test_unit/test_shortener.py b/tests/test_unit/test_shortener.py new file mode 100644 index 0000000..adbd48d --- /dev/null +++ b/tests/test_unit/test_shortener.py @@ -0,0 +1,100 @@ +"""Plugin registry lookup, offline builders, host validation.""" + +import pytest + +from Thunder.utils.shortener import ( + BitlyPlugin, + CuttLyPlugin, + GenericShortenerPlugin, + LinkvertisePlugin, + OuoIoPlugin, + ShortenerSystem, +) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "domain,expected", + [ + ("bitly.com", BitlyPlugin), + ("shrinkme.dev", GenericShortenerPlugin), # generic fallback + ("bitly.com.evil.com", GenericShortenerPlugin), # lookalike rejected + ], +) +def test_registry_lookup(domain, expected): + system = ShortenerSystem() + assert system._get_plugin_class(domain) is expected + + +@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" + + +@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 diff --git a/tests/test_unit/test_stream_routes.py b/tests/test_unit/test_stream_routes.py new file mode 100644 index 0000000..9a0b0f5 --- /dev/null +++ b/tests/test_unit/test_stream_routes.py @@ -0,0 +1,352 @@ +"""HTTP parsing primitives + dual-hash validation + disposition rules.""" + +from types import SimpleNamespace + +import pytest +from aiohttp import web +from aiohttp.web import HTTPBadRequest, HTTPRequestRangeNotSatisfiable + +from Thunder.server.exceptions import InvalidHash +from Thunder.server.stream_routes import ( + _is_activation_token, + _telegram_activate_url, + 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 + assert h == "AbCdEf" + + @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): + 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): + 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): + 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_end_beyond_eof_clamps_instead_of_416(self): + # RFC 7233: last-byte-pos >= length means "rest of the file"; download + # managers send such fixed-chunk ends, and a hard 416 broke their resume/seeking. + 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) + + @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* + + +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) + + +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:] + + +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_db_outage_maps_to_503_not_404(self, monkeypatch): + """Review fix regression: a Mongo read failure is a brownout, not + absence -- must never surface as a cacheable 404.""" + from pymongo.errors import ExecutionTimeout + + import Thunder.server.stream_routes as stream_routes + + async def get_file_by_hash(_h, raise_on_error=True): + raise ExecutionTimeout("server timeout") + + monkeypatch.setattr(stream_routes, "get_file_by_hash", get_file_by_hash) + 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" + # no admission happened: the slot count is untouched + assert stream_routes.work_loads == {0: 0} + + @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 + + +class TestLegacyDisabledGone: + """With ENABLE_LEGACY_LINKS=False the legacy families must 410.""" + + @staticmethod + def _request(path="AbCdEf12345/name.mp4"): + return SimpleNamespace(match_info={"path": path}) + + @pytest.mark.unit + async def test_preview_gone(self, monkeypatch): + import Thunder.server.stream_routes as stream_routes + from Thunder.vars import Var + + monkeypatch.setattr(Var, "ENABLE_LEGACY_LINKS", False) + with pytest.raises(web.HTTPGone): + await stream_routes.media_preview(self._request()) + + @pytest.mark.unit + async def test_delivery_gone(self, monkeypatch): + import Thunder.server.stream_routes as stream_routes + from Thunder.vars import Var + + monkeypatch.setattr(Var, "ENABLE_LEGACY_LINKS", False) + with pytest.raises(web.HTTPGone): + await stream_routes.media_delivery(self._request()) + + +class TestAdmissionControl: + """Saturated clients refuse with 503 + Retry-After instead of stacking.""" + + @pytest.mark.unit + def test_full_pool_maps_to_503_with_retry_after(self, monkeypatch): + import Thunder.server.stream_routes as stream_routes + + monkeypatch.setattr( + stream_routes, "work_loads", {0: stream_routes.MAX_CONCURRENT_PER_CLIENT} + ) + with pytest.raises(web.HTTPServiceUnavailable) as exc: + stream_routes.select_optimal_client() + assert exc.value.headers.get("Retry-After") == str( + stream_routes.OVERLOAD_RETRY_AFTER_SECONDS + ) + + +class TestConstantErrorBodies: + """503 bodies are constant strings: the exception text carries internal + state (FloodWait values, chat ids) and must never reach the client.""" + + @staticmethod + def _request(): + return SimpleNamespace(match_info={"secure_hash": "a" * 32}) + + @pytest.mark.unit + async def test_503_body_hides_exception_text(self, monkeypatch): + import Thunder.server.stream_routes as stream_routes + from Thunder.server.exceptions import TelegramUnavailable + + secret = "FloodWait-SECRET-xyz-987" + + async def get_message(_ref): + raise TelegramUnavailable(secret) + + 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): + return None + + 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 secret not in exc.value.text + assert exc.value.text == "Telegram is temporarily unavailable; please retry shortly." + assert exc.value.headers.get("Retry-After") == "5" diff --git a/tests/test_unit/test_time_format.py b/tests/test_unit/test_time_format.py new file mode 100644 index 0000000..984e14d --- /dev/null +++ b/tests/test_unit/test_time_format.py @@ -0,0 +1,33 @@ +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" + + +@pytest.mark.unit +def test_none_returns_na(): + assert get_readable_time(None) == "N/A" diff --git a/tests/test_unit/test_tokens_consume.py b/tests/test_unit/test_tokens_consume.py new file mode 100644 index 0000000..61ae878 --- /dev/null +++ b/tests/test_unit/test_tokens_consume.py @@ -0,0 +1,153 @@ +"""Consume() status ladder: corrupt/expired tokens read "invalid", never "already".""" + +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 + pre-check row, or the scripted post-CAS doc when a projection arg is passed.""" + + 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" + + +@pytest.mark.unit +async def test_naive_future_expiry_treated_as_utc(monkeypatch): + """Legacy naive datetimes are UTC instants: future stays activatable.""" + + class _FakeOkCol(_FakeTokenCol): + async def find_one_and_update(self, *_args, **_kwargs): + return {"activated": True} + + doc = { + "token": "t", + "user_id": 7, + "activated": False, + "expires_at": datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1), + } + monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeOkCol(doc))) + monkeypatch.setattr(tokens_module.Var, "TOKEN_TTL_HOURS", 7) + monkeypatch.setattr(tokens_module.flags, "invalidate", lambda *keys: None) + status, _hours = await consume("t", 7) + assert status == "ok" + + +@pytest.mark.unit +async def test_naive_past_expiry_is_invalid(monkeypatch): + doc = { + "token": "t", + "user_id": 7, + "activated": False, + "expires_at": datetime.now(UTC).replace(tzinfo=None) - 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_non_datetime_expiry_is_invalid_not_500(monkeypatch): + """Corrupt expires_at shapes fail closed instead of raising TypeError.""" + doc = {"token": "t", "user_id": 7, "activated": False, "expires_at": "tomorrow-ish"} + 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_token_for_other_user_is_wrong_user(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", 999) + assert (status, hours) == ("wrong_user", 0.0) + + +@pytest.mark.unit +async def test_ok_path_activates_and_invalidates_flags(monkeypatch): + class _FakeOkCol(_FakeTokenCol): + async def find_one_and_update(self, *_args, **_kwargs): + return {"activated": True} + + doc = { + "token": "t", + "user_id": 7, + "activated": False, + "expires_at": datetime.now(UTC) + timedelta(hours=1), + } + invalidated: list = [] + monkeypatch.setattr(tokens_module, "db", SimpleNamespace(token_col=_FakeOkCol(doc))) + monkeypatch.setattr(tokens_module.Var, "TOKEN_TTL_HOURS", 7) + monkeypatch.setattr(tokens_module.flags, "invalidate", lambda *keys: invalidated.append(keys)) + status, hours = await consume("t", 7) + assert status == "ok" + assert hours == 7.0 # round(ttl_hours, 1) + assert (("allowed", 7), ("token_ok", 7)) in invalidated diff --git a/thunder.sh b/thunder.sh index 8e84da0..9e8d8e1 100644 --- a/thunder.sh +++ b/thunder.sh @@ -1 +1,10 @@ -python3 update.py && python3 -m Thunder \ No newline at end of file +#!/usr/bin/env bash +# boot orchestration only: best-effort shell-free update; a failing update never blocks boot. +set -u + +# work from the script's directory so manual invocations from any cwd behave +# like the Docker entrypoint +cd "$(dirname "$0")" || exit 1 + +python3 update.py || true +exec python3 -m Thunder diff --git a/update.py b/update.py index 801e08c..8b895d2 100644 --- a/update.py +++ b/update.py @@ -1,41 +1,140 @@ -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') +"""Boot-time best-effort self-update. + +Non-destructive ``pull --ff-only`` via argv-list subprocess; any failure +logs and keeps running the old code. +""" + +import os +import re +import shutil +import subprocess + +from dotenv import load_dotenv + +from Thunder.utils.logger import logger + +# Guarantees (the old shell=True chain had none): argv-list only, no +# destructive git ops, no global config mutation; no-ops cleanly without git. + +# Real environment wins over files; .local wins over base (same precedence +# as Thunder/vars.py — load local first so setdefault keeps its values). +load_dotenv("config.env.local", override=False) +load_dotenv("config.env", override=False) + +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.bak" + + +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. + + A config.env that git tracks was just shipped by the upstream pull, not + written by the operator (P3-11): the operator's backup wins there too. + An untracked config.env is the operator's own (re-created after the + crash) and is left alone; the stale backup stays in place for safety. + """ + if not os.path.exists(_CONFIG_BACKUP): + return + restore = not os.path.exists("config.env") + if not restore and os.path.isdir(".git") and shutil.which("git") is not None: + tracked = subprocess.run( + ["git", "ls-files", "--error-unmatch", "config.env"], + capture_output=True, + timeout=10, + ) + restore = tracked.returncode == 0 + if restore: + 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"): + 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 _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: + # restore orphans first: early returns below must not skip recovery + _recover_config_backup() + if not UPSTREAM_REPO: + return + if UPSTREAM_REPO.startswith("-") or UPSTREAM_BRANCH.startswith("-"): + # operator-supplied env vars, but git would treat leading-dash values as options + logger.info("UPSTREAM_REPO/UPSTREAM_BRANCH must not start with '-'; skipping self-update.") + 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.") + return + + # defense-in-depth: the argv/dash guards do not stop the git-remote-ext family + # (ext::sh -c ...) -- allowlist ordinary transport schemes only. ssh:// is + # excluded on purpose: the image ships no keys, so it would fail noisily + # on every boot; use https with a token URL instead. + if "://" in UPSTREAM_REPO and UPSTREAM_REPO.split("://", 1)[0] not in { + "https", + "http", + "git", + }: + logger.error("UPSTREAM_REPO uses an unsupported scheme; skipping self-update.") + return + if UPSTREAM_REPO.startswith("ext::"): + 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. + result = subprocess.run( + ["git", "pull", "--ff-only", UPSTREAM_REPO, UPSTREAM_BRANCH], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode == 0: + logger.info("Self-update pulled latest commit from UPSTREAM_REPO.") + else: + # keep running the old code; never hard-fail the boot + logger.error( + "Self-update failed (non-destructive, keeping current code): " + f"{_redact_credentials((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: + _restore_config(backed_up) + + +if __name__ == "__main__": + main() diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..c0f2409 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1794 @@ +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 = "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 = "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 = "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" +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 = "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.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/77/56/8c52e7f57719051748378fe534fbf916440b1eb8fdb8053c9b43003c9865/pymongo-4.18.1.tar.gz", hash = "sha256:d04134ff4ea0e7d14b6e2ef23dbfe06112a99318c9578b213d2dee9060b23dce", size = 2744931, upload-time = "2026-09-10T14:57:11.817Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a5/0e/bfeec4bb1820828e66d133eee6e7e5138b7e0db5ff40bc533449048dd3a5/pymongo-4.18.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a787cd29b6bc5735ced10aba4ec052d4ef2d2a82b4875efa8539ecd635e720b", size = 818659, upload-time = "2026-09-10T14:55:59.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/ee/852cb3c707e6cc53d84e656b4c4f534afa96f0058d88470f49a33770c790/pymongo-4.18.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e63d2f8770f0cfb74c7c3fbe35c6b2828f27a16395a7c4019c81451e61489e", size = 819003, upload-time = "2026-09-10T14:56:01.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/dd/e42afd21c9238bed54ae571f7fc6912aae6b286d6293592f2c9a9835add0/pymongo-4.18.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:305877cd97e4344fde699c8721e4cc9890a689990bd1a1783eb5d0946e093d13", size = 1039219, upload-time = "2026-09-10T14:56:02.807Z" }, + { url = "https://files.pythonhosted.org/packages/24/d8/1a7b20a9c436f539bdabfd49cb40a1d65ad7f0d9eae3cc330d071ac12823/pymongo-4.18.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbef9ef64cdae97e85b27cf00e203a26a016f7c803dc9bd0ccd48b3adade1232", size = 1050251, upload-time = "2026-09-10T14:56:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/b0/5e/75f2742abb591c55340a3b1421836d918ad7c5e6a23185ed32031b302c46/pymongo-4.18.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0a6b59282941d2f6f62acf35fb75bae371d3194ab041f0358446668f9df10be", size = 1074998, upload-time = "2026-09-10T14:56:05.868Z" }, + { url = "https://files.pythonhosted.org/packages/0a/8f/0d5b604bc1f200e6ba92cddd71313d3f7d209aadbca19a1a55e0d1b614c1/pymongo-4.18.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1eaed0c72bba39fa42e6167636d56587e30cdab13ec3178a68ac85bee093d44b", size = 1067750, upload-time = "2026-09-10T14:56:07.514Z" }, + { url = "https://files.pythonhosted.org/packages/08/0f/0daf459b604f61253b914b28f0e6dd16cdc8a7aa962cf901e482f5cbba67/pymongo-4.18.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:55309ea9f6d8f697618f12f1ea2b643f26df434de043cd5de6f96426093011b3", size = 1049523, upload-time = "2026-09-10T14:56:09.046Z" }, + { url = "https://files.pythonhosted.org/packages/6d/ec/c87acb3be5a222b87fae3c38cf389187c923ceaad0c16ade60a858c3b043/pymongo-4.18.1-cp313-cp313-win32.whl", hash = "sha256:4ebb5a6a42a1512a86f774ca517b4b56aacd0ccbde535d787cfd9789c5905e2a", size = 814890, upload-time = "2026-09-10T14:56:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5c/9b55c1fd4e5ec9d880d6c12dda01ff45ebf384af698d2a61d5411371ed4e/pymongo-4.18.1-cp313-cp313-win_amd64.whl", hash = "sha256:1a78dec1c10eaae298aefc6cd7ad916ca2ceeb51f8ff7946dddbca97adf9ed0c", size = 820480, upload-time = "2026-09-10T14:56:11.967Z" }, + { url = "https://files.pythonhosted.org/packages/11/70/c2efefb8d414489726d68eaab88b2230435aa17a493259af8614bd0c6d7a/pymongo-4.18.1-cp313-cp313-win_arm64.whl", hash = "sha256:aff3582edd807980e0f910cc20b3f588b8c5533556b0aeb3f33d88c7681f9b21", size = 815375, upload-time = "2026-09-10T14:56:13.661Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ef/869a383a4ac3e615599a35340c9bcf02ecbc9a5059d2227b8f96ba6af38e/pymongo-4.18.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:67d56552a940d3553024fdf39535975c4de2ff5b82cf20287ba1ffef028f004b", size = 818559, upload-time = "2026-09-10T14:56:15.241Z" }, + { url = "https://files.pythonhosted.org/packages/d0/30/a1b0870b71efbd49ee0affa61bf8be067acfdc3f91244443a3395edf9c94/pymongo-4.18.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ae18db68105a0ba7adc190a13cbd73e6c87593bc55a336719f70ad042aa66aaf", size = 819124, upload-time = "2026-09-10T14:56:16.9Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/40e08f302e66a3d4da98c7679136f674f736e24c0ac4b306567a0b5cbf0d/pymongo-4.18.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4541273796aa3022b39de892e265aac4a437adcc6e4894396c23258d990f8c97", size = 1040989, upload-time = "2026-09-10T14:56:18.508Z" }, + { url = "https://files.pythonhosted.org/packages/59/d0/7bb489c716c64c5e3c77df69d5e38ea67374ec2e75e5af413fed263dcc1f/pymongo-4.18.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c3f52a1a793262ef1af15b36535220d194b3ad5ce7564f98c15f3881ffbc269", size = 1050963, upload-time = "2026-09-10T14:56:20.259Z" }, + { url = "https://files.pythonhosted.org/packages/53/31/09c78af31758923c0b39aa4ec934cb261446619453c7e263ed8d38d4abf6/pymongo-4.18.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab33db087dc52f8f28bdb8680682ebc8fc5fb0be84e89a0853a8dc059fcc06a8", size = 1074988, upload-time = "2026-09-10T14:56:22.057Z" }, + { url = "https://files.pythonhosted.org/packages/1f/dc/83956070a4313cc9efa676f45d742240540078a44727fc52e3521a15e8a5/pymongo-4.18.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:173a69caa539731284a815ee2fd616442431d12e8c84d970f3cc555d9ac5ef81", size = 1064428, upload-time = "2026-09-10T14:56:23.663Z" }, + { url = "https://files.pythonhosted.org/packages/d6/05/8817d987e5eadd127a4f44d19ce7a31e507c300d6f1511de99bc73f880f5/pymongo-4.18.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b46ef2de0b077a2c708790e9ced5c8afb5b9deef1cacba28e8d9a90027e86d2", size = 1049218, upload-time = "2026-09-10T14:56:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/b3c38caeb743a7055a1cc29afe2edee06206fe2406e90b3e0d4900242df0/pymongo-4.18.1-cp314-cp314-win32.whl", hash = "sha256:d9be9221bf2db1562d490bb67522ce2f7378513bc8bcc157e824a0bb3d87eba0", size = 816052, upload-time = "2026-09-10T14:56:26.935Z" }, + { url = "https://files.pythonhosted.org/packages/02/0e/e1a8406b5361b06043762b7f50a13a3d9c04b2ea8202f39c8cde737a8f13/pymongo-4.18.1-cp314-cp314-win_amd64.whl", hash = "sha256:134a8f787360c3cea18cbbc126bf51cd233f1789db8099f7081ace5b1cff7e22", size = 821969, upload-time = "2026-09-10T14:56:28.571Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ff/82420a828245bb3bf36dcb6a59eebd914fba78dac87985c48145d03a405b/pymongo-4.18.1-cp314-cp314-win_arm64.whl", hash = "sha256:088e78ab7340d7ab4c14cd7a96852f9ea3b51c25fc9fb02244715d21f57ec2e5", size = 816505, upload-time = "2026-09-10T14:56:30.184Z" }, + { url = "https://files.pythonhosted.org/packages/f0/b1/fa1c5cd5ea032ffbce4f4c7876403405405c9cc9de7abba33c78254bd278/pymongo-4.18.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9bf92d46b15fb8fcfcd517442b0b90055924d835fef77e574ff4678b40be49ce", size = 821533, upload-time = "2026-09-10T14:56:31.96Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cf/41c06f9cce2035bf061248d1576439bc60701852f75118fd2b5a3408abd0/pymongo-4.18.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:58c3392585c5823ad90e318b6e7c0fb0709763631fb06d81de45166a1844ea18", size = 821971, upload-time = "2026-09-10T14:56:33.83Z" }, + { url = "https://files.pythonhosted.org/packages/53/3f/c698242cc5013a0d99099db622813c480e23dac7c8bad3852610fb5a5145/pymongo-4.18.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4b8f7b212ab86552db2f35b2779cc290561a341b73fba1ab1030ed9d48e1b1e5", size = 1105379, upload-time = "2026-09-10T14:56:35.497Z" }, + { url = "https://files.pythonhosted.org/packages/37/a8/02fc8305155d46651c0e3980998d3da881db287866be8a5c98d9a0fb67a2/pymongo-4.18.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2ef166249993afd94b704bd25af27104fccb259580960a41355f8d7869496ed", size = 1125164, upload-time = "2026-09-10T14:56:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/53/0b/7fcdda4b6e07497ce63d59a932ce193e3da098c309cb18a2d55ed7c99f84/pymongo-4.18.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:adebc03de2d1520bb163953361779288316e4e9c4b66fe4ce9e91d4183e313b8", size = 1144618, upload-time = "2026-09-10T14:56:38.929Z" }, + { url = "https://files.pythonhosted.org/packages/ad/ce/b9213ece00a38f742ca4cd6afb812d73d4cfe857b1018e90f803d72c4b9f/pymongo-4.18.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9ae9c28e6c290c2524c3288a0a6e27dca958daeeaba2f36a6a4ee97c44e5f49", size = 1136374, upload-time = "2026-09-10T14:56:40.647Z" }, + { url = "https://files.pythonhosted.org/packages/c4/42/aa057e91930209d731ec3fed03edf32cf6aa59838f7766227ee219142574/pymongo-4.18.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5907ccdcc2f7925100b4bed8069acd0d93156e5cf013a8e15aa5932819ce3d5e", size = 1117151, upload-time = "2026-09-10T14:56:42.561Z" }, + { url = "https://files.pythonhosted.org/packages/00/a5/86777256266850eb2217d6c8c8f382a213a1773c144383d51f61bbb2e651/pymongo-4.18.1-cp314-cp314t-win32.whl", hash = "sha256:9da74b79e382bb37311b9bd5beb89f1369a1c3e27e7c97bae79661cfdf3893aa", size = 818806, upload-time = "2026-09-10T14:56:47.024Z" }, + { url = "https://files.pythonhosted.org/packages/49/6d/08e9256de9fab497465e7e6589fab4d40ca266dcceb88d3acdd0947095e1/pymongo-4.18.1-cp314-cp314t-win_amd64.whl", hash = "sha256:016d21e7fbf55ac89f419daa48da95314896205d902ed9aa4058f26a436e789c", size = 826141, upload-time = "2026-09-10T14:56:48.886Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/12afe9d59fb82b60832356f9fe8f454a99371c0e0a42f0ec7856f5c893fd/pymongo-4.18.1-cp314-cp314t-win_arm64.whl", hash = "sha256:960c61ed86a316d3636b1f184006d3cc2b972b8546186ae8e2c8324e397632ff", size = 817669, upload-time = "2026-09-10T14:56:50.602Z" }, +] + +[[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.optional-dependencies] +speedup = [ + { name = "tgcrypto-pyrofork" }, + { name = "uvloop" }, +] + +[[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 = "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" +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 = "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" +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", extra = ["speedup"] }, + { name = "python-dotenv" }, + { name = "uvloop" }, +] + +[package.dev-dependencies] +dev = [ + { name = "bandit" }, + { name = "mypy" }, + { name = "pip-audit" }, + { name = "pytest" }, + { 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 = "jinja2", specifier = "==3.1.6" }, + { name = "psutil", specifier = "==7.2.2" }, + { name = "pymongo", specifier = "==4.18.1" }, + { name = "pyrofork", extras = ["speedup"], specifier = "==2.3.69" }, + { name = "python-dotenv", specifier = "==1.2.3" }, + { name = "uvloop", specifier = "==0.22.1" }, +] + +[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.4" }, + { name = "pytest-asyncio", specifier = ">=0.24" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = ">=0.8" }, + { name = "testcontainers", extras = ["mongodb"], specifier = ">=4.15" }, + { 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 = "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" +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" }, +]