diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..8dd1b836 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,28 @@ +.git/ +.github/ +.venv/ +__pycache__/ +**/__pycache__/ +*.py[cod] +*$py.class +*.so +*.egg-info/ +*.egg +build/ +dist/ +downloads/ +eggs/ +.eggs/ +config.env +.env +*.session +*.session-journal +*.session-shm +*.session-wal +tests/ +*.md +LICENSE +.vscode/ +log.text +Thunder/logs/ +tdlib_data/ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..0f89c1ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: uv sync + - run: uv run ruff check Thunder/ + - run: uv run ruff format --check Thunder/ + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: uv sync + - run: uv run pytest tests/ -v --tb=short + + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - run: uv sync + - run: uv run pyright Thunder/ --level basic diff --git a/.gitignore b/.gitignore index 431eecad..7232a011 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,7 @@ var/ wheels/ *.egg-info/ .installed.cfg -*.egg \ No newline at end of file +*.egg +Thunder/logs/ +.pytest_cache/ +tdlib_data/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index a8549d09..58c8ca96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # AGENTS.md — Thunder File-to-Link Bot -Python 3.13+ Telegram bot converting files to direct HTTP links. Uses Pyrofork, aiohttp, MongoDB, uvloop. +Python 3.13+ Telegram bot converting files to direct HTTP links. Uses Pytdbot (TDLib), aiohttp, MongoDB, uvloop. ## Run @@ -11,9 +11,12 @@ bash thunder.sh # Runs python3 update.py && python3 -m Thunder ## Dependencies +Managed with [uv](https://docs.astral.sh/uv/) (lockfile: `uv.lock`). + ```bash -pip install -r requirements.txt -# aiohttp, cloudscraper, Jinja2, pyrofork, pymongo, psutil, python-dotenv, speedtest-cli, tgcrypto, uvloop==0.21.0 +uv sync # Install from lockfile (recommended) +uv run python -m Thunder # Run in uv-managed environment +pip install -r requirements.txt # Alternative: legacy pip install ``` ## Project Structure @@ -35,7 +38,8 @@ Thunder/ │ ├── __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 +├── utils/ # 27 modules — see imports below +│ └── database/ # 8 files — mixin-based repo decomposition └── template/ # dl.html, req.html (Jinja2) ``` @@ -47,17 +51,30 @@ from Thunder.utils.database import db # AsyncMongoClient singleton from Thunder.utils.rate_limiter import rate_limiter, request_executor, handle_rate_limited_request from Thunder.utils.bot_utils import is_admin # async def is_admin(cli, chat_id_val) -> bool — checks bot membership, NOT a decorator from Thunder.utils.decorators import owner_only # async guard function, not a decorator +from Thunder.utils.compat import Filters # Pyrogram-style filters on top of Pytdbot from Thunder.vars import Var # All env config ``` +## Telegram Client: Pytdbot (TDLib) + +This project uses [Pytdbot](https://github.com/pytdbot/client) — a TDLib wrapper for Python. Key differences from Pyrogram: + +- **Types**: `from pytdbot import types` — `types.Message`, `types.User`, `types.Chat`, `types.Error`, etc. +- **Client**: `from pytdbot import Client` — `Client(token=..., api_id=..., api_hash=...)` +- **Handlers**: `@client.on_message()` for new messages, `@client.on_updateNewCallbackQuery()` for callbacks +- **Filters**: Custom filters via `pytdbot.filters.create(func)` and `Thunder.utils.compat.Filters` +- **Error handling**: Methods return `types.Error | SuccessType` (no exceptions for API errors) +- **File streaming**: `downloadFile()` downloads to disk, then read from `file.local.path` +- **Message bound methods**: `message.reply_text()`, `message.editTextMessage()`, `message.delete()`, `message.copy()` + ## 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)` +- Check `isinstance(result, types.Error)` after every Pytdbot API call - Log with `logger.error(..., exc_info=True)` for exceptions -- Admin access: `filters.user(Var.OWNER_ID)` on Pyrogram handlers (not `is_admin()`) +- Admin access: `Filters.user(Var.OWNER_ID)` on handlers (not `is_admin()`) - Naming: PascalCase classes, snake_case functions/vars, UPPER_SNAKE_CASE constants ## Rate Limiting @@ -74,7 +91,8 @@ Copy `config_sample.env` → `config.env`. Required vars: `API_ID`, `API_HASH`, ## Debugging -- Logs: `Thunder/logs/bot.txt` +- Logs: `Thunder/logs/bot.txt` (gitignored) - 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 +- Linting: `uv run ruff check Thunder/` and `uv run ruff format --check Thunder/` +- Type checking: `uv run pyright Thunder/ --level basic` +- Tests: `uv run pytest tests/ -v` (22 tests, requires pytdbot installed) diff --git a/API.md b/API.md new file mode 100644 index 00000000..7d55f7ec --- /dev/null +++ b/API.md @@ -0,0 +1,146 @@ +# API Documentation — Thunder FileToLink + +## HTTP Endpoints + +### `GET /` +Redirects to the GitHub repository. + +### `GET /health` +Health check endpoint for load balancers. + +**Response:** `200 OK` +```json +{"status": "ok"} +``` + +### `GET /metrics` +Prometheus-compatible metrics endpoint (requires `ADMIN_TOKEN` header if configured). + +**Response:** `200 OK` (text/plain) +``` +# HELP thunder_uptime_seconds Time since bot started +thunder_uptime_seconds 8130 +# HELP thunder_active_streams Currently active file streams +thunder_active_streams 3 +# HELP thunder_bytes_served_total Total bytes served via streaming +thunder_bytes_served_total 1073741824 +# HELP thunder_requests_total Total HTTP requests by path +thunder_requests_total{path="/f/hash123/file.mp4"} 42 +# HELP thunder_errors_total Total HTTP errors by status code +thunder_errors_total{status="404"} 5 +``` + +### `GET /status` +Returns bot status and resource usage (requires `ADMIN_TOKEN` header if configured). + +**Response:** `200 OK` +```json +{ + "status": "operational", + "version": "2.1.0", + "uptime": "2h 15m 30s", + "active_clients": 1, + "total_workload": 3 +} +``` + +### `GET /watch/f/{public_hash}/{filename}` +Renders an HTML streaming page with video player and download button. + +**Parameters:** +- `secure_hash` — 6-character file unique ID prefix +- `message_id` — Telegram message ID +- `filename` — Original filename (for display) + +**Response:** `200 OK` (text/html) — Cinema streaming player + +### `GET /watch/f/{public_hash}/{filename}` +Renders an HTML streaming page for canonical (deduplicated) files. + +**Parameters:** +- `public_hash` — 20-character SHA256 hash of file unique ID +- `filename` — Original filename + +**Response:** `200 OK` (text/html) — Cinema streaming player + +### `GET /f/{secure_hash}{message_id}/{filename}` +Downloads or streams a file directly. + +**Parameters:** +- `secure_hash` — 6-character file unique ID prefix +- `message_id` — Telegram message ID +- `filename` — Original filename + +**Query Parameters:** +- `disposition` — `inline` or `attachment` (default: `attachment`) + +**Headers:** +- `Range` — Byte range for partial content (e.g., `bytes=0-1023`) + +**Response:** +- `200 OK` — Full file +- `206 Partial Content` — Range request +- `404 Not Found` — File not found + +### `GET /f/{public_hash}/{filename}` +Downloads or streams a canonical (deduplicated) file. + +**Parameters:** +- `public_hash` — 20-character SHA256 hash +- `filename` — Original filename + +**Response:** Same as above. + +### `HEAD` on any endpoint +Returns headers without body (same as GET but no content). + +### `OPTIONS` on any endpoint +Returns CORS preflight headers. + +--- + +## Error Responses + +| Status | Meaning | +|--------|---------| +| `200` | Success | +| `206` | Partial content (range request) | +| `302` | Redirect | +| `400` | Bad request (invalid parameters) | +| `404` | Resource not found | +| `416` | Range not satisfiable | +| `500` | Internal server error (with error ID) | +| `503` | All clients at capacity | + +--- + +## Telegram Bot Commands + +### User Commands + +| Command | Description | +|---------|-------------| +| `/start` | Welcome message and token activation | +| `/help` | Usage instructions | +| `/about` | Bot information | +| `/link [n]` | Generate links (reply to file in groups, `n` for batch) | +| `/dc` | Data center info for user or file | +| `/ping` | Check bot responsiveness | + +### Admin Commands + +| Command | Description | +|---------|-------------| +| `/status` | Bot status and workload | +| `/stats` | System resource usage | +| `/users` | Total user count | +| `/broadcast [mode]` | Send message to all users | +| `/ban [reason]` | Ban a user or channel | +| `/unban ` | Unban a user or channel | +| `/authorize ` | Grant permanent access | +| `/deauthorize ` | Revoke permanent access | +| `/listauth` | List authorized users | +| `/log` | Send bot log file | +| `/restart` | Restart the bot | +| `/shell ` | Execute shell command (60s timeout) | +| `/speedtest` | Network speed test | diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 00000000..a3e55e4b --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,154 @@ +# Architecture — Thunder FileToLink + +## System Overview + +``` +┌─────────────────────────────────────────────────────────┐ +│ Telegram API (TDLib) │ +│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ +│ │ Primary │ │ Client 1 │ │ Client N │ Multi-client │ +│ │ Client │ │ │ │ │ load balancing │ +│ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ +│ └────────────┼────────────┘ │ +└────────────────────┼────────────────────────────────────┘ + │ +┌────────────────────┼────────────────────────────────────┐ +│ ▼ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ Bot Plugins │ │ +│ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ admin │ │ common │ │ stream │ │ │ +│ │ │ /status │ │ /start │ │ /link │ │ │ +│ │ │ /ban │ │ /help │ │ media │ │ │ +│ │ └─────────┘ └──────────┘ └────┬─────┘ │ │ +│ └─────────────────────────────────┼───────┘ │ +│ │ │ +│ ┌─────────────────────────────────▼───────┐ │ +│ │ Rate Limiter │ │ +│ │ Priority queue (authorized users) │ │ +│ │ Regular queue (all users) │ │ +│ │ Owner bypass │ │ +│ └─────────────────────────────────┬───────┘ │ +│ │ │ +│ ┌─────────────────────────────────▼───────┐ │ +│ │ Canonical Files Service │ │ +│ │ ┌───────────┐ ┌──────────────┐ │ │ +│ │ │ LRU Cache │ │ Ingest Lock │ │ │ +│ │ │ (3 dicts) │ │ (MongoDB) │ │ │ +│ │ └───────────┘ └──────────────┘ │ │ +│ │ File deduplication via SHA256 hash │ │ +│ └─────────────────────────────────┬───────┘ │ +│ │ │ +│ ┌─────────────────────────────────▼───────┐ │ +│ │ MongoDB (Database) │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ UserRepo │ │ BanRepo │ │ FileRepo │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │TokenRepo │ │ LockRepo │ │RestartRepo│ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ + │ +┌────────────────────┼────────────────────────────────────┐ +│ ▼ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ HTTP Server (aiohttp) │ │ +│ │ Middleware: CORS, Error, Metrics │ │ +│ │ ┌─────────┐ ┌──────────┐ ┌──────────┐ │ │ +│ │ │ /watch/ │ │ /f/ │ │ /health │ │ │ +│ │ │ /status │ │ /metrics │ │ │ │ │ +│ │ └─────────┘ └──────────┘ └──────────┘ │ │ +│ └─────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────┐ │ +│ │ ByteStreamer │ │ +│ │ downloadFile → aiofiles → chunked HTTP │ │ +│ └─────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────┘ +``` + +## Data Flow + +### File Upload → Link Generation + +1. User sends file to bot (private chat or group `/link`) +2. `stream.py` validates: ban check → token check → force channel check +3. Rate limiter checks limits, queues if needed +4. `canonical_files.py` checks if file already exists (by `file_unique_id`) +5. If new: forward to `BIN_CHANNEL`, build `FileRecord`, store in MongoDB +6. If exists: reuse canonical record, increment `reuse_count` +7. Generate links: `/{hash}{msg_id}/{name}` and `/watch/{hash}{msg_id}/{name}` +8. Send links to user (DM + group reply) + +### HTTP Request → File Streaming + +1. Client requests `/f/{hash}{msg_id}/{name}` +2. `stream_routes.py` parses hash + message_id from URL +3. `select_optimal_client()` picks client with lowest workload +4. `work_loads[client_id] += 1` +5. `ByteStreamer.get_file_info()` fetches message metadata +6. Hash validation: `file_unique_id[:6] == secure_hash` +7. `stream_file()` downloads file via TDLib, then streams via `aiofiles` +8. Range requests supported via `parse_range_header()` +9. `work_loads[client_id] -= 1` in `finally` block + +### Canonical File Deduplication + +Same file uploaded by different users → single BIN copy: + +1. `build_public_hash(file_unique_id)` → 20-char SHA256 prefix +2. Check LRU cache → check MongoDB → if found, reuse +3. If not found: acquire ingest claim (MongoDB lock) +4. Forward to BIN_CHANNEL, build `FileRecord` +5. Store in MongoDB + LRU cache +6. Release ingest claim + +## Key Components + +### Rate Limiter (`rate_limiter.py`) +- Two deque queues: priority (authorized) and regular +- Owner bypasses all limits +- Exponential backoff on requeue (0.5s → 1s → 2s → 4s → 8s) +- Max 5 requeues before dropping +- Periodic cleanup of stale entries (5-min interval) + +### Multi-Client (`clients.py`) +- Primary client + N additional clients from `MULTI_TOKEN*` env vars +- Each client has own TDLib instance and data directory +- `work_loads` dict tracks concurrent streams per client +- Backpressure: reject when all clients at `MAX_CONCURRENT_PER_CLIENT` (8) + +### Caching (`canonical_files.py`) +- Three LRU OrderedDict caches: by unique_id, by hash, by message_id +- 600s TTL, 4096 max items per cache +- Periodic pruning every 50 inserts +- Background touch batching (10s delay) + +### Database (`database/`) +- Mixin-based repository pattern +- MongoDB connection pool: maxPoolSize=50, minPoolSize=5 +- User existence cache: 5-min TTL +- Ban status cache: 5-min TTL with invalidation on ban/unban + +## Configuration + +All config via environment variables (see `config_sample.env`). + +**Required:** `API_ID`, `API_HASH`, `BOT_TOKEN`, `BIN_CHANNEL`, `DATABASE_URL` + +**Key Optional:** +- `MULTI_TOKEN1..49` — Additional bot tokens +- `FORCE_CHANNEL_ID` — Required channel join +- `TOKEN_ENABLED` — Token-based access control +- `RATE_LIMIT_ENABLED` — Rate limiting +- `GLOBAL_RATE_LIMIT` — Global request throttling + +## Deployment + +- **Docker:** `docker build -t thunder . && docker run thunder` +- **uv:** `uv sync && uv run python -m Thunder` +- **pip:** `pip install -r requirements.txt && python -m Thunder` + +Health check: `GET /health` +Metrics: `GET /metrics` diff --git a/Dockerfile b/Dockerfile index 5e7ede22..426b6f13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,23 +1,35 @@ -FROM python:3.13-slim - -ENV PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 - -WORKDIR /app - -RUN apt-get update && \ - apt-get install -y --no-install-recommends \ - git \ - build-essential \ - libssl-dev \ - && apt-get clean && \ - rm -rf /var/lib/apt/lists/* - -COPY requirements.txt . - -RUN pip install --upgrade pip && \ - pip install --no-cache-dir -r requirements.txt - -COPY . . - -CMD ["bash", "thunder.sh"] +FROM python:3.13-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +WORKDIR /app + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + git \ + build-essential \ + libssl-dev \ + curl \ + && apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +COPY --from=ghcr.io/astral-sh/uv:0.7 /uv /usr/local/bin/uv + +COPY pyproject.toml uv.lock ./ + +RUN uv sync --frozen --no-dev --no-install-project + +COPY . . + +ENV PATH="/app/.venv/bin:$PATH" + +RUN addgroup --system thunder && adduser --system --ingroup thunder thunder && \ + chown -R thunder:thunder /app + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD curl -f http://localhost:${PORT:-8080}/health || exit 1 + +USER thunder + +CMD ["python3", "-m", "Thunder"] diff --git a/README.md b/README.md index 78a722e5..80aeb6e9 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python Version - Pyrofork + Pytdbot License Telegram Channel

@@ -263,9 +263,9 @@ Thunder implements a sophisticated multi-tier rate limiting system designed for #### **Smart Queue Management** -- **Automatic Re-queuing**: Failed requests due to rate limits are intelligently re-queued. +- **Automatic Re-queuing**: Failed requests due to rate limits are intelligently re-queued with exponential backoff. - **Queue Size Limits**: Configurable maximum queue size. -- **Flood Protection**: Built-in protection against Telegram flood waits. +- **Head-of-Line Blocking Avoidance**: Scans queue for ready requests to prevent blocking. ### Network Speed Testing @@ -564,10 +564,9 @@ Licensed under the [Apache License 2.0](LICENSE). See the `LICENSE` file for det ## Acknowledgments -- [Pyrofork](https://github.com/Mayuri-Chan/pyrofork) - Telegram MTProto API Framework +- [Pytdbot](https://github.com/pytdbot/client) - Telegram TDLib 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 diff --git a/Thunder/__main__.py b/Thunder/__main__.py index a6c5473a..5ed24a32 100644 --- a/Thunder/__main__.py +++ b/Thunder/__main__.py @@ -1,328 +1,318 @@ -# 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 signal +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 pytdbot import types + +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.canonical_files import drain_background_touch_tasks +from Thunder.utils.commands import set_commands +from Thunder.utils.database import db +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, request_executor +from Thunder.utils.tokens import cleanup_expired_tokens +from Thunder.vars import Var + +PLUGIN_PATH = "Thunder/bot/plugins/*.py" +VERSION = __version__ + +_shutdown_event = asyncio.Event() + + +def _signal_handler(): + logger.info("Shutdown signal received.") + _shutdown_event.set() + + +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(): + import shutil + import tempfile + _download_dir = os.path.join(tempfile.gettempdir(), "thunder_downloads") + await asyncio.to_thread(shutil.rmtree, _download_dir, ignore_errors=True) + await asyncio.to_thread(os.makedirs, _download_dir, exist_ok=True) + + start_time = datetime.now() + print_banner() + print("╔════════════════ INITIALIZING BOT SERVICES ════════════════╗") + + print(" ▶ Starting Telegram Bot initialization...") + try: + await StreamBot.start() + bot_info = await StreamBot.getMe() + if isinstance(bot_info, types.Error): + logger.error(f" ✖ Failed to get bot info: {bot_info.message}") + return + + bot_username = "unknown" + if hasattr(bot_info, "usernames") and bot_info.usernames: + bot_username = bot_info.usernames.editable_username or "unknown" + else: + bot_username = getattr(bot_info, "username", "unknown") + + StreamBot.username = bot_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: + await StreamBot.editTextMessage( + chat_id=restart_message_data["chat_id"], + message_id=restart_message_data["message_id"], + text=MSG_ADMIN_RESTART_DONE, + ) + 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 + ) + + 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) + await _cleanup_all( + background_tasks=[request_executor_task] if 'request_executor_task' in locals() else [], + app_runner=None + ) + return + + elapsed_time = (datetime.now() - start_time).total_seconds() + print("╠═══════════════════════════════════════════════════════════╣") + print(f" ▶ Bot Name: {bot_info.first_name}") + print(f" ▶ Username: @{bot_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 + ] + + loop = asyncio.get_running_loop() + if sys.platform != 'win32': + for sig in (signal.SIGTERM, signal.SIGINT): + loop.add_signal_handler(sig, _signal_handler) + + try: + done, _ = await asyncio.wait( + [asyncio.create_task(StreamBot.run()), asyncio.create_task(_shutdown_event.wait())], + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + print(" ▶ Shutting down services...") + await _cleanup_all(background_tasks=background_tasks, app_runner=app_runner) + + +async def _cleanup_all(*, background_tasks: list, app_runner=None): + for task in background_tasks: + if not task.done(): + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + try: + await StreamBot.stop() + except Exception as e: + logger.error(f"Error stopping StreamBot: {e}") + + 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 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: + 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) + + +def main(): + """CLI entry point for the `thunder` console script.""" + try: + asyncio.run(start_services()) + except KeyboardInterrupt: + pass + + +if __name__ == '__main__': + main() diff --git a/Thunder/bot/__init__.py b/Thunder/bot/__init__.py index ea22191b..525c0a9c 100644 --- a/Thunder/bot/__init__.py +++ b/Thunder/bot/__init__.py @@ -1,16 +1,25 @@ -# Thunder/bot/__init__.py - -from pyrogram import Client -from Thunder.vars import Var - -StreamBot = Client( - name="Web Streamer", - api_id=Var.API_ID, - api_hash=Var.API_HASH, - bot_token=Var.BOT_TOKEN, - sleep_threshold=Var.SLEEP_THRESHOLD, - workers=Var.WORKERS -) - -multi_clients = {} -work_loads = {} +import os + +from pytdbot import Client + +from Thunder.vars import Var + +# Import message patch to fix outgoing message filtering +from Thunder.message_patch import apply_message_patch +apply_message_patch() + +_ENCRYPTION_KEY = b"thunder_tdlib_encryption_key_32b" + +StreamBot = Client( + token=Var.BOT_TOKEN, + api_id=Var.API_ID, + api_hash=Var.API_HASH, + files_directory=os.path.join("tdlib_data", "primary"), + database_encryption_key=_ENCRYPTION_KEY, + workers=Var.WORKERS, + td_verbosity=0, + default_parse_mode="markdown", # Enable Markdown parsing by default +) + +multi_clients = {} +work_loads = {} diff --git a/Thunder/bot/clients.py b/Thunder/bot/clients.py index 14cf307d..be1bc4c8 100644 --- a/Thunder/bot/clients.py +++ b/Thunder/bot/clients.py @@ -1,82 +1,74 @@ -# 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 os + +import pytdbot + +from Thunder.bot import StreamBot, multi_clients, work_loads, _ENCRYPTION_KEY +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: + 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 = pytdbot.Client( + token=token, + api_id=Var.API_ID, + api_hash=Var.API_HASH, + files_directory=os.path.join("tdlib_data", str(client_id)), + database_encryption_key=_ENCRYPTION_KEY, + no_updates=True, + workers=Var.WORKERS, + td_verbosity=0, + ) + await client.start() + work_loads[client_id] = 0 + print(f" ◎ Client ID {client_id} started") + return client_id, client + except Exception as e: + logger.error(f" ✖ Failed to start Client ID {client_id}. Error: {e}", exc_info=True) + return None + + clients = await asyncio.gather(*[start_client(i, token) for i, token in all_tokens.items() if token]) + clients = [client for client in clients if client] + + multi_clients.update(dict(clients)) + + if len(multi_clients) > 1: + Var.MULTI_CLIENT = True + print("╠══════════════════════ MULTI-CLIENT ═══════════════════════╣") + print(f" ◎ Total Clients: {len(multi_clients)} (Including primary client)") + + print(" ▶ Initial workload distribution:") + for client_id, load in work_loads.items(): + print(f" • Client {client_id}: {load} tasks") + + else: + print("╠═══════════════════════════════════════════════════════════╣") + print(" ▶ No additional clients were initialized") + print(" ▶ Primary client will handle all requests") diff --git a/Thunder/bot/plugins/admin.py b/Thunder/bot/plugins/admin.py index 82d0e729..b2b6aeca 100644 --- a/Thunder/bot/plugins/admin.py +++ b/Thunder/bot/plugins/admin.py @@ -1,53 +1,83 @@ -# 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 +import asyncio +import html +import os +import shutil +import sys +import time + +import psutil +import pytdbot +from pytdbot import types + +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 - -owner_filter = filters.private & filters.user(Var.OWNER_ID) +from Thunder.utils.broadcast import broadcast_message +from Thunder.utils.compat import Filters +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_AUTH_USER_INFO, + MSG_AUTHORIZE_FAILED, + MSG_AUTHORIZE_SUCCESS, + MSG_AUTHORIZE_USAGE, + MSG_BAN_REASON_SUFFIX, + MSG_BAN_USAGE, + MSG_BROADCAST_USAGE, + MSG_BUTTON_CLOSE, + MSG_CANNOT_BAN_OWNER, + MSG_CHANNEL_BANNED, + MSG_CHANNEL_BANNED_REASON_SUFFIX, + MSG_CHANNEL_NOT_BANNED, + MSG_CHANNEL_UNBANNED, + MSG_DB_ERROR, + MSG_DB_STATS, + MSG_DEAUTHORIZE_FAILED, + MSG_DEAUTHORIZE_SUCCESS, + MSG_DEAUTHORIZE_USAGE, + MSG_ERROR_GENERIC, + MSG_INVALID_BROADCAST_CMD, + MSG_INVALID_USER_ID, + MSG_LOG_FILE_CAPTION, + MSG_LOG_FILE_EMPTY, + MSG_LOG_FILE_MISSING, + MSG_NO_AUTH_USERS, + MSG_RESTARTING, + MSG_SHELL_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.speedtest import run_speedtest +from Thunder.utils.time_format import get_readable_time +from Thunder.utils.tokens import authorize, deauthorize, list_allowed +from Thunder.vars import Var + +def _write_file_sync(fd: int, content: str) -> None: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + f.write(content) + + +owner_filter = Filters.and_(Filters.private, Filters.user(Var.OWNER_ID)) _MARKDOWN_ESCAPE_TRANS = str.maketrans({ "\\": "\\\\", @@ -61,197 +91,212 @@ 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) - + + +def _make_close_button(): + return types.InlineKeyboardButton( + text=MSG_BUTTON_CLOSE, + type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel") + ) + + +@StreamBot.on_message(filters=Filters.command("users") & owner_filter) +async def get_total_users(client: pytdbot.Client, message: types.Message): + try: + total = await db.total_users_count() + await reply(message, + text=MSG_DB_STATS.format(total_users=total), + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[_make_close_button()]])) + 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=Filters.command("broadcast") & owner_filter) +async def broadcast_handler(client: pytdbot.Client, message: types.Message): + mode = "all" + text = getattr(message, "text", "") or "" + parts = text.split() + if len(parts) > 1: + arg = parts[1].lower().strip() + if arg in ("help", "--help", "-h"): + return await reply(message, text=MSG_BROADCAST_USAGE) + 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}") + return + + reply_to = getattr(message, "reply_to", None) + if not reply_to or not hasattr(reply_to, "message_id"): + return await reply(message, text=MSG_INVALID_BROADCAST_CMD) + + await broadcast_message(client, message, mode=mode) + + +@StreamBot.on_message(filters=Filters.command("status") & owner_filter) +async def show_status(client: pytdbot.Client, message: types.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, + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[_make_close_button()]])) + 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=Filters.command("stats") & owner_filter) +async def show_stats(client: pytdbot.Client, message: types.Message): + try: + def _collect_stats(): + sys_uptime = psutil.boot_time() + net_io_counters = psutil.net_io_counters() + cpu_percent = psutil.cpu_percent(interval=0.5) + cpu_cores = psutil.cpu_count(logical=False) + cpu_freq = psutil.cpu_freq() + ram_info = psutil.virtual_memory() + total_disk, used_disk, free_disk = shutil.disk_usage('.') + return { + 'sys_uptime': sys_uptime, + 'net_io': net_io_counters, + 'cpu_percent': cpu_percent, + 'cpu_cores': cpu_cores, + 'cpu_freq': cpu_freq, + 'ram': ram_info, + 'disk_total': total_disk, + 'disk_used': used_disk, + 'disk_free': free_disk, + 'disk_percent': (used_disk / total_disk * 100) if total_disk > 0 else 0, + } + + stats = await asyncio.to_thread(_collect_stats) + + sys_uptime_str = get_readable_time(int(time.time() - stats['sys_uptime'])) + bot_uptime_str = get_readable_time(int(time.time() - StartTime)) + cpu_freq_ghz = f"{stats['cpu_freq'].current / 1000:.2f}" if stats['cpu_freq'] else "N/A" + + stats_text_val = MSG_SYSTEM_STATS.format( + sys_uptime=sys_uptime_str, + bot_uptime=bot_uptime_str, + cpu_percent=stats['cpu_percent'], + cpu_cores=stats['cpu_cores'], + cpu_freq=cpu_freq_ghz, + ram_total=humanbytes(stats['ram'].total), + ram_used=humanbytes(stats['ram'].used), + ram_free=humanbytes(stats['ram'].free), + disk_percent=stats['disk_percent'], + total=humanbytes(stats['disk_total']), + used=humanbytes(stats['disk_used']), + free=humanbytes(stats['disk_free']), + upload=humanbytes(stats['net_io'].bytes_sent), + download=humanbytes(stats['net_io'].bytes_recv) + ) + + await reply(message, + text=stats_text_val, + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[_make_close_button()]])) + 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=Filters.command("restart") & owner_filter) +async def restart_bot(client: pytdbot.Client, message: types.Message): + msg = await reply(message, text=MSG_RESTARTING) + if msg and not isinstance(msg, types.Error): + await db.add_restart_message(msg.id, message.chat_id) + os.execv(sys.executable, [sys.executable, "-m", "Thunder"]) + + +@StreamBot.on_message(filters=Filters.command("log") & owner_filter) +async def send_logs(client: pytdbot.Client, message: types.Message): + log_exists = await asyncio.to_thread(os.path.exists, LOG_FILE) + if not log_exists or await asyncio.to_thread(os.path.getsize, LOG_FILE) == 0: + await reply( + message, + text=(MSG_LOG_FILE_MISSING if not log_exists else MSG_LOG_FILE_EMPTY) + ) + return + + try: + result = await client.sendDocument( + chat_id=message.chat_id, + document=types.InputFileLocal(path=LOG_FILE), + caption=MSG_LOG_FILE_CAPTION + ) + if isinstance(result, types.Error): + await reply(message, text=MSG_ERROR_GENERIC) + 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=Filters.command("authorize") & owner_filter) +async def authorize_command(client: pytdbot.Client, message: types.Message): + text = getattr(message, "text", "") or "" + parts = text.split() + if len(parts) != 2: + return await reply(message, text=MSG_AUTHORIZE_USAGE) + + try: + user_id = int(parts[1]) + success = await authorize(user_id, getattr(message, "from_id", 0)) + 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=Filters.command("deauthorize") & owner_filter) +async def deauthorize_command(client: pytdbot.Client, message: types.Message): + text = getattr(message, "text", "") or "" + parts = text.split() + if len(parts) != 2: + return await reply(message, text=MSG_DEAUTHORIZE_USAGE) + + try: + user_id = int(parts[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=Filters.command("listauth") & owner_filter) +async def list_authorized_command(client: pytdbot.Client, message: types.Message): + users = await list_allowed() + if not users: + return await reply(message, text=MSG_NO_AUTH_USERS) + text = MSG_ADMIN_AUTH_LIST_HEADER for i, user in enumerate(users, 1): display_name = "Unknown" 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" + raw_display_name = f"@{tg_user.username}" if hasattr(tg_user, "username") and 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) @@ -263,267 +308,223 @@ async def list_authorized_command(client: Client, message: Message): authorized_by=user['authorized_by'], auth_time=user['authorized_at'] ) - - await reply(message, - text=text, - parse_mode=ParseMode.MARKDOWN, - reply_markup=InlineKeyboardMarkup( - [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) - - -@StreamBot.on_message(filters.command("ban") & owner_filter) -async def ban_command(client: Client, message: Message): - if len(message.command) < 2: - return await reply(message, text=MSG_BAN_USAGE) - - try: - target_id = int(message.command[1]) - reason = " ".join(message.command[2:]) or MSG_ADMIN_NO_BAN_REASON - banned_by_id = message.from_user.id if message.from_user else None - - if target_id == Var.OWNER_ID: - return await reply(message, text=MSG_CANNOT_BAN_OWNER) - - if target_id < 0: - await db.add_banned_channel( - channel_id=target_id, - reason=reason, - banned_by=banned_by_id - ) - text = MSG_CHANNEL_BANNED.format(channel_id=target_id) - if reason != MSG_ADMIN_NO_BAN_REASON: - text += MSG_CHANNEL_BANNED_REASON_SUFFIX.format(reason=reason) - await reply(message, text=text) - try: - try: - await client.leave_chat(target_id) - except FloodWait as e: - logger.debug(f"FloodWait in leave_chat, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.leave_chat(target_id) - except Exception as e: - logger.warning(f"Could not leave banned channel {target_id}: {e}", exc_info=True) - else: - await db.add_banned_user( - user_id=target_id, - reason=reason, - banned_by=banned_by_id - ) - text = MSG_ADMIN_USER_BANNED.format(user_id=target_id) - if reason != MSG_ADMIN_NO_BAN_REASON: - text += MSG_BAN_REASON_SUFFIX.format(reason=reason) - await reply(message, text=text) - try: - try: - await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) - except FloodWait as e: - logger.debug(f"FloodWait in ban notification, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) - except Exception as e: - logger.warning(f"Could not notify banned user {target_id}: {e}", exc_info=True) - - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in ban_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("unban") & owner_filter) -async def unban_command(client: Client, message: Message): - if len(message.command) != 2: - return await reply(message, text=MSG_UNBAN_USAGE) - - try: - target_id = int(message.command[1]) - - if target_id < 0: - if await db.remove_banned_channel(channel_id=target_id): - await reply(message, text=MSG_CHANNEL_UNBANNED.format(channel_id=target_id)) - else: - await reply(message, text=MSG_CHANNEL_NOT_BANNED.format(channel_id=target_id)) - else: - if await db.remove_banned_user(user_id=target_id): - await reply(message, text=MSG_ADMIN_USER_UNBANNED.format(user_id=target_id)) - try: - try: - await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) - except FloodWait as e: - logger.debug(f"FloodWait in unban notification, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) - except Exception as e: - logger.warning(f"Could not notify unbanned user {target_id}: {e}", exc_info=True) - else: - await reply(message, text=MSG_USER_NOT_IN_BAN_LIST.format(user_id=target_id)) - except ValueError: - await reply(message, text=MSG_INVALID_USER_ID) - except Exception as e: - logger.error(f"Error in unban_command: {e}", exc_info=True) - await reply(message, text=MSG_ERROR_GENERIC) - - -@StreamBot.on_message(filters.command("shell") & owner_filter) -async def run_shell_command(client: Client, message: Message): - if len(message.command) < 2: - return await reply( - message, text=MSG_SHELL_USAGE, parse_mode=ParseMode.HTML) - - command = " ".join(message.command[1:]) - status_msg = await reply(message, - text=MSG_SHELL_EXECUTING.format( - command=html.escape(command)), - parse_mode=ParseMode.HTML) - - try: - process = await asyncio.create_subprocess_shell( - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE - ) - - stdout, stderr = await process.communicate() - - output = "" - if stdout: - output += MSG_SHELL_OUTPUT_STDOUT.format( - output=html.escape(stdout.decode(errors='ignore'))) - if stderr: - output += MSG_SHELL_OUTPUT_STDERR.format( - error=html.escape(stderr.decode(errors='ignore'))) - - output = output.strip() or MSG_SHELL_NO_OUTPUT - - try: - await status_msg.delete() - except FloodWait as e: - logger.debug(f"FloodWait in shell status message delete, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.delete() - - if len(output) > 4096: - file = BytesIO(output.encode()) - file.name = "shell_output.txt" - try: - await message.reply_document( - file, - caption=MSG_SHELL_OUTPUT.format( - command=html.escape(command))) - except FloodWait as e: - logger.debug(f"FloodWait in shell output document, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_document( - file, - caption=MSG_SHELL_OUTPUT.format( - command=html.escape(command))) - else: - await reply(message, text=output, parse_mode=ParseMode.HTML) - - except Exception as e: - try: - try: - await status_msg.edit_text( - MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - except FloodWait as e: - logger.debug(f"FloodWait in shell error message edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - except MessageNotModified: - pass - except Exception: - await reply( - message, - text=MSG_SHELL_ERROR.format(error=html.escape(str(e))), - parse_mode=ParseMode.HTML) - - -@StreamBot.on_message(filters.command("speedtest") & owner_filter) -async def speedtest_command(client: Client, message: Message): - status_msg = await reply(message, text=MSG_SPEEDTEST_INIT) - try: - result_dict, image_url = await run_speedtest() - if result_dict is None: - try: - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest error edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except MessageNotModified: - pass - return - - result_text = _format_speedtest_result(result_dict) - await _send_result(message, status_msg, result_text, image_url) - except Exception as e: - logger.error(f"Error in speedtest_command: {e}", exc_info=True) - try: - try: - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest exception error edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(MSG_SPEEDTEST_ERROR) - except MessageNotModified: - pass - except Exception: - await reply(message, text=MSG_SPEEDTEST_ERROR) - - -def _format_speedtest_result(result_dict: dict) -> str: - s, c = result_dict['server'], result_dict['client'] - return MSG_SPEEDTEST_RESULT.format( - download_mbps=_fmt(result_dict['download_mbps']), - upload_mbps=_fmt(result_dict['upload_mbps']), - download_bps=humanbytes(result_dict['download_bps']), - upload_bps=humanbytes(result_dict['upload_bps']), - ping=_fmt(result_dict['ping']), - timestamp=result_dict['timestamp'], - bytes_sent=humanbytes(result_dict['bytes_sent']), - bytes_received=humanbytes(result_dict['bytes_received']), - server_name=s['name'], - server_country=f"{s['country']} ({s['cc']})", - server_sponsor=s['sponsor'], - server_latency=_fmt(s['latency']), - server_lat=_fmt(s['lat'], 4), - server_lon=_fmt(s['lon'], 4), - client_ip=c['ip'], - client_lat=_fmt(c['lat'], 4), - client_lon=_fmt(c['lon'], 4), - client_isp=c['isp'], - client_isprating=c['isprating'], - client_country=c['country'] - ) - - -async def _send_result(message: Message, status_msg: Message, result_text: str, image_url: str): - if image_url: - try: - await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest photo reply, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) - try: - await status_msg.delete() - except FloodWait as e: - logger.debug(f"FloodWait in speedtest status delete, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.delete() - else: - try: - await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) - except FloodWait as e: - logger.debug(f"FloodWait in speedtest result edit, sleeping for {e.value}s") - await asyncio.sleep(e.value) - await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) - except MessageNotModified: - pass - - -def _fmt(value, decimals: int = 2) -> str: + + await reply(message, + text=text, + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[_make_close_button()]])) + + +@StreamBot.on_message(filters=Filters.command("ban") & owner_filter) +async def ban_command(client: pytdbot.Client, message: types.Message): + text = getattr(message, "text", "") or "" + parts = text.split() + if len(parts) < 2: + return await reply(message, text=MSG_BAN_USAGE) + + try: + target_id = int(parts[1]) + reason = " ".join(parts[2:]) or MSG_ADMIN_NO_BAN_REASON + banned_by_id = getattr(message, "from_id", 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) + result = await client.leaveChat(chat_id=target_id) + if isinstance(result, types.Error): + logger.warning(f"Could not leave banned channel {target_id}: {result.message}") + 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) + result = await client.sendTextMessage(chat_id=target_id, text=MSG_USER_BANNED_NOTIFICATION) + if isinstance(result, types.Error): + logger.warning(f"Could not notify banned user {target_id}: {result.message}") + + 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=Filters.command("unban") & owner_filter) +async def unban_command(client: pytdbot.Client, message: types.Message): + text = getattr(message, "text", "") or "" + parts = text.split() + if len(parts) != 2: + return await reply(message, text=MSG_UNBAN_USAGE) + + try: + target_id = int(parts[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)) + result = await client.sendTextMessage(chat_id=target_id, text=MSG_USER_UNBANNED_NOTIFICATION) + if isinstance(result, types.Error): + logger.warning(f"Could not notify unbanned user {target_id}: {result.message}") + 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=Filters.command("shell") & owner_filter) +async def run_shell_command(client: pytdbot.Client, message: types.Message): + text = getattr(message, "text", "") or "" + parts = text.split() + if len(parts) < 2: + return await reply(message, text=MSG_SHELL_USAGE) + + command = " ".join(parts[1:]) + status_msg = await reply(message, text=MSG_SHELL_EXECUTING.format(command=html.escape(command))) + + try: + proc = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60) + except TimeoutError: + proc.kill() + await proc.communicate() + await reply(message, text="⏱️ **Shell command timed out** (60s limit)") + return + + 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 + + if status_msg and not isinstance(status_msg, types.Error): + try: + await status_msg.delete() + except Exception: + logger.debug(f"Failed to delete shell status message {status_msg.id}") + + if len(output) > 4096: + import tempfile + tmp_fd, tmp_path = tempfile.mkstemp(suffix='.txt', prefix='shell_') + try: + await asyncio.to_thread(_write_file_sync, tmp_fd, output) + result = await client.sendDocument( + chat_id=message.chat_id, + document=types.InputFileLocal(path=tmp_path), + caption=MSG_SHELL_OUTPUT.format(command=html.escape(command)) + ) + if isinstance(result, types.Error): + await reply(message, text=f"Failed to send output: {result.message}") + finally: + try: + await asyncio.to_thread(os.remove, tmp_path) + except OSError: + pass + else: + await reply(message, text=output) + + except Exception as e: + logger.error(f"Error in run_shell_command: {e}", exc_info=True) + if status_msg and not isinstance(status_msg, types.Error): + try: + await status_msg.editTextMessage(chat_id=status_msg.chat_id, message_id=status_msg.id, text=MSG_SHELL_ERROR.format(error=html.escape(str(e)))) + except Exception: + logger.debug("Failed to edit shell error status message") + + +@StreamBot.on_message(filters=Filters.command("speedtest") & owner_filter) +async def speedtest_command(client: pytdbot.Client, message: types.Message): + status_msg = await reply(message, text=MSG_SPEEDTEST_INIT) + try: + result_dict, image_url = await run_speedtest() + if result_dict is None: + if status_msg and not isinstance(status_msg, types.Error): + try: + await status_msg.editTextMessage(chat_id=status_msg.chat_id, message_id=status_msg.id, text=MSG_SPEEDTEST_ERROR) + except Exception: + logger.debug("Failed to edit speedtest error status message") + return + + result_text = _format_speedtest_result(result_dict) + if image_url: + try: + result = await client.sendPhoto( + chat_id=message.chat_id, + photo=types.InputFileRemote(id=image_url), + caption=result_text + ) + if isinstance(result, types.Error): + if status_msg and not isinstance(status_msg, types.Error): + await status_msg.editTextMessage(chat_id=status_msg.chat_id, message_id=status_msg.id, text=result_text) + except Exception: + if status_msg and not isinstance(status_msg, types.Error): + await status_msg.editTextMessage(chat_id=status_msg.chat_id, message_id=status_msg.id, text=result_text) + else: + if status_msg and not isinstance(status_msg, types.Error): + await status_msg.editTextMessage(chat_id=status_msg.chat_id, message_id=status_msg.id, text=result_text) + except Exception as e: + logger.error(f"Error in speedtest_command: {e}", exc_info=True) + if status_msg and not isinstance(status_msg, types.Error): + try: + await status_msg.editTextMessage(chat_id=status_msg.chat_id, message_id=status_msg.id, text=MSG_SPEEDTEST_ERROR) + except Exception: + logger.debug("Failed to edit speedtest error status message") + + +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'] + ) + + +def _fmt(value, decimals: int = 2) -> str: return f"{float(value):.{decimals}f}" diff --git a/Thunder/bot/plugins/callbacks.py b/Thunder/bot/plugins/callbacks.py index ab6d71a1..2188670a 100644 --- a/Thunder/bot/plugins/callbacks.py +++ b/Thunder/bot/plugins/callbacks.py @@ -1,223 +1,214 @@ -# 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 pytdbot +from pytdbot import types + +from Thunder.bot import StreamBot +from Thunder.utils.broadcast import broadcast_ids +from Thunder.utils.compat import Filters +from Thunder.utils.decorators import owner_only +from Thunder.utils.logger import logger +from Thunder.utils.telegram_helpers import is_error +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: pytdbot.Client): + if not Var.FORCE_CHANNEL_ID: + return None + try: + chat = await client.getChat(chat_id=Var.FORCE_CHANNEL_ID) + if isinstance(chat, types.Error): + return None + if chat: + invite_link = None + if hasattr(chat, "invite_link") and chat.invite_link: + invite_link = chat.invite_link + if not invite_link: + if hasattr(chat, "type") and isinstance(chat.type, types.ChatTypeSupergroup): + sg_id = chat.type.supergroup_id + invite_link = f"https://t.me/c/{sg_id}" + if invite_link: + return [types.InlineKeyboardButton( + text=MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title or "Channel"), + type=types.InlineKeyboardButtonTypeUrl(url=invite_link) + )] + except Exception as e: + logger.error(f"Error getting force channel button: {e}", exc_info=True) + return None + + +def _cb_data(callback_query: types.UpdateNewCallbackQuery) -> str: + payload = callback_query.payload + if isinstance(payload, types.CallbackQueryPayloadData): + return payload.data.decode("utf-8", errors="replace") + return "" + + +async def _edit_cb_message(client: pytdbot.Client, cq: types.UpdateNewCallbackQuery, text: str, reply_markup=None): + return await client.editTextMessage( + chat_id=cq.chat_id, + message_id=cq.message_id, + text=text, + reply_markup=reply_markup, + ) + + +async def _delete_cb_message(client: pytdbot.Client, cq: types.UpdateNewCallbackQuery): + return await client.deleteMessages( + chat_id=cq.chat_id, + message_ids=[cq.message_id], + revoke=True, + ) + + +@StreamBot.on_updateNewCallbackQuery(filters=Filters.regex(r"^help_command$")) +async def help_callback(client: pytdbot.Client, callback_query: types.UpdateNewCallbackQuery): + try: + result = await callback_query.answer(text="", show_alert=False) + if is_error(result): + logger.debug(f"Callback answer failed: {result.message}") + buttons = [[types.InlineKeyboardButton( + text=MSG_BUTTON_ABOUT, + type=types.InlineKeyboardButtonTypeCallback(data=b"about_command") + )]] + force_button = await get_force_channel_button(client) + if force_button: + buttons.append(force_button) + buttons.append([types.InlineKeyboardButton( + text=MSG_BUTTON_CLOSE, + type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel") + )]) + try: + await _edit_cb_message( + client, callback_query, + text=MSG_HELP.format(max_files=Var.MAX_BATCH_FILES), + reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons) + ) + except Exception as e: + logger.error(f"Error editing help message: {e}", exc_info=True) + except Exception as e: + logger.error(f"Error in help callback: {e}", exc_info=True) + + +@StreamBot.on_updateNewCallbackQuery(filters=Filters.regex(r"^about_command$")) +async def about_callback(client: pytdbot.Client, callback_query: types.UpdateNewCallbackQuery): + try: + result = await callback_query.answer(text="", show_alert=False) + if is_error(result): + logger.debug(f"Callback answer failed: {result.message}") + buttons = [ + [types.InlineKeyboardButton( + text=MSG_BUTTON_GET_HELP, + type=types.InlineKeyboardButtonTypeCallback(data=b"help_command") + )], + [ + types.InlineKeyboardButton( + text=MSG_BUTTON_GITHUB, + type=types.InlineKeyboardButtonTypeUrl(url="https://github.com/fyaz05/FileToLink") + ), + types.InlineKeyboardButton( + text=MSG_BUTTON_CLOSE, + type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel") + ) + ] + ] + try: + await _edit_cb_message( + client, callback_query, + text=MSG_ABOUT, + reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons) + ) + except Exception as e: + logger.error(f"Error editing about message: {e}", exc_info=True) + except Exception as e: + logger.error(f"Error in about callback: {e}", exc_info=True) + + +@StreamBot.on_updateNewCallbackQuery(filters=Filters.regex(r"^restart_broadcast$")) +async def restart_broadcast_callback(client: pytdbot.Client, callback_query: types.UpdateNewCallbackQuery): + if not await owner_only(client, callback_query): + return + try: + result = await callback_query.answer(text=MSG_ERROR_BROADCAST_RESTART, show_alert=True) + if is_error(result): + logger.debug(f"Callback answer failed: {result.message}") + buttons = [ + [ + types.InlineKeyboardButton( + text=MSG_BUTTON_GET_HELP, + type=types.InlineKeyboardButtonTypeCallback(data=b"help_command") + ), + types.InlineKeyboardButton( + text=MSG_BUTTON_CLOSE, + type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel") + ) + ] + ] + try: + await _edit_cb_message( + client, callback_query, + text=MSG_ERROR_BROADCAST_INSTRUCTION, + reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons) + ) + except Exception as e: + logger.error(f"Error editing broadcast restart message: {e}", exc_info=True) + except Exception as e: + logger.error(f"Error in restart broadcast callback: {e}", exc_info=True) + + +@StreamBot.on_updateNewCallbackQuery(filters=Filters.regex(r"^close_panel$")) +async def close_panel_callback(client: pytdbot.Client, callback_query: types.UpdateNewCallbackQuery): + try: + result = await callback_query.answer(text="", show_alert=False) + if is_error(result): + logger.debug(f"Callback answer failed: {result.message}") + try: + await _delete_cb_message(client, callback_query) + except Exception as e: + logger.debug(f"Failed to delete callback message: {e}") + except Exception as e: + logger.error(f"General error in close panel callback: {e}", exc_info=True) + + +@StreamBot.on_updateNewCallbackQuery(filters=Filters.regex(r"^cancel_")) +async def cancel_broadcast(client: pytdbot.Client, callback_query: types.UpdateNewCallbackQuery): + try: + data = _cb_data(callback_query) + broadcast_id = data.split("_")[1] if "_" in data else "" + if broadcast_id in broadcast_ids: + broadcast_ids[broadcast_id]["cancelled"] = True + await callback_query.answer(text="Broadcast cancelled.", show_alert=False) + try: + await _edit_cb_message( + client, callback_query, + text=MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id) + ) + except Exception as e: + logger.error(f"Error editing cancel message: {e}", exc_info=True) + else: + result = await callback_query.answer(text=MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id), show_alert=True) + if is_error(result): + logger.debug(f"Callback answer failed: {result.message}") + except Exception as e: + logger.error(f"Error in cancel broadcast callback: {e}", exc_info=True) + + +@StreamBot.on_updateNewCallbackQuery() +async def fallback_callback(client: pytdbot.Client, callback_query: types.UpdateNewCallbackQuery): + try: + result = await callback_query.answer(text=MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True) + if is_error(result): + logger.debug(f"Callback answer failed: {result.message}") + except Exception as e: + logger.error(f"Error in fallback callback: {e}", exc_info=True) diff --git a/Thunder/bot/plugins/common.py b/Thunder/bot/plugins/common.py index 2b6344dd..89514663 100644 --- a/Thunder/bot/plugins/common.py +++ b/Thunder/bot/plugins/common.py @@ -1,280 +1,298 @@ -# 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 time +from datetime import UTC, datetime, timedelta + +import pytdbot +from pytdbot import types + +from Thunder.bot import StreamBot +from Thunder.utils.bot_utils import gen_dc_txt, get_user, log_newusr, reply_user_err +from Thunder.utils.compat import Filters, _get_media_file +from Thunder.utils.database import db +from Thunder.utils.decorators import check_banned +from Thunder.utils.file_properties import get_fname, get_fsize +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=Filters.command("start") & Filters.private) +async def start_command(bot: pytdbot.Client, msg: types.Message): + if not await check_banned(bot, msg): + return + from_id = getattr(msg, "from_id", 0) + if from_id: + await log_newusr(bot, from_id, "") + + text = getattr(msg, "text", "") or "" + parts = text.split() + if len(parts) >= 2: + payload = parts[1] + + if payload != "start": + token = await db.token_col.find_one({"token": payload}) + if token: + if token["user_id"] != from_id: + try: + return await msg.reply_text(MSG_TOKEN_FAILED.format( + reason="This activation link is not for your account.", + error_id=str(int(time.time()))[-8:] + )) + except Exception: + logger.debug(f"Failed to send token mismatch error to user {from_id}") + return + + if token.get("activated"): + try: + return await msg.reply_text(MSG_TOKEN_FAILED.format( + reason="Token has already been activated.", + error_id=str(int(time.time()))[-8:] + )) + except Exception: + logger.debug(f"Failed to send token already activated error to user {from_id}") + return + + now = datetime.now(UTC) + exp = now + timedelta(hours=Var.TOKEN_TTL_HOURS) + + await db.token_col.update_one( + {"token": payload, "user_id": from_id}, + {"$set": {"activated": True, "created_at": now, "expires_at": exp}} + ) + + hrs = round((exp - now).total_seconds() / 3600, 1) + try: + return await msg.reply_text(MSG_TOKEN_ACTIVATED.format(duration_hours=hrs)) + except Exception: + logger.debug(f"Failed to send token activated confirmation to user {from_id}") + return + else: + try: + return await msg.reply_text(MSG_TOKEN_INVALID) + except Exception: + logger.debug(f"Failed to send token invalid message to user {from_id}") + return + + txt = MSG_WELCOME.format(user_name="") + link, title = await get_force_info(bot) + if link: + txt += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=title)}" + + buttons = [ + [types.InlineKeyboardButton(text=MSG_BUTTON_GET_HELP, type=types.InlineKeyboardButtonTypeCallback(data=b"help_command")), + types.InlineKeyboardButton(text=MSG_BUTTON_ABOUT, type=types.InlineKeyboardButtonTypeCallback(data=b"about_command"))], + [types.InlineKeyboardButton(text=MSG_BUTTON_GITHUB, type=types.InlineKeyboardButtonTypeUrl(url="https://github.com/fyaz05/FileToLink/")), + types.InlineKeyboardButton(text=MSG_BUTTON_CLOSE, type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel"))] + ] + + if link: + buttons.append([types.InlineKeyboardButton(text=MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), type=types.InlineKeyboardButtonTypeUrl(url=link))]) + + try: + await msg.reply_text(txt, reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons)) + except Exception as e: + logger.error(f"Error in start_command: {e}", exc_info=True) + + +@StreamBot.on_message(filters=Filters.command("help") & Filters.private) +async def help_command(bot: pytdbot.Client, msg: types.Message): + if not await check_banned(bot, msg): + return + from_id = getattr(msg, "from_id", 0) + if from_id: + await log_newusr(bot, from_id, "") + + txt = MSG_HELP.format(max_files=Var.MAX_BATCH_FILES) + buttons = [[types.InlineKeyboardButton(text=MSG_BUTTON_ABOUT, type=types.InlineKeyboardButtonTypeCallback(data=b"about_command"))]] + + link, title = await get_force_info(bot) + if link: + buttons.append([types.InlineKeyboardButton(text=MSG_BUTTON_JOIN_CHANNEL.format(channel_title=title), type=types.InlineKeyboardButtonTypeUrl(url=link))]) + + buttons.append([types.InlineKeyboardButton(text=MSG_BUTTON_CLOSE, type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel"))]) + try: + await msg.reply_text(txt, reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons)) + except Exception as e: + logger.error(f"Error in help_command: {e}", exc_info=True) + + +@StreamBot.on_message(filters=Filters.command("about") & Filters.private) +async def about_command(bot: pytdbot.Client, msg: types.Message): + if not await check_banned(bot, msg): + return + from_id = getattr(msg, "from_id", 0) + if from_id: + await log_newusr(bot, from_id, "") + + buttons = [ + [types.InlineKeyboardButton(text=MSG_BUTTON_GET_HELP, type=types.InlineKeyboardButtonTypeCallback(data=b"help_command"))], + [types.InlineKeyboardButton(text=MSG_BUTTON_GITHUB, type=types.InlineKeyboardButtonTypeUrl(url="https://github.com/fyaz05/FileToLink/")), + types.InlineKeyboardButton(text=MSG_BUTTON_CLOSE, type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel"))] + ] + + try: + await msg.reply_text(MSG_ABOUT, reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons)) + except Exception as e: + logger.error(f"Error in about_command: {e}", exc_info=True) + + +async def send_user_dc(msg: types.Message, user: types.User): + txt = await gen_dc_txt(user) + url = f"https://t.me/{user.username}" if hasattr(user, "username") and user.username else f"tg://user?id={user.id}" + buttons = [ + [types.InlineKeyboardButton(text=MSG_BUTTON_VIEW_PROFILE, type=types.InlineKeyboardButtonTypeUrl(url=url))], + [types.InlineKeyboardButton(text=MSG_BUTTON_CLOSE, type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel"))] + ] + try: + await msg.reply_text(txt, reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons)) + except Exception as e: + logger.error(f"Error sending user DC: {e}") + + +async def send_file_dc(msg: types.Message, file_msg: types.Message): + try: + fname = get_fname(file_msg) or "Untitled File" + fsize = humanbytes(get_fsize(file_msg)) + + content = getattr(file_msg, "content", None) + type_map = { + "MessageDocument": MSG_FILE_TYPE_DOCUMENT, + "MessagePhoto": MSG_FILE_TYPE_PHOTO, + "MessageVideo": MSG_FILE_TYPE_VIDEO, + "MessageAudio": MSG_FILE_TYPE_AUDIO, + "MessageVoiceNote": MSG_FILE_TYPE_VOICE, + "MessageSticker": MSG_FILE_TYPE_STICKER, + "MessageAnimation": MSG_FILE_TYPE_ANIMATION, + "MessageVideoNote": MSG_FILE_TYPE_VIDEO_NOTE, + } + + file_type = type(content).__name__ if content else "unknown" + type_display = type_map.get(file_type, MSG_FILE_TYPE_UNKNOWN) + + dc_id = MSG_DC_UNKNOWN + media_file = _get_media_file(file_msg) + if media_file and hasattr(media_file, "remote"): + dc_id = getattr(media_file.remote, "dc_id", MSG_DC_UNKNOWN) or MSG_DC_UNKNOWN + + txt = MSG_DC_FILE_INFO.format( + file_name=fname, + file_size=fsize, + file_type=type_display, + dc_id=dc_id + ) + + buttons = [[types.InlineKeyboardButton(text=MSG_BUTTON_CLOSE, type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel"))]] + await msg.reply_text(txt, reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons)) + + 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=Filters.command("dc")) +async def dc_command(bot: pytdbot.Client, msg: types.Message): + if not await check_banned(bot, msg): + return + if not await force_channel_check(bot, msg): + return + + from_id = getattr(msg, "from_id", None) + reply_to = getattr(msg, "reply_to", None) + + if not from_id and not reply_to: + return await reply_user_err(msg, MSG_DC_ANON_ERROR) + + text = getattr(msg, "text", "") or "" + args = 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 reply_to and hasattr(reply_to, "message_id"): + ref_result = await bot.getMessage(chat_id=msg.chat_id, message_id=reply_to.message_id) + if not isinstance(ref_result, types.Error) and ref_result: + content = getattr(ref_result, "content", None) + if content and _get_media_file(ref_result): + await send_file_dc(msg, ref_result) + else: + sender_id = getattr(ref_result, "from_id", None) + if sender_id: + user = await get_user(bot, sender_id) + if user: + await send_user_dc(msg, user) + else: + await reply_user_err(msg, MSG_ERROR_USER_INFO) + else: + await reply_user_err(msg, MSG_DC_INVALID_USAGE) + return + + if from_id: + user = await get_user(bot, from_id) + if user: + await send_user_dc(msg, user) + else: + await reply_user_err(msg, MSG_DC_ANON_ERROR) + + +@StreamBot.on_message(filters=Filters.command("ping") & Filters.private) +async def ping_command(bot: pytdbot.Client, msg: types.Message): + if not await check_banned(bot, msg): + return + if not await force_channel_check(bot, msg): + return + start = time.time() + sent = await msg.reply_text(MSG_PING_START) + end = time.time() + ms = (end - start) * 1000 + + buttons = [ + [types.InlineKeyboardButton(text=MSG_BUTTON_GET_HELP, type=types.InlineKeyboardButtonTypeCallback(data=b"help_command")), + types.InlineKeyboardButton(text=MSG_BUTTON_CLOSE, type=types.InlineKeyboardButtonTypeCallback(data=b"close_panel"))] + ] + + if sent and not isinstance(sent, types.Error): + try: + await sent.editTextMessage( + chat_id=msg.chat_id, + message_id=sent.id, + text=MSG_PING_RESPONSE.format(time_taken_ms=ms), + reply_markup=types.ReplyMarkupInlineKeyboard(rows=buttons) + ) + except Exception as e: + logger.error(f"Error editing ping message: {e}", exc_info=True) diff --git a/Thunder/bot/plugins/stream.py b/Thunder/bot/plugins/stream.py index 02c3368a..a23dcf1f 100644 --- a/Thunder/bot/plugins/stream.py +++ b/Thunder/bot/plugins/stream.py @@ -1,84 +1,109 @@ -# 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 secrets +from typing import Any + +import pytdbot +from pytdbot import types 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 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.compat import ( + Filters, + _get_media_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 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 +BATCH_CONCURRENCY = 3 + + +async def fwd_media(m_msg: types.Message) -> types.Message | None: + for attempt in range(3): + try: + result = await StreamBot.sendCopy( + chat_id=Var.BIN_CHANNEL, + from_chat_id=m_msg.chat_id, + message_id=m_msg.id, + ) + if isinstance(result, types.Error): + logger.error(f"Error fwd_media copy (attempt {attempt + 1}): {result.message}") + if attempt < 2: + await asyncio.sleep(1 * (2 ** attempt)) + continue + return None + return result + except Exception as e: + logger.error(f"Error fwd_media copy (attempt {attempt + 1}): {e}", exc_info=True) + if attempt < 2: + await asyncio.sleep(1 * (2 ** attempt)) + continue + return None + return None + + +def get_link_buttons(links): + return types.ReplyMarkupInlineKeyboard(rows=[[ + types.InlineKeyboardButton( + text=MSG_BUTTON_STREAM_NOW, + type=types.InlineKeyboardButtonTypeUrl(url=links['stream_link']) + ), + types.InlineKeyboardButton( + text=MSG_BUTTON_DOWNLOAD, + type=types.InlineKeyboardButtonTypeUrl(url=links['online_link']) + ) + ]]) + + +async def validate_request_common(client: pytdbot.Client, message: types.Message) -> bool | None: + if not await check_banned(client, message): + return None + if not await require_token(client, message): + return None + if not await force_channel_check(client, message): + return None + return await get_shortener_status(client, message) + + async def send_channel_links( - links: Dict[str, Any], + links: dict[str, Any], source_info: str, source_id: int, *, - target_msg: Optional[Message] = None, - reply_to_message_id: Optional[int] = None + target_msg: types.Message | None = None, + reply_to_message_id: int | None = None ): try: text = MSG_NEW_FILE_REQUEST.format( @@ -88,257 +113,207 @@ async def send_channel_links( stream_link=links['stream_link'] ) if target_msg: - await target_msg.reply_text( - text, - disable_web_page_preview=True, - quote=True - ) + await target_msg.reply_text(text) else: - await StreamBot.send_message( + result = await StreamBot.sendTextMessage( chat_id=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 or 0 ) - except FloodWait as e: - await asyncio.sleep(e.value) - text = MSG_NEW_FILE_REQUEST.format( - source_info=source_info, - id_=source_id, - online_link=links['online_link'], - stream_link=links['stream_link'] + if isinstance(result, types.Error): + logger.warning(f"Failed to send channel links: {result.message}") + except Exception as e: + logger.error(f"Error sending channel links: {e}", exc_info=True) + + +async def safe_edit_message(message: types.Message, text: str, **kwargs): + try: + result = await message.editTextMessage( + chat_id=message.chat_id, + message_id=message.id, + text=text ) - if target_msg: - await target_msg.reply_text( - text, - disable_web_page_preview=True, - quote=True - ) + if isinstance(result, types.Error): + logger.debug(f"Failed to edit message: {result.message}") + except Exception as e: + logger.debug(f"Error editing message: {e}") + + +async def safe_delete_message(message: types.Message): + try: + await message.delete() + except Exception as e: + logger.debug(f"Failed to delete message: {e}") + + +async def send_dm_links(bot: pytdbot.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'] + ) + result = await bot.sendTextMessage( + chat_id=user_id, + text=dm_text, + reply_markup=get_link_buttons(links) + ) + if isinstance(result, types.Error): + logger.warning(f"Failed to send DM links to user {user_id}: {result.message}") + except Exception as e: + logger.error(f"Error sending DM to user {user_id}: {e}", exc_info=True) + + +async def send_link(msg: types.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'] + ), + reply_markup=get_link_buttons(links) + ) + except Exception as e: + logger.error(f"Error sending link: {e}", exc_info=True) + + +def _is_group_chat(message: types.Message) -> bool: + chat = getattr(message, "chat", None) + if chat and isinstance(chat.type, (types.ChatTypeBasicGroup, types.ChatTypeSupergroup)): + if isinstance(chat.type, types.ChatTypeSupergroup): + return not chat.type.is_channel + return True + return False + + +def _is_channel_chat(message: types.Message) -> bool: + chat = getattr(message, "chat", None) + if chat and isinstance(chat.type, types.ChatTypeSupergroup): + return chat.type.is_channel + return False + + +@StreamBot.on_message(filters=Filters.command("link") & ~Filters.private) +async def link_handler(bot: pytdbot.Client, msg: types.Message, **kwargs): + async def _actual_link_handler(client: pytdbot.Client, message: types.Message, **handler_kwargs): + shortener_val = await validate_request_common(client, message) + if shortener_val is None: + return + + from_id = getattr(message, "from_id", 0) + if from_id and not await db.is_user_exist(from_id): + me = await client.getMe() + bot_username = "bot" + if not isinstance(me, types.Error): + if hasattr(me, "usernames") and me.usernames: + bot_username = me.usernames.editable_username or "bot" + else: + bot_username = getattr(me, "username", "bot") + invite_link = f"https://t.me/{bot_username}?start=start" + try: + await message.reply_text( + MSG_ERROR_START_BOT.format(invite_link=invite_link), + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[ + types.InlineKeyboardButton( + text=MSG_BUTTON_START_CHAT, + type=types.InlineKeyboardButtonTypeUrl(url=invite_link) + ) + ]]) + ) + except Exception: + logger.debug(f"Failed to send start bot invite to user {from_id}") + pass + return + + if _is_group_chat(message) and not await is_admin(client, message.chat_id): + await reply_user_err(message, MSG_ERROR_NOT_ADMIN) + return + + reply_to = getattr(message, "reply_to", None) + replied_has_media = False + if reply_to and hasattr(reply_to, "message_id"): + ref = await client.getMessage(chat_id=message.chat_id, message_id=reply_to.message_id) + if not isinstance(ref, types.Error) and ref: + replied_has_media = _get_media_file(ref) is not None + + if not reply_to or not replied_has_media: + await reply_user_err( + message, + MSG_ERROR_REPLY_FILE if not reply_to else MSG_ERROR_NO_FILE) + return + + notification_msg = handler_kwargs.get('notification_msg') + + text = getattr(message, "text", "") or "" + parts = 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 + + status_msg = await message.reply_text(MSG_PROCESSING_REQUEST) + shortener_val = handler_kwargs.get('shortener', shortener_val) + if num_files == 1: + ref_msg = await client.getMessage(chat_id=message.chat_id, message_id=reply_to.message_id) + if isinstance(ref_msg, types.Error): + await reply_user_err(message, MSG_ERROR_NO_FILE) + return + await process_single(client, message, ref_msg, 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 - ) - - -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 - + await process_batch(client, 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=Filters.private & Filters.incoming & Filters.media, group=4) +async def private_receive_handler(bot: pytdbot.Client, msg: types.Message, **kwargs): + async def _actual_private_receive_handler(client: pytdbot.Client, message: types.Message, **handler_kwargs): + shortener_val = await validate_request_common(client, message) + if shortener_val is None: + return + from_id = getattr(message, "from_id", None) + if not from_id: + return + + notification_msg = handler_kwargs.get('notification_msg') + + await log_newusr(client, from_id, "") + status_msg = await message.reply_text(MSG_PROCESSING_FILE) + 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=Filters.channel & Filters.incoming & (Filters.document | Filters.video | Filters.audio)) +async def channel_receive_handler(bot: pytdbot.Client, msg: types.Message): + async def _actual_channel_receive_handler(client: pytdbot.Client, message: types.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: + result = await client.leaveChat(chat_id=message.chat_id) + if isinstance(result, types.Error): + logger.warning(f"Error leaving banned channel {message.chat_id}: {result.message}") + return + if not await is_admin(client, message.chat_id): + return + try: shortener_val = await get_shortener_status(client, message) canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(message, fwd_media) @@ -357,94 +332,65 @@ async def _actual_channel_receive_handler(client: Client, message: Message, **ha if not stored_msg: stored_msg = await fwd_media(message) if not stored_msg: - logger.error( - 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. + source_info = message.chat.title if hasattr(message.chat, "title") else "Unknown Channel" 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 - ) - except Exception as e: - 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 + await notification_msg.editTextMessage( + chat_id=notification_msg.chat_id, + message_id=notification_msg.id, + text=MSG_NEW_FILE_REQUEST.format( + source_info=source_info, + id_=message.chat_id, + online_link=links['online_link'], + stream_link=links['stream_link'] + ) ) + except Exception as e: + logger.error(f"Error editing notification message: {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) else: - await send_channel_links( - links, - source_info, - message.chat.id, - target_msg=stored_msg, - reply_to_message_id=reply_to_message_id + await send_channel_links(links, source_info, message.chat_id, target_msg=stored_msg, reply_to_message_id=reply_to_message_id) + + try: + result = await client.editMessageReplyMarkup( + chat_id=message.chat_id, + message_id=message.id, + reply_markup=get_link_buttons(links), ) - - 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 + if isinstance(result, types.Error): + await send_link(message, links) + except Exception: + await send_link(message, links) + except Exception as e: + logger.error(f"Error in channel_receive_handler: {e}", exc_info=True) + + rl_user_id = None + sender_chat = getattr(msg, "sender_chat", None) + if sender_chat: + rl_user_id = getattr(sender_chat, "id", None) + from_id = getattr(msg, "from_id", None) + if rl_user_id is None and from_id: + rl_user_id = from_id + + if rl_user_id is None: + 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: pytdbot.Client, + msg: types.Message, + file_msg: types.Message, + status_msg: types.Message | None, + shortener_val: bool, + original_request_msg: types.Message | None = None, + notification_msg: types.Message | None = None ): try: canonical_record, stored_msg, reused_existing = await get_or_create_canonical_file(file_msg, fwd_media) @@ -467,202 +413,156 @@ async def process_single( return None links = await gen_links(stored_msg, shortener=shortener_val) canonical_reply_id = stored_msg.id - if notification_msg: - result = await safe_edit_message( - notification_msg, - MSG_LINKS.format( - file_name=links['media_name'], - file_size=links['media_size'], - download_link=links['online_link'], - stream_link=links['stream_link'] - ), - parse_mode=enums.ParseMode.MARKDOWN, - disable_web_page_preview=True, - reply_markup=get_link_buttons(links) - ) - if not result: - await send_link(msg, links) + + if notification_msg: + result = await notification_msg.editTextMessage( + chat_id=notification_msg.chat_id, + message_id=notification_msg.id, + text=MSG_LINKS.format( + file_name=links['media_name'], + file_size=links['media_size'], + download_link=links['online_link'], + stream_link=links['stream_link'] + ), + reply_markup=get_link_buttons(links) + ) + if isinstance(result, types.Error): + 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 - elif source_msg.chat.type == enums.ChatType.CHANNEL: - source_info = source_msg.chat.title or "Unknown Channel" - source_id = source_msg.chat.id + await send_link(msg, links) + + is_group = _is_group_chat(msg) + from_id = getattr(msg, "from_id", None) + if is_group and from_id and not original_request_msg: + chat_title = msg.chat.title if hasattr(msg.chat, "title") else "the chat" + try: + await send_dm_links(bot, from_id, links, chat_title) + except Exception: + await reply_user_err(msg, MSG_ERROR_DM_FAILED) + + source_msg = original_request_msg if original_request_msg else msg + source_info = "" + source_id = 0 + source_from_id = getattr(source_msg, "from_id", None) + if source_from_id: + source_info = f"user_{source_from_id}" + source_id = source_from_id + if source_msg.chat and hasattr(source_msg.chat, "title") and source_msg.chat.title: + source_info = source_msg.chat.title if source_info and source_id: try: await send_channel_links( - links, - source_info, - source_id, - target_msg=stored_msg, - reply_to_message_id=canonical_reply_id - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await send_channel_links( - links, - source_info, - source_id, + links, source_info, source_id, target_msg=stored_msg, reply_to_message_id=canonical_reply_id ) + except Exception as e: + logger.error(f"Error sending channel links: {e}", exc_info=True) + if status_msg: await safe_delete_message(status_msg) - return links - except Exception as e: - logger.error(f"Error processing single file for message {file_msg.id}: {e}", exc_info=True) - if status_msg: - await safe_edit_message(status_msg, MSG_ERROR_PROCESSING_MEDIA) - - await notify_own(bot, MSG_CRITICAL_ERROR.format( - error=str(e), - error_id=secrets.token_hex(6) - )) - return None - - -async def process_batch( - bot: Client, - msg: Message, - start_id: int, - count: int, - status_msg: Message, - shortener_val: bool, - notification_msg: Optional[Message] = None -): - processed = 0 - failed = 0 - links_list = [] - for batch_start in range(0, count, BATCH_SIZE): - batch_size = min(BATCH_SIZE, count - batch_start) - batch_ids = list(range(start_id + batch_start, start_id + batch_start + batch_size)) - try: - try: - await status_msg.edit_text( - MSG_PROCESSING_BATCH.format( - batch_number=(batch_start // BATCH_SIZE) + 1, - total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, - file_count=batch_size - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_BATCH.format( - batch_number=(batch_start // BATCH_SIZE) + 1, - total_batches=(count + BATCH_SIZE - 1) // BATCH_SIZE, - file_count=batch_size - ) - ) - except MessageNotModified: - pass - try: - try: - messages = await bot.get_messages(msg.chat.id, batch_ids) - except FloodWait as e: - await asyncio.sleep(e.value) - messages = await bot.get_messages(msg.chat.id, batch_ids) - if messages is None: - messages = [] - except Exception as e: - logger.error(f"Error getting messages in batch: {e}", exc_info=True) - messages = [] - for m in messages: - if m and m.media: - links = await process_single(bot, msg, m, None, shortener_val, original_request_msg=msg) - if links: - links_list.append(links['online_link']) - processed += 1 - else: - failed += 1 - else: - failed += 1 - if (processed + failed) % BATCH_UPDATE_INTERVAL == 0 or (processed + failed) == count: - try: - try: - await status_msg.edit_text( - MSG_PROCESSING_STATUS.format( - processed=processed, - total=count, - failed=failed - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_STATUS.format( - processed=processed, - total=count, - failed=failed - ) - ) - except MessageNotModified: - pass - for i in range(0, len(links_list), LINK_CHUNK_SIZE): - chunk = links_list[i:i+LINK_CHUNK_SIZE] - chunk_text = MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n{chr(10).join(chunk)}" - try: - await msg.reply_text( - chunk_text, - quote=True, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await msg.reply_text( - chunk_text, - quote=True, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - if msg.chat.type != enums.ChatType.PRIVATE and msg.from_user: - try: - try: - await bot.send_message( - chat_id=msg.from_user.id, - text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await bot.send_message( - chat_id=msg.from_user.id, - text=MSG_DM_BATCH_PREFIX.format(chat_title=msg.chat.title or "the chat") + "\n" + chunk_text, - disable_web_page_preview=True, - parse_mode=enums.ParseMode.HTML - ) - except Exception as e: - logger.error(f"Error sending DM in batch: {e}", exc_info=True) - await reply_user_err(msg, MSG_ERROR_DM_FAILED) - if i + LINK_CHUNK_SIZE < len(links_list): - await asyncio.sleep(MESSAGE_DELAY) - try: - await status_msg.edit_text( - MSG_PROCESSING_RESULT.format( - processed=processed, - total=count, - failed=failed - ) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - await status_msg.edit_text( - MSG_PROCESSING_RESULT.format( - processed=processed, - total=count, - failed=failed - ) - ) - if notification_msg: - await safe_delete_message(notification_msg) + return links + except Exception as e: + logger.error(f"Error processing single file: {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: pytdbot.Client, + msg: types.Message, + start_id: int, + count: int, + status_msg: types.Message, + shortener_val: bool, + notification_msg: types.Message | None = None +): + processed = 0 + failed = 0 + links_list = [] + for batch_start in range(0, count, BATCH_SIZE): + batch_size = min(BATCH_SIZE, count - batch_start) + batch_ids = list(range(start_id + batch_start, start_id + batch_start + batch_size)) + try: + await status_msg.editTextMessage( + chat_id=status_msg.chat_id, + message_id=status_msg.id, + 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 Exception: + logger.debug(f"Failed to edit batch processing status message for batch starting at {batch_start}") + + result = await bot.getMessages(chat_id=msg.chat_id, message_ids=batch_ids) + messages = [] + if not isinstance(result, types.Error) and result: + messages = result.messages if hasattr(result, "messages") else [] + + sem = asyncio.Semaphore(BATCH_CONCURRENCY) + + async def _process_one(m): + async with sem: + if m and _get_media_file(m): + return await process_single(bot, msg, m, None, shortener_val, original_request_msg=msg) + return None + + results = await asyncio.gather(*[_process_one(m) for m in messages]) + for links in results: + if links: + links_list.append(links['online_link']) + processed += 1 + else: + failed += 1 + + if (processed + failed) % BATCH_UPDATE_INTERVAL == 0 or (processed + failed) == count: + try: + await status_msg.editTextMessage( + chat_id=status_msg.chat_id, + message_id=status_msg.id, + text=MSG_PROCESSING_STATUS.format(processed=processed, total=count, failed=failed) + ) + except Exception: + logger.debug(f"Failed to edit processing status message ({processed}/{count})") + + dm_failed = False + 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) + except Exception: + logger.debug(f"Failed to reply with batch links chunk (index {i})") + from_id = getattr(msg, "from_id", None) + if _is_group_chat(msg) and from_id and not dm_failed: + try: + chat_title = msg.chat.title if hasattr(msg.chat, "title") else "the chat" + await bot.sendTextMessage( + chat_id=from_id, + text=MSG_DM_BATCH_PREFIX.format(chat_title=chat_title) + "\n" + chunk_text + ) + except Exception as e: + logger.error(f"Error sending DM in batch: {e}", exc_info=True) + dm_failed = 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.editTextMessage( + chat_id=status_msg.chat_id, + message_id=status_msg.id, + text=MSG_PROCESSING_RESULT.format(processed=processed, total=count, failed=failed) + ) + except Exception: + logger.debug(f"Failed to edit final batch result message ({processed}/{count})") + if notification_msg: + await safe_delete_message(notification_msg) diff --git a/Thunder/message_patch.py b/Thunder/message_patch.py new file mode 100644 index 00000000..1826e7c0 --- /dev/null +++ b/Thunder/message_patch.py @@ -0,0 +1,22 @@ +# Patch to fix is_outgoing=True messages being skipped by pytdbot +# This adds MessageText to allow_outgoing_message_types +import pytdbot.client as client_module +from pytdbot import types + +# Store original __init__ +_original_init = client_module.Client.__init__ + +def _patched_init(self, *args, **kwargs): + _original_init(self, *args, **kwargs) + # Add MessageText to allow_outgoing_message_types + # This allows incoming command messages to be processed even when is_outgoing=True + current = self.allow_outgoing_message_types + if types.MessageText not in current: + self.allow_outgoing_message_types = current + (types.MessageText,) + +# Apply the patch +client_module.Client.__init__ = _patched_init + +def apply_message_patch(): + """Entry point for importing this patch.""" + pass # Patch is applied at module import time \ No newline at end of file diff --git a/Thunder/server/__init__.py b/Thunder/server/__init__.py index ab450eec..0a185ecd 100644 --- a/Thunder/server/__init__.py +++ b/Thunder/server/__init__.py @@ -1,10 +1,69 @@ -# 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 logging + +from aiohttp import web + +from Thunder.utils.metrics import record_request +from Thunder.vars import Var + +from .stream_routes import routes + +_CORS_ORIGIN = getattr(Var, "FQDN", "*") +if not _CORS_ORIGIN or _CORS_ORIGIN == "0.0.0.0": + _CORS_ORIGIN = "*" + logging.warning("CORS origin is wildcard '*' — set FQDN env var for production security.") + + +@web.middleware +async def cors_middleware(request: web.Request, handler): + if request.method == "OPTIONS": + return web.Response( + status=204, + headers={ + "Access-Control-Allow-Origin": _CORS_ORIGIN, + "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", + "Access-Control-Allow-Headers": "Range, Content-Type", + "Access-Control-Max-Age": "86400", + }, + ) + response = await handler(request) + response.headers.setdefault("Access-Control-Allow-Origin", _CORS_ORIGIN) + response.headers.setdefault("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + response.headers.setdefault("Access-Control-Allow-Headers", "Range, Content-Type") + response.headers.setdefault("Access-Control-Expose-Headers", "Content-Length, Content-Range, Content-Disposition") + response.headers["X-Content-Type-Options"] = "nosniff" + return response + + +@web.middleware +async def error_middleware(request: web.Request, handler): + try: + response = await handler(request) + record_request(request.path, response.status) + return response + except web.HTTPException as ex: + record_request(request.path, ex.status) + ex.headers["Cache-Control"] = "no-store" + ex.headers.setdefault("Access-Control-Allow-Origin", _CORS_ORIGIN) + raise + except Exception: + import secrets + error_id = secrets.token_hex(6) + logging.exception(f"Unhandled server error {error_id}") + record_request(request.path, 500) + return web.json_response( + {"error": "Internal Server Error", "error_id": error_id}, + status=500, + headers={ + "Cache-Control": "no-store", + "Access-Control-Allow-Origin": _CORS_ORIGIN, + }, + ) + + +async def web_server(): + web_app = web.Application( + client_max_size=50 * 1024 * 1024, + middlewares=[error_middleware, cors_middleware], + ) + web_app.add_routes(routes) + return web_app diff --git a/Thunder/server/exceptions.py b/Thunder/server/exceptions.py index b25ef7ec..706e90e8 100644 --- a/Thunder/server/exceptions.py +++ b/Thunder/server/exceptions.py @@ -5,3 +5,6 @@ class InvalidHash(Exception): class FileNotFound(Exception): pass + +class RateLimited(Exception): + pass diff --git a/Thunder/server/stream_routes.py b/Thunder/server/stream_routes.py index 4a3c70e0..ed1efe67 100644 --- a/Thunder/server/stream_routes.py +++ b/Thunder/server/stream_routes.py @@ -1,84 +1,81 @@ -# Thunder/server/stream_routes.py - +from __future__ import annotations + import re import secrets import time from urllib.parse import quote, unquote from aiohttp import web +from pytdbot import types -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, RateLimited from Thunder.utils.bot_utils import quote_media_name -from Thunder.utils.canonical_files import ( - PUBLIC_HASH_LENGTH, - get_file_by_hash, - update_cached_file_id, -) -from Thunder.utils.custom_dl import ByteStreamer -from Thunder.utils.file_properties import get_media +from Thunder.utils.canonical_files import ( + PUBLIC_HASH_LENGTH, + get_file_by_hash, + update_cached_file_id, +) +from Thunder.utils.compat import _get_media_file +from Thunder.utils.custom_dl import ByteStreamer from Thunder.utils.logger import logger +from Thunder.utils.media_helpers import _get_extension_for_content_type +from Thunder.utils.metrics import get_metrics_text from Thunder.utils.render_template import render_media_page, render_page from Thunder.utils.time_format import get_readable_time from Thunder.vars import Var - -routes = web.RouteTableDef() - -SECURE_HASH_LENGTH = 6 -CHUNK_SIZE = 1024 * 1024 -MAX_CONCURRENT_PER_CLIENT = 8 -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_ID_FIRST = re.compile(r"^(\d+)(?:/.*)?$") + +routes = web.RouteTableDef() + +SECURE_HASH_LENGTH = 6 +CHUNK_SIZE = 1024 * 1024 +MAX_CONCURRENT_PER_CLIENT = 8 +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_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_DISPOSITIONS = {"inline", "attachment"} - -CORS_HEADERS = { - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, HEAD, OPTIONS", - "Access-Control-Allow-Headers": "Range, Content-Type, *", - "Access-Control-Expose-Headers": "Content-Length, Content-Range, Content-Disposition", -} - -streamers = {} - - -def get_streamer(client_id: int) -> ByteStreamer: - if client_id not in streamers: - streamers[client_id] = ByteStreamer(multi_clients[client_id]) - return streamers[client_id] - - + +streamers = {} +_cached_bot_username: str | None = None + + +def get_streamer(client_id: int) -> ByteStreamer: + if client_id not in streamers: + streamers[client_id] = ByteStreamer(multi_clients[client_id]) + return streamers[client_id] + + def parse_media_request(path: str, query: dict) -> tuple[int, str]: - clean_path = unquote(path).strip('/') - - match = PATTERN_HASH_FIRST.match(clean_path) - if match: - try: - message_id = int(match.group(2)) - secure_hash = match.group(1) - if (len(secure_hash) == SECURE_HASH_LENGTH and - VALID_HASH_REGEX.match(secure_hash)): - return message_id, secure_hash - except ValueError as e: - raise InvalidHash(f"Invalid message ID format in path: {e}") from e - - 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 - + clean_path = unquote(path).strip('/') + + match = PATTERN_HASH_FIRST.match(clean_path) + if match: + try: + message_id = int(match.group(2)) + secure_hash = match.group(1) + if (len(secure_hash) == SECURE_HASH_LENGTH and + VALID_HASH_REGEX.match(secure_hash)): + return message_id, secure_hash + except ValueError as e: + raise InvalidHash(f"Invalid message ID format in path: {e}") from e + + 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 + raise InvalidHash("Invalid URL structure or missing hash") @@ -87,59 +84,59 @@ def validate_public_hash(public_hash: str) -> str: if len(secure_hash) != PUBLIC_HASH_LENGTH or 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.")) - - available_clients = [ - (cid, load) for cid, load in work_loads.items() - if load < MAX_CONCURRENT_PER_CLIENT] - - if available_clients: - client_id = min(available_clients, key=lambda x: x[1])[0] - else: - client_id = min(work_loads, key=work_loads.get) - - return client_id, get_streamer(client_id) - - -def get_content_disposition(request: web.Request) -> str: - disposition = request.query.get("disposition", "attachment").strip().lower() - return disposition if disposition in VALID_DISPOSITIONS else "attachment" - - + + +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.") + + available_clients = [ + (cid, load) for cid, load in work_loads.items() + if load < MAX_CONCURRENT_PER_CLIENT] + + if available_clients: + client_id = min(available_clients, key=lambda x: x[1])[0] + return client_id, get_streamer(client_id) + + # Graceful degradation: pick least-loaded even at capacity + client_id = min(work_loads.items(), key=lambda x: x[1])[0] + return client_id, get_streamer(client_id) + + +def get_content_disposition(request: web.Request) -> str: + disposition = request.query.get("disposition", "attachment").strip().lower() + return disposition if disposition in VALID_DISPOSITIONS else "attachment" + + 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}") - - 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 - else: - if not end_str: - raise web.HTTPBadRequest(text=f"Invalid range header: {range_header}") - suffix_len = int(end_str) - if suffix_len <= 0: - raise web.HTTPRequestRangeNotSatisfiable( - headers={"Content-Range": f"bytes */{file_size}"}) - start = max(file_size - suffix_len, 0) - end = file_size - 1 - - if start < 0 or end >= file_size or start > end: - raise web.HTTPRequestRangeNotSatisfiable( - headers={"Content-Range": f"bytes */{file_size}"} - ) - + 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}") + + 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 + else: + if not end_str: + raise web.HTTPBadRequest(text=f"Invalid range header: {range_header}") + suffix_len = int(end_str) + if suffix_len <= 0: + raise web.HTTPRequestRangeNotSatisfiable( + headers={"Content-Range": f"bytes */{file_size}"}) + start = max(file_size - suffix_len, 0) + end = file_size - 1 + + if start < 0 or end >= file_size or start > end: + raise web.HTTPRequestRangeNotSatisfiable( + headers={"Content-Range": f"bytes */{file_size}"} + ) + return start, end @@ -150,17 +147,39 @@ def _resolve_unique_id(file_info: dict) -> str: return unique_id -def _resolve_filename(file_info: dict, mime_type: str) -> str: +def _resolve_filename(file_info: dict) -> 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) + content_type = file_info.get("media_type", "") + ext = _get_extension_for_content_type(content_type).lstrip(".") return f"file_{secrets.token_hex(4)}.{ext}" +async def _get_cached_bot_username() -> str: + global _cached_bot_username + if _cached_bot_username: + return _cached_bot_username + me = await StreamBot.getMe() + if isinstance(me, types.Error): + return "unknown" + if hasattr(me, "usernames") and me.usernames: + _cached_bot_username = me.usernames.editable_username or "unknown" + else: + _cached_bot_username = getattr(me, "username", "unknown") + return _cached_bot_username + + +def _require_admin_token(request: web.Request) -> None: + admin_token = getattr(Var, "ADMIN_TOKEN", "") + if not admin_token: + return + auth_header = request.headers.get("Authorization", "") + if auth_header != f"Bearer {admin_token}": + raise web.HTTPUnauthorized(text="Missing or invalid authorization token.") + + async def _serve_media_response( request: web.Request, *, @@ -169,7 +188,8 @@ async def _serve_media_response( client_id: int, media_ref: int | str, fallback_message_id: int | None = None, - on_fallback_message=None + on_fallback_message=None, + extra_headers: dict | None = None, ): file_size = int(file_info.get('file_size', 0) or 0) if file_size == 0: @@ -183,7 +203,7 @@ async def _serve_media_response( range_header = "" mime_type = file_info.get('mime_type') or 'application/octet-stream' - filename = _resolve_filename(file_info, mime_type) + filename = _resolve_filename(file_info) disposition = get_content_disposition(request) headers = { @@ -194,13 +214,12 @@ async def _serve_media_response( "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" } + if extra_headers: + headers.update(extra_headers) + if range_header: headers["Content-Range"] = f"bytes {start}-{end}/{file_size}" @@ -214,8 +233,6 @@ async def _serve_media_response( async def stream_generator(): try: bytes_sent = 0 - bytes_to_skip = start % CHUNK_SIZE - async for chunk in streamer.stream_file( media_ref, offset=start, @@ -223,21 +240,12 @@ async def stream_generator(): fallback_message_id=fallback_message_id, on_fallback_message=on_fallback_message ): - if bytes_to_skip > 0: - if len(chunk) <= bytes_to_skip: - bytes_to_skip -= len(chunk) - continue - chunk = chunk[bytes_to_skip:] - bytes_to_skip = 0 - remaining = content_length - bytes_sent if len(chunk) > remaining: chunk = chunk[:remaining] - if chunk: yield chunk bytes_sent += len(chunk) - if bytes_sent >= content_length: break finally: @@ -248,62 +256,52 @@ async def stream_generator(): body=stream_generator(), headers=headers ) - - -@routes.get("/", allow_head=True) -async def root_redirect(request): - raise web.HTTPFound("https://github.com/fyaz05/FileToLink") - - -@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())} - - return web.json_response( - { - "server": { - "status": "operational", - "version": __version__, - "uptime": get_readable_time(uptime) - }, - "telegram_bot": { - "username": f"@{StreamBot.username}", - "active_clients": len(multi_clients) - }, - "resources": { - "total_workload": total_load, - "workload_distribution": workload_distribution - } - }, - 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" - }) - - -@routes.get(r"/watch/f/{secure_hash}/{name:.+}", allow_head=True) -async def canonical_media_preview(request: web.Request): - try: - secure_hash = validate_public_hash(request.match_info["secure_hash"]) - file_record = await get_file_by_hash(secure_hash, raise_on_error=False) - if not file_record: + + +@routes.get("/", allow_head=True) +async def root_redirect(request): + raise web.HTTPFound("https://github.com/fyaz05/FileToLink") + + +@routes.get("/health", allow_head=True) +async def health_endpoint(request): + return web.json_response({"status": "ok"}) + + +@routes.get("/metrics", allow_head=True) +async def metrics_endpoint(request): + _require_admin_token(request) + return web.Response( + text=get_metrics_text(), + content_type="text/plain; version=0.0.4", + ) + + +@routes.get("/status", allow_head=True) +async def status_endpoint(request): + _require_admin_token(request) + uptime = time.time() - StartTime + total_load = sum(work_loads.values()) + workload_distribution = {str(k): v for k, v in sorted(work_loads.items())} + bot_username = await _get_cached_bot_username() + + return web.json_response( + { + "status": "operational", + "version": __version__, + "uptime": get_readable_time(uptime), + "active_clients": len(multi_clients), + "total_workload": total_load, + } + ) + + +@routes.get(r"/watch/f/{secure_hash}/{name:.+}", allow_head=True) +async def canonical_media_preview(request: web.Request): + try: + secure_hash = validate_public_hash(request.match_info["secure_hash"]) + file_record = await get_file_by_hash(secure_hash, raise_on_error=False) + if not file_record: raise FileNotFound("Canonical file not found") file_name = file_record.get("file_name") or f"file_{secure_hash}" @@ -313,157 +311,130 @@ async def canonical_media_preview(request: web.Request): response = web.Response( text=rendered_page, content_type='text/html', - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Range, Content-Type, *", - "X-Content-Type-Options": "nosniff", - } + headers={"X-Content-Type-Options": "nosniff"} ) 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 + raise web.HTTPInternalServerError(text=f"Server error: {error_id}") from e @routes.get(r"/watch/{path:.+}", allow_head=True) async def media_preview(request: web.Request): - try: - path = request.match_info["path"] - message_id, secure_hash = parse_media_request(path, request.query) - - rendered_page = await render_page( - message_id, secure_hash, requested_action='stream') - - response = web.Response( - text=rendered_page, - content_type='text/html', - headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Headers": "Range, Content-Type, *", - "X-Content-Type-Options": "nosniff", - } - ) - 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: - secure_hash = validate_public_hash(request.match_info["secure_hash"]) - file_record = await get_file_by_hash(secure_hash, raise_on_error=False) - if not file_record: - raise FileNotFound("Canonical file not found") - - client_id, streamer = select_optimal_client() - work_loads[client_id] += 1 - - try: - _resolve_unique_id(file_record) - media_ref = int(file_record["canonical_message_id"]) - fallback_message_id = int(file_record["canonical_message_id"]) - - async def persist_refreshed_file_id(message): - if client_id != 0: - return - media = get_media(message) - new_file_id = getattr(media, "file_id", None) if media else None - if new_file_id and new_file_id != file_record.get("file_id"): - try: - await update_cached_file_id(file_record, new_file_id) - except Exception as e: - logger.warning( - f"Failed to refresh cached file_id for canonical file {secure_hash}: {e}", - exc_info=True - ) - - return await _serve_media_response( - request, - file_info=file_record, - streamer=streamer, - client_id=client_id, - media_ref=media_ref, - fallback_message_id=fallback_message_id, - on_fallback_message=persist_refreshed_file_id + try: + path = request.match_info["path"] + message_id, secure_hash = parse_media_request(path, request.query) + rendered_page = await render_page(message_id, secure_hash, requested_action='stream') + + response = web.Response( + text=rendered_page, + content_type='text/html', + headers={"X-Content-Type-Options": "nosniff"} + ) + response.enable_compression() + return response + except (InvalidHash, FileNotFound) as e: + raise web.HTTPNotFound(text="Resource not found") from e + except RateLimited as e: + raise web.HTTPTooManyRequests(text=str(e)) 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: {error_id}") from e + + +@routes.get(r"/f/{secure_hash}/{name:.+}", allow_head=True) +async def canonical_media_delivery(request: web.Request): + try: + secure_hash = validate_public_hash(request.match_info["secure_hash"]) + file_record = await get_file_by_hash(secure_hash, raise_on_error=False) + if not file_record: + raise FileNotFound("Canonical file not found") + + client_id, streamer = select_optimal_client() + overloaded = work_loads[client_id] >= MAX_CONCURRENT_PER_CLIENT + work_loads[client_id] += 1 + extra_headers = {"Retry-After": "5"} if overloaded else None + + 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 + new_file_id = getattr(message, "remote_file_id", 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 {secure_hash}: {e}", exc_info=True) + + try: + return await _serve_media_response( + request, + file_info=file_record, + streamer=streamer, + client_id=client_id, + media_ref=media_ref, + fallback_message_id=fallback_message_id, + on_fallback_message=persist_refreshed_file_id, + extra_headers=extra_headers, ) except (FileNotFound, InvalidHash): work_loads[client_id] -= 1 raise - except Exception as e: + except Exception: 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 + raise 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 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 + logger.error(f"Canonical stream error {error_id}: {e}", exc_info=True) + raise web.HTTPInternalServerError(text=f"Server error: {error_id}") from e @routes.get(r"/{path:.+}", allow_head=True) async def media_delivery(request: web.Request): - try: - path = request.match_info["path"] - message_id, secure_hash = parse_media_request(path, request.query) - - client_id, streamer = select_optimal_client() - + try: + path = request.match_info["path"] + message_id, secure_hash = parse_media_request(path, request.query) + + client_id, streamer = select_optimal_client() + overloaded = work_loads[client_id] >= MAX_CONCURRENT_PER_CLIENT work_loads[client_id] += 1 + extra_headers = {"Retry-After": "5"} if overloaded else None try: file_info = await streamer.get_file_info(message_id) unique_id = _resolve_unique_id(file_info) if unique_id[:SECURE_HASH_LENGTH] != secure_hash: - raise InvalidHash( - "Provided hash does not match file's unique ID.") + raise InvalidHash("Provided hash does not match file's unique ID.") + return await _serve_media_response( request, file_info=file_info, streamer=streamer, client_id=client_id, - media_ref=message_id + media_ref=message_id, + extra_headers=extra_headers, ) - except (FileNotFound, InvalidHash): - work_loads[client_id] -= 1 - 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 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 + work_loads[client_id] -= 1 + raise + except Exception: + work_loads[client_id] -= 1 + raise + except (InvalidHash, FileNotFound) as e: + raise web.HTTPNotFound(text="Resource not found") from e + 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"Server error: {error_id}") from e + + diff --git a/Thunder/utils/bot_utils.py b/Thunder/utils/bot_utils.py index 10bf3c01..af54aec4 100644 --- a/Thunder/utils/bot_utils.py +++ b/Thunder/utils/bot_utils.py @@ -1,24 +1,42 @@ -# Thunder/utils/bot_utils.py - import asyncio -from typing import Any, Dict, Optional +import time +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) + +import pytdbot +from pytdbot import types + +from Thunder.utils.compat import ( + ChatMemberStatus, + _get_file_name, + _get_file_size, + get_member_status, +) +from Thunder.utils.database import db +from Thunder.utils.file_properties import 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_USER_INFO, MSG_NEW_USER from Thunder.utils.shortener import shorten from Thunder.vars import Var +_bot_me_cache: dict[int, tuple[int, float]] = {} +_bot_me_cache_ttl: int = 3600 + + +async def _get_bot_me(cli: pytdbot.Client) -> int | None: + bot_id = id(cli) + now = time.time() + if bot_id in _bot_me_cache: + cached_id, ts = _bot_me_cache[bot_id] + if now - ts < _bot_me_cache_ttl: + return cached_id + me = await cli.getMe() + if isinstance(me, types.Error): + return None + _bot_me_cache[bot_id] = (me.id, now) + return me.id + def quote_media_name(file_name: str) -> str: return quote(str(file_name).replace("/", "_"), safe="") @@ -31,7 +49,7 @@ async def _build_links( media_name: str, media_size: str, shortener: bool = True -) -> Dict[str, str]: +) -> dict[str, str]: base_url = Var.URL.rstrip("/") slink = f"{base_url}{stream_path}" olink = f"{base_url}{download_path}" @@ -59,7 +77,7 @@ async def gen_canonical_links( file_size: int, public_hash: str, shortener: bool = True -) -> Dict[str, str]: +) -> dict[str, str]: media_name = str(file_name) media_size = humanbytes(file_size) encoded_name = quote_media_name(media_name) @@ -72,69 +90,68 @@ async def gen_canonical_links( ) +async def notify_ch(cli: pytdbot.Client, txt: str): + if not (hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0): + return + try: + await cli.sendTextMessage(chat_id=Var.BIN_CHANNEL, text=txt) + except Exception as e: + logger.error(f"Error notifying channel: {e}") + + +async def notify_own(cli: pytdbot.Client, txt: str): + o_ids = Var.OWNER_ID if isinstance(Var.OWNER_ID, (list, tuple, set)) else [Var.OWNER_ID] + + async def send_with_retry(chat_id: int): + try: + await cli.sendTextMessage(chat_id=chat_id, text=txt) + except Exception as e: + logger.error(f"Error notifying owner {chat_id}: {e}") + + tasks = [send_with_retry(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_retry(Var.BIN_CHANNEL)) + await asyncio.gather(*tasks, return_exceptions=True) + + +async def reply_user_err(msg: types.Message, err_txt: str): + try: + button = types.InlineKeyboardButton( + text=MSG_BUTTON_GET_HELP, + type=types.InlineKeyboardButtonTypeCallback(data=b"help_command") + ) + await msg.reply_text( + err_txt, + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[button]]) + ) + except Exception as e: + logger.error(f"Error replying to user: {e}") + -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 log_newusr(cli: pytdbot.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.sendTextMessage( + chat_id=Var.BIN_CHANNEL, + text=MSG_NEW_USER.format(first_name=fname, user_id=uid) + ) + except Exception as e: + logger.error(f"Error logging new user: {e}") + except Exception as e: + logger.error(f"Database error in log_newusr for user {uid}: {e}") + + +async def gen_links(fwd_msg: types.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_size_hr = humanbytes(get_fsize(fwd_msg)) + m_name = _get_file_name(fwd_msg) or "Untitled" + if isinstance(m_name, bytes): + m_name = m_name.decode('utf-8', errors='replace') + m_name = str(m_name) + m_size_hr = humanbytes(_get_file_size(fwd_msg)) enc_fname = quote_media_name(m_name) f_hash = get_hash(fwd_msg) return await _build_links( @@ -144,55 +161,92 @@ async def gen_links(fwd_msg: Message, shortener: bool = True) -> Dict[str, str]: media_size=m_size_hr, shortener=shortener ) - - -async def gen_dc_txt(usr: User) -> str: - dc_id_val = usr.dc_id if usr.dc_id is not None else MSG_DC_UNKNOWN - return MSG_DC_USER_INFO.format(user_name=usr.first_name or 'User', user_id=usr.id, dc_id=dc_id_val) - - -async def get_user(cli: Client, qry: Any) -> Optional[User]: - if isinstance(qry, str): - if qry.startswith('@'): - try: - return await cli.get_users(qry) - except FloodWait as e: - await asyncio.sleep(e.value) - return await cli.get_users(qry) - elif qry.isdigit(): - try: - return await cli.get_users(int(qry)) - except FloodWait as e: - await asyncio.sleep(e.value) - return await cli.get_users(int(qry)) - elif isinstance(qry, int): - try: - return await cli.get_users(qry) - except FloodWait as e: - await asyncio.sleep(e.value) - return await cli.get_users(qry) - return None - - -async def is_admin(cli: Client, chat_id_val: int) -> bool: - try: - member = await cli.get_chat_member(chat_id_val, cli.me.id) - except FloodWait as e: - await asyncio.sleep(e.value) - try: - member = await cli.get_chat_member(chat_id_val, cli.me.id) - except Exception: - return False - except Exception: - return False - if member is None: - return False - return member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER] - - -async def reply(msg: Message, **kwargs): - try: - return await msg.reply_text(**kwargs, quote=True, disable_web_page_preview=True) - except FloodWait as e: - await asyncio.sleep(e.value) - return await msg.reply_text(**kwargs, quote=True, disable_web_page_preview=True) + + +async def gen_dc_txt(usr: types.User) -> str: + dc_id_val = "Unknown" + if hasattr(usr, "profile_photo") and usr.profile_photo: + dc_id_val = getattr(usr.profile_photo, "dc_id", "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: pytdbot.Client, qry: Any) -> types.User | None: + try: + if isinstance(qry, str): + if qry.startswith('@'): + return None + elif qry.isdigit(): + result = await cli.getUser(user_id=int(qry)) + else: + return None + elif isinstance(qry, int): + result = await cli.getUser(user_id=qry) + else: + return None + + if isinstance(result, types.Error): + logger.warning(f"Error getting user {qry}: {result.message}") + return None + return result + except Exception as e: + logger.error(f"Error in get_user: {e}") + return None + + +async def is_admin(cli: pytdbot.Client, chat_id_val: int) -> bool: + try: + bot_id = await _get_bot_me(cli) + if bot_id is None: + return False + member = await cli.getChatMember( + chat_id=chat_id_val, + member_id=types.MessageSenderUser(user_id=bot_id) + ) + if isinstance(member, types.Error): + return False + status = get_member_status(member) + return status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER] + except Exception as e: + logger.error(f"Error checking admin: {e}") + return False + + +async def reply(msg: types.Message, **kwargs): + try: + text = kwargs.get("text", "") + parse_mode = kwargs.get("parse_mode") + reply_markup = kwargs.get("reply_markup") + + if reply_markup and isinstance(reply_markup, types.ReplyMarkupInlineKeyboard): + pass + elif reply_markup and isinstance(reply_markup, dict): + rows = reply_markup.get("inline_keyboard", []) + buttons = [] + for row in rows: + button_row = [] + for btn in row: + if isinstance(btn, types.InlineKeyboardButton): + button_row.append(btn) + elif isinstance(btn, dict): + text_btn = btn.get("text", "") + if "url" in btn: + button_row.append(types.InlineKeyboardButton( + text=text_btn, + type=types.InlineKeyboardButtonTypeUrl(url=btn["url"]) + )) + elif "callback_data" in btn: + button_row.append(types.InlineKeyboardButton( + text=text_btn, + type=types.InlineKeyboardButtonTypeCallback(data=btn["callback_data"].encode("utf-8")) + )) + if button_row: + buttons.append(button_row) + reply_markup = types.ReplyMarkupInlineKeyboard(rows=buttons) if buttons else None + + result = await msg.reply_text(text, reply_markup=reply_markup) + if isinstance(result, types.Error): + logger.error(f"Error replying: {result.message}") + return result + except Exception as e: + logger.error(f"Error in reply: {e}", exc_info=True) + return None diff --git a/Thunder/utils/broadcast.py b/Thunder/utils/broadcast.py index 044dcd42..f700dd07 100644 --- a/Thunder/utils/broadcast.py +++ b/Thunder/utils/broadcast.py @@ -1,189 +1,136 @@ -# 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 + +import pytdbot +from pytdbot import types + +from Thunder.utils.database import db +from Thunder.utils.logger import logger +from Thunder.utils.messages import ( + MSG_BROADCAST_COMPLETE, + MSG_BROADCAST_START, + MSG_BUTTON_CANCEL_BROADCAST, + MSG_INVALID_BROADCAST_CMD, +) +from Thunder.utils.time_format import get_readable_time + +broadcast_ids = {} + + +async def broadcast_message(client: pytdbot.Client, message: types.Message, mode: str = "all"): + reply_to = getattr(message, "reply_to", None) + replied_msg = None + if reply_to and hasattr(reply_to, "message_id"): + replied_msg = await client.getMessage( + chat_id=message.chat_id, message_id=reply_to.message_id + ) + if isinstance(replied_msg, types.Error): + replied_msg = None + + if not replied_msg: + try: + 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 + + cancel_button = types.InlineKeyboardButton( + text=MSG_BUTTON_CANCEL_BROADCAST, + type=types.InlineKeyboardButtonTypeCallback(data=f"cancel_{broadcast_id}".encode()) + ) + try: + status_msg = await message.reply_text( + MSG_BROADCAST_START, + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[cancel_button]]) + ) + except Exception as e: + logger.error(f"Error starting broadcast: {e}", exc_info=True) + del broadcast_ids[broadcast_id] + return + + if isinstance(status_msg, types.Error): + 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) + del broadcast_ids[broadcast_id] + return + + if stats["total"] == 0: + 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: + continue + + try: + result = await client.sendCopy( + chat_id=user_id, + from_chat_id=message.chat_id, + message_id=replied_msg.id + ) + if isinstance(result, types.Error): + if result.code in [400, 403]: + 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 + else: + stats["failed"] += 1 + else: + stats["success"] += 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 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) + except Exception as e: + logger.error(f"Failed to send broadcast completion message: {e}", exc_info=True) + + if broadcast_id in broadcast_ids: + del broadcast_ids[broadcast_id] + + asyncio.create_task(do_broadcast()) diff --git a/Thunder/utils/canonical_files.py b/Thunder/utils/canonical_files.py index 367171d1..4e542307 100644 --- a/Thunder/utils/canonical_files.py +++ b/Thunder/utils/canonical_files.py @@ -1,463 +1,464 @@ -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 pytdbot import types + +from Thunder.bot import StreamBot +from Thunder.utils.compat import _get_file_name, _get_file_unique_id, _get_media_file, _get_mime_type +from Thunder.utils.media_helpers import _infer_mime_from_content_type +from Thunder.utils.database import db +from Thunder.utils.file_record import FileRecord +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, FileRecord]]" = OrderedDict() +_cache_by_hash: "OrderedDict[str, tuple[float, FileRecord]]" = OrderedDict() +_cache_by_message_id: "OrderedDict[int, tuple[float, FileRecord]]" = 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[FileRecord, bool]] = {} +_pending_touches_lock = asyncio.Lock() +_flush_task: asyncio.Task | None = 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(message: types.Message) -> str: + mime = _get_mime_type(message) + if mime: + return mime + content = getattr(message, "content", None) + if content: + type_name = type(content).__name__.lower() + # Strip "message" prefix to get content type (e.g., "messagephoto" -> "photo") + content_type = type_name.replace("message", "", 1) if type_name.startswith("message") else type_name + inferred = _infer_mime_from_content_type(content_type) + if inferred: + return inferred + return "application/octet-stream" + + +def build_file_record( + stored_message: types.Message, + *, + source_chat_id: int | None = None, + source_message_id: int | None = None +) -> FileRecord | None: + media_file = _get_media_file(stored_message) + file_unique_id = _get_file_unique_id(stored_message) + if not media_file 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(stored_message, "remote_file_id", None), + "file_name": _get_file_name(stored_message), + "mime_type": _infer_mime_type(stored_message), + "file_size": media_file.size, + "media_type": type(stored_message.content).__name__.lower().replace("message", ""), + "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, FileRecord]]") -> 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, FileRecord]]", + key: Any +) -> FileRecord | 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: FileRecord) -> FileRecord: + 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: FileRecord) -> 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) -> FileRecord | 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 +) -> FileRecord | 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 get_file_by_message_id(canonical_message_id: int) -> FileRecord | None: + cached = _cache_get(_cache_by_message_id, canonical_message_id) + if cached: + return cached + record = await db.get_file_by_message_id(canonical_message_id) + return _remember(record) if record else None + + +async def touch_file_record(record: FileRecord, *, reused: bool = False) -> None: + 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) + await db.touch_file_record(record["public_hash"], reused=reused, raise_on_error=True) + + +async def _flush_pending_touches() -> None: + global _flush_task + batch = {} + try: + await asyncio.sleep(_FLUSH_DELAY_SECONDS) + async with _pending_touches_lock: + batch = dict(_pending_touches) + _pending_touches.clear() + for public_hash in list(batch): + try: + record, reused = batch[public_hash] + await db.touch_file_record(public_hash, reused=reused) + del batch[public_hash] + except Exception as e: + logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True) + except asyncio.CancelledError: + if batch: + async with _pending_touches_lock: + for public_hash, (record, reused) in batch.items(): + if public_hash not in _pending_touches: + _pending_touches[public_hash] = (record, reused) + else: + _, existing_reused = _pending_touches[public_hash] + _pending_touches[public_hash] = (record, existing_reused or reused) + raise + finally: + _flush_task = None + + +def schedule_touch_file_record(record: FileRecord, *, reused: bool = False) -> None: + global _flush_task + 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"] + + async def _do_schedule(): + async with _pending_touches_lock: + 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) + + def _log_schedule_error(t: asyncio.Task) -> None: + if not t.cancelled() and t.exception(): + logger.error(f"Error in background touch schedule: {t.exception()}") + + task = asyncio.create_task(_do_schedule()) + task.add_done_callback(_log_schedule_error) + + if _flush_task is None or _flush_task.done(): _flush_task = asyncio.create_task(_flush_pending_touches()) - - -async def drain_background_touch_tasks() -> None: - if _flush_task and not _flush_task.done(): - _flush_task.cancel() - try: - await _flush_task - except asyncio.CancelledError: - pass - - items = list(_pending_touches.items()) - _pending_touches.clear() - - for public_hash, (record, reused) in items: - try: - await db.touch_file_record(public_hash, reused=reused) - except Exception as e: - logger.error(f"Failed to flush touch for {public_hash}: {e}", exc_info=True) - - -async def update_cached_file_id(record: Dict[str, Any], file_id: str) -> None: - if not record.get("public_hash") or not file_id: - return - record["file_id"] = file_id - _remember(record) - await db.update_file_id(record["public_hash"], file_id, raise_on_error=True) - - -async def _fetch_canonical_message(record: Dict[str, Any]) -> Optional[Message]: - canonical_message_id = record.get("canonical_message_id") - if canonical_message_id is None: - return None - - try: - try: - message = await StreamBot.get_messages( - chat_id=int(Var.BIN_CHANNEL), - message_ids=int(canonical_message_id) - ) - except FloodWait as e: - await asyncio.sleep(e.value) - message = await StreamBot.get_messages( - chat_id=int(Var.BIN_CHANNEL), - message_ids=int(canonical_message_id) - ) - except Exception as e: - logger.warning( - f"Error fetching canonical message {canonical_message_id}: {e}", - exc_info=True - ) - raise - - if not message or not message.media: - return None - return message - - -async def _is_canonical_record_valid(record: Dict[str, Any], file_unique_id: str) -> bool: - message = await _fetch_canonical_message(record) - return bool(message and get_uniqid(message) == file_unique_id) - - -async def _get_reusable_canonical_record( - file_unique_id: str -) -> Tuple[Optional[Dict[str, Any]], Optional[Dict[str, Any]]]: - existing = await get_file_by_unique_id(file_unique_id) - if not existing: - return None, None - - try: - is_valid = await _is_canonical_record_valid(existing, file_unique_id) - except Exception as e: - logger.warning( - f"Falling back to BIN re-copy for {file_unique_id} after canonical validation failed: {e}", - exc_info=True - ) - is_valid = False - - if is_valid: - return existing, None - - _forget(existing) - return None, existing - - -async def _wait_for_other_worker_canonical_record(file_unique_id: str) -> Optional[Dict[str, Any]]: - loop = asyncio.get_running_loop() - deadline = loop.time() + _INGEST_CLAIM_WAIT_SECONDS - - while loop.time() < deadline: - reusable_record, _ = await _get_reusable_canonical_record(file_unique_id) - if reusable_record: - return reusable_record - - if not await db.is_file_ingest_claim_active(file_unique_id): - break - - await asyncio.sleep(_INGEST_CLAIM_POLL_SECONDS) - - return None - - -def _merge_replacement_record( - existing: Dict[str, Any], - refreshed: Dict[str, Any] -) -> Dict[str, Any]: - refreshed["created_at"] = existing.get("created_at", refreshed["created_at"]) - refreshed["seen_count"] = int(existing.get("seen_count", 0)) + 1 - refreshed["reuse_count"] = int(existing.get("reuse_count", 0)) - refreshed["first_source_chat_id"] = existing.get( - "first_source_chat_id", - refreshed.get("first_source_chat_id") - ) - refreshed["first_source_message_id"] = existing.get( - "first_source_message_id", - refreshed.get("first_source_message_id") - ) - return refreshed - - -@asynccontextmanager -async def file_ingest_lock(file_unique_id: str): - async with _upload_locks_guard: - lock = _upload_locks.get(file_unique_id) - if lock is None: - lock = asyncio.Lock() - _upload_locks[file_unique_id] = lock - _upload_lock_counts[file_unique_id] = 0 - _upload_lock_counts[file_unique_id] += 1 - - acquired = False - try: - await lock.acquire() - acquired = True - yield - finally: - if acquired: - lock.release() - async with _upload_locks_guard: - remaining = _upload_lock_counts.get(file_unique_id, 1) - 1 - if remaining <= 0: - _upload_lock_counts.pop(file_unique_id, None) - _upload_locks.pop(file_unique_id, None) - else: - _upload_lock_counts[file_unique_id] = remaining - - -async def get_or_create_canonical_file( - source_message: Message, - copy_media: Callable[[Message], Awaitable[Optional[Message]]] -) -> Tuple[Optional[Dict[str, Any]], Optional[Message], bool]: - file_unique_id = get_uniqid(source_message) - if not file_unique_id: - return None, None, False - - async with file_ingest_lock(file_unique_id): - for _attempt in range(_MAX_INGEST_RETRIES): - if _attempt > 0: - await asyncio.sleep(min(0.5 * (2 ** (_attempt - 1)), 5.0)) - - reusable_record, stale_record = await _get_reusable_canonical_record(file_unique_id) - if reusable_record: - schedule_touch_file_record(reusable_record, reused=True) - return reusable_record, None, True - - claim_acquired = await db.acquire_file_ingest_claim( - file_unique_id, - ttl_seconds=_INGEST_CLAIM_TTL_SECONDS - ) - if not claim_acquired: - reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id) - if reusable_record: - schedule_touch_file_record(reusable_record, reused=True) - return reusable_record, None, True - continue - - try: - reusable_record, stale_record = await _get_reusable_canonical_record(file_unique_id) - if reusable_record: - schedule_touch_file_record(reusable_record, reused=True) - return reusable_record, None, True - - stored_message = await copy_media(source_message) - if not stored_message: - return None, None, False - - record = build_file_record( - stored_message, - source_chat_id=source_message.chat.id if source_message.chat else None, - source_message_id=source_message.id - ) - if not record: - return None, stored_message, False - - try: - if stale_record: - record = _merge_replacement_record(stale_record, record) - await db.replace_file_record(record) - else: - await db.create_file_record(record) - _remember(record) - return record, stored_message, False - except DuplicateKeyError: - reusable_record = await _wait_for_other_worker_canonical_record(file_unique_id) - if reusable_record: - schedule_touch_file_record(reusable_record, reused=True) - return reusable_record, stored_message, True - if stored_message: - try: - await stored_message.delete() - except Exception as e: - logger.warning(f"Failed to delete stored message {stored_message.id} in BIN_CHANNEL: {e}", exc_info=True) - raise - except FloodWait: - raise - except Exception as e: - logger.error(f"Error creating canonical file for {file_unique_id}: {e}", exc_info=True) - return None, stored_message, False - finally: - await db.release_file_ingest_claim(file_unique_id) - - logger.error(f"Max ingest retries ({_MAX_INGEST_RETRIES}) exhausted for {file_unique_id}") - return None, None, False + + +async def drain_background_touch_tasks() -> None: + if _flush_task and not _flush_task.done(): + _flush_task.cancel() + try: + await _flush_task + except asyncio.CancelledError: + pass + + async with _pending_touches_lock: + 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: FileRecord, 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: FileRecord) -> types.Message | None: + canonical_message_id = record.get("canonical_message_id") + if canonical_message_id is None: + return None + + try: + result = await StreamBot.getMessage( + chat_id=int(Var.BIN_CHANNEL), + message_id=int(canonical_message_id) + ) + if isinstance(result, types.Error): + logger.warning(f"Error fetching canonical message {canonical_message_id}: {result.message}") + return None + if not result or not hasattr(result, "content") or result.content is None: + return None + return result + except Exception as e: + logger.warning(f"Error fetching canonical message {canonical_message_id}: {e}", exc_info=True) + raise + + +async def _is_canonical_record_valid(record: FileRecord, file_unique_id: str) -> bool: + message = await _fetch_canonical_message(record) + return bool(message and _get_file_unique_id(message) == file_unique_id) + + +async def _get_reusable_canonical_record( + file_unique_id: str +) -> tuple[FileRecord | None, FileRecord | 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) + except Exception as e: + logger.warning(f"Falling back to BIN re-copy 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) -> FileRecord | 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) + 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: FileRecord, + refreshed: FileRecord +) -> FileRecord: + 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: types.Message, + copy_media: Callable[[types.Message], Awaitable[types.Message | None]] +) -> tuple[FileRecord | None, types.Message | None, bool]: + file_unique_id = _get_file_unique_id(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=getattr(source_message, "chat_id", 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 Exception as e: + if "duplicate key" in str(e).lower() or "E11000" in str(e): + 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 del_e: + logger.warning(f"Failed to delete stored message: {del_e}", exc_info=True) + raise + else: + logger.error(f"Error creating canonical file for {file_unique_id}: {e}", exc_info=True) + return None, stored_message, False + finally: + await db.release_file_ingest_claim(file_unique_id) + + logger.error(f"Max ingest retries ({_MAX_INGEST_RETRIES}) exhausted for {file_unique_id}") + return None, None, False diff --git a/Thunder/utils/commands.py b/Thunder/utils/commands.py index d4831496..74ebd834 100644 --- a/Thunder/utils/commands.py +++ b/Thunder/utils/commands.py @@ -1,38 +1,46 @@ -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 pytdbot import types + +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 [types.BotCommand(command=name, description=desc) for name, desc in command_descriptions.items()] + + +async def set_commands(): + if Var.SET_COMMANDS: + try: + commands = get_commands() + if commands: + result = await StreamBot.setCommands( + scope=types.BotCommandScopeDefault(), + language_code="", + commands=commands + ) + if isinstance(result, types.Error): + logger.error(f"Failed to set bot commands: {result.message}") + except Exception as e: + logger.error(f"Failed to set bot commands: {e}", exc_info=True) diff --git a/Thunder/utils/compat.py b/Thunder/utils/compat.py new file mode 100644 index 00000000..bb275c4d --- /dev/null +++ b/Thunder/utils/compat.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from .filters import ( + Filters, + _FilterAnd, + _FilterAnimation, + _FilterAudio, + _FilterChannel, + _FilterChat, + _FilterCommand, + _FilterDocument, + _FilterIncoming, + _FilterMedia, + _FilterNot, + _FilterOr, + _FilterPhoto, + _FilterPrivate, + _FilterRegex, + _FilterUser, + _FilterVideo, + _FilterVideoNote, + _FilterVoice, +) +from .media_helpers import ( + _get_file_id, + _get_file_int_id, + _get_file_name, + _get_file_size, + _get_file_unique_id, + _get_media_content, + _get_media_file, + _get_mime_type, +) +from .telegram_helpers import ( + ChatMemberStatus, + get_member_status, + is_error, +) + +__all__ = [ + # media_helpers + "_get_media_content", + "_get_media_file", + "_get_file_name", + "_get_mime_type", + "_get_file_size", + "_get_file_unique_id", + "_get_file_id", + "_get_file_int_id", + # filters + "_FilterPrivate", + "_FilterIncoming", + "_FilterCommand", + "_FilterRegex", + "_FilterMedia", + "_FilterDocument", + "_FilterVideo", + "_FilterPhoto", + "_FilterAudio", + "_FilterVoice", + "_FilterAnimation", + "_FilterVideoNote", + "_FilterChannel", + "_FilterUser", + "_FilterChat", + "_FilterAnd", + "_FilterOr", + "_FilterNot", + "Filters", + # telegram_helpers + "is_error", + "ChatMemberStatus", + "get_member_status", +] diff --git a/Thunder/utils/config_parser.py b/Thunder/utils/config_parser.py index d1bb0b4d..12e6191a 100644 --- a/Thunder/utils/config_parser.py +++ b/Thunder/utils/config_parser.py @@ -1,35 +1,36 @@ # Thunder/utils/config_parser.py import os -from typing import Dict, Optional + from Thunder.utils.logger import logger + class TokenParser: - def __init__(self, config_file: Optional[str] = None): - self.tokens: Dict[int, str] = {} + def __init__(self, config_file: str | None = None): + self.tokens: dict[int, str] = {} self.config_file = config_file - def parse_from_env(self) -> Dict[int, str]: + def parse_from_env(self) -> dict[int, str]: try: multi_tokens = { key: value.strip() for key, value in os.environ.items() if key.startswith("MULTI_TOKEN") and value.strip() } - + if not multi_tokens: return {} - + sorted_tokens = sorted( multi_tokens.items(), key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0) ) - + self.tokens = { index + 1: token for index, (_, token) in enumerate(sorted_tokens) } - + return self.tokens except Exception as e: logger.error(f"Error in parse_from_env: {e}", exc_info=True) diff --git a/Thunder/utils/custom_dl.py b/Thunder/utils/custom_dl.py index 22b0de46..f991389c 100644 --- a/Thunder/utils/custom_dl.py +++ b/Thunder/utils/custom_dl.py @@ -1,132 +1,160 @@ -# Thunder/utils/custom_dl.py +from __future__ import annotations import asyncio -from typing import Any, AsyncGenerator, Awaitable, Callable, Dict, Optional +import os +import tempfile +from collections.abc import AsyncGenerator, Awaitable, Callable +from typing import Any -from pyrogram import Client -from pyrogram.errors import FloodWait -from pyrogram.types import Message +import aiofiles +import pytdbot +from pytdbot import types from Thunder.server.exceptions import FileNotFound -from Thunder.utils.file_properties import get_media +from Thunder.utils.compat import _get_file_name, _get_media_file, _get_mime_type from Thunder.utils.logger import logger +from Thunder.utils.media_helpers import _get_extension_for_content_type, _infer_mime_from_content_type from Thunder.vars import Var +_DOWNLOAD_DIR = os.path.join(tempfile.gettempdir(), "thunder_downloads") +os.makedirs(_DOWNLOAD_DIR, exist_ok=True) + class ByteStreamer: __slots__ = ('client', 'chat_id') - def __init__(self, client: Client) -> None: + def __init__(self, client: pytdbot.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 - - if not message or not message.media: + async def get_message(self, message_id: int) -> types.Message: + result = await self.client.getMessage( + chat_id=self.chat_id, message_id=message_id + ) + if isinstance(result, types.Error): + raise FileNotFound(f"Message {message_id} not found: {result.message}") + if not result or not hasattr(result, "content") or result.content is None: raise FileNotFound(f"Message {message_id} not found") - return message + if not self._extract_media_file(result): + raise FileNotFound(f"Message {message_id} has no media") + return result + + @staticmethod + def _cleanup_file(path: str) -> None: + if os.path.exists(path): + try: + os.remove(path) + except OSError as e: + logger.warning(f"Failed to clean up temp file {path}: {e}") + + @staticmethod + def _extract_media_file(message: types.Message) -> types.File | None: + return _get_media_file(message) + + async def _download_file(self, file_id: int) -> str: + result = await self.client.downloadFile( + file_id=file_id, priority=1, synchronous=True + ) + if isinstance(result, types.Error): + raise FileNotFound(f"Download failed: {result.message}") + if not result.local.is_downloading_completed: + raise FileNotFound(f"Download incomplete for file {file_id}") + return result.local.path async def stream_file( self, - media_ref: int | Message, + media_ref: int | types.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) - 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): + on_fallback_message: Callable[[types.Message], Awaitable[None]] | None = None, + ) -> AsyncGenerator[bytes]: + refs: list[int] = [] + if isinstance(media_ref, types.Message): + refs.append(media_ref.id) + elif isinstance(media_ref, int): + refs.append(media_ref) + + if fallback_message_id is not None and fallback_message_id not in refs: 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 + file_path: str | None = None + try: + message = await self.get_message(ref) + if on_fallback_message is not None and ref == fallback_message_id: + await on_fallback_message(message) + + media_file = self._extract_media_file(message) + if not media_file: + raise FileNotFound(f"No media in message {ref}") + + file_path = await self._download_file(media_file.id) + file_size = media_file.size + + read_offset = max(0, offset) + bytes_remaining = (file_size - read_offset) if limit <= 0 else min(limit, file_size - read_offset) + chunk_size = 1024 * 1024 # 1 MB + + async with aiofiles.open(file_path, "rb") as f: + await f.seek(read_offset) + while bytes_remaining > 0: + to_read = min(chunk_size, bytes_remaining) + chunk = await f.read(to_read) + if not chunk: + break 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]: - media = get_media(message) - if not media: + bytes_remaining -= len(chunk) + return + except FileNotFound: + last_error = FileNotFound(f"Unable to stream file for ref {ref}") + continue + except Exception as e: + last_error = e + logger.debug(f"Error streaming media ref {ref}: {e}", exc_info=True) + continue + finally: + if file_path: + try: + await asyncio.to_thread(self._cleanup_file, file_path) + except Exception: + pass + + raise last_error or FileNotFound("Unable to stream file") + + def get_file_info_sync(self, message: types.Message) -> dict[str, Any]: + media_file = self._extract_media_file(message) + if not media_file: return {"message_id": message.id, "error": "No media"} - media_type = type(media).__name__.lower() - file_name = getattr(media, 'file_name', None) - mime_type = getattr(media, 'mime_type', None) + content = message.content + content_type = type(content).__name__.lower() + media_type = content_type.replace("message", "", 1) if content_type.startswith("message") else content_type + file_name = _get_file_name(message) + mime_type = _get_mime_type(message) 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 = _get_extension_for_content_type(media_type).lstrip(".") 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) + inferred = _infer_mime_from_content_type(media_type) + if inferred: + mime_type = inferred + else: + mime_type = "application/octet-stream" return { "message_id": message.id, - "file_size": getattr(media, 'file_size', 0) or 0, + "file_size": media_file.size, "file_name": file_name, "mime_type": mime_type, - "unique_id": getattr(media, 'file_unique_id', None), - "media_type": media_type + "unique_id": getattr(message, "remote_unique_file_id", None), + "media_type": media_type, } - async def get_file_info(self, message_id: int) -> Dict[str, Any]: + async def get_file_info(self, message_id: int) -> dict[str, Any]: try: message = await self.get_message(message_id) return self.get_file_info_sync(message) diff --git a/Thunder/utils/database.py b/Thunder/utils/database.py deleted file mode 100644 index d0bbe9a7..00000000 --- a/Thunder/utils/database.py +++ /dev/null @@ -1,453 +0,0 @@ -# Thunder/utils/database.py - -import datetime -from typing import Any, Dict, Optional -from pymongo import AsyncMongoClient -from pymongo.asynchronous.collection import AsyncCollection -from pymongo.errors import DuplicateKeyError -from Thunder.vars import Var -from Thunder.utils.logger import logger - -class Database: - def __init__(self, uri: str, database_name: str, *args, **kwargs): - self._client = AsyncMongoClient(uri, *args, **kwargs) - self.db = self._client[database_name] - self.col: AsyncCollection = self.db.users - self.banned_users_col: AsyncCollection = self.db.banned_users - self.banned_channels_col: AsyncCollection = self.db.banned_channels - self.token_col: AsyncCollection = self.db.tokens - self.authorized_users_col: AsyncCollection = self.db.authorized_users - self.restart_message_col: AsyncCollection = self.db.restart_message - self.files_col: AsyncCollection = self.db.files - self.file_ingest_locks_col: AsyncCollection = self.db.file_ingest_locks - - async def _deduplicate_users(self) -> None: - pipeline = [ - {"$sort": {"join_date": 1}}, - {"$group": {"_id": "$id", "doc_id": {"$first": "$_id"}}}, - {"$project": {"_id": "$doc_id"}} - ] - keep_ids = [] - async for doc in self.col.aggregate(pipeline): - keep_ids.append(doc["_id"]) - if keep_ids: - result = await self.col.delete_many({"_id": {"$nin": keep_ids}}) - if result.deleted_count > 0: - logger.warning(f"Deduplicated {result.deleted_count} duplicate user documents.") - - async def ensure_indexes(self, *, raise_on_error: bool = True) -> bool: - try: - await self.banned_users_col.create_index("user_id", unique=True) - await self.banned_channels_col.create_index("channel_id", unique=True) - await self.token_col.create_index("token", unique=True) - await self.authorized_users_col.create_index("user_id", unique=True) - try: - await self.col.create_index("id", unique=True) - except DuplicateKeyError: - logger.warning("Duplicate users found, deduplicating...") - await self._deduplicate_users() - await self.col.create_index("id", unique=True) - await self.token_col.create_index("expires_at", expireAfterSeconds=0) - await self.token_col.create_index("activated") - await self.restart_message_col.create_index("message_id", unique=True) - await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600) - await self.files_col.create_index("file_unique_id", unique=True) - await self.files_col.create_index("public_hash", unique=True) - await self.files_col.create_index("canonical_message_id", unique=True) - await self.files_col.create_index("created_at") - await self.files_col.create_index("last_seen_at") - await self.file_ingest_locks_col.create_index("expires_at", expireAfterSeconds=0) - - logger.debug("Database indexes ensured.") - return True - except Exception as e: - logger.error(f"Error in ensure_indexes: {e}", exc_info=True) - if raise_on_error: - raise - return False - - def new_user(self, user_id: int) -> dict: - try: - return { - 'id': user_id, - 'join_date': datetime.datetime.now(datetime.timezone.utc) - } - except Exception as e: - logger.error(f"Error in new_user for user {user_id}: {e}", exc_info=True) - raise - - async def add_user(self, user_id: int) -> bool: - try: - result = await self.col.update_one( - {'id': user_id}, - {'$setOnInsert': self.new_user(user_id)}, - upsert=True - ) - if result.upserted_id: - logger.debug(f"Added new user {user_id} to database.") - return True - return False - except Exception as e: - logger.error(f"Error in add_user for user {user_id}: {e}", exc_info=True) - raise - - - async def is_user_exist(self, user_id: int) -> bool: - """Read-only existence check. For user registration, use add_user() instead.""" - try: - user = await self.col.find_one({'id': user_id}, {'_id': 1}) - return bool(user) - except Exception as e: - logger.error(f"Error in is_user_exist for user {user_id}: {e}", exc_info=True) - raise - - async def total_users_count(self) -> int: - try: - return await self.col.count_documents({}) - except Exception as e: - logger.error(f"Error in total_users_count: {e}", exc_info=True) - return 0 - - async def get_authorized_users_count(self) -> int: - try: - return await self.authorized_users_col.count_documents({}) - except Exception as e: - logger.error(f"Error in get_authorized_users_count: {e}", exc_info=True) - return 0 - - async def get_regular_users_count(self) -> int: - try: - auth_ids = await self.authorized_users_col.distinct("user_id") - return await self.col.count_documents({"id": {"$nin": auth_ids}}) - except Exception as e: - logger.error(f"Error in get_regular_users_count: {e}", exc_info=True) - return 0 - - async def get_all_users(self): - try: - return self.col.find({}) - except Exception as e: - logger.error(f"Error in get_all_users: {e}", exc_info=True) - return self.col.find({"_id": {"$exists": False}}) - - async def get_authorized_users_cursor(self): - try: - return self.authorized_users_col.find({}) - except Exception as e: - logger.error(f"Error in get_authorized_users_cursor: {e}", exc_info=True) - return self.authorized_users_col.find({"_id": {"$exists": False}}) - - async def get_regular_users_cursor(self): - try: - auth_ids = await self.authorized_users_col.distinct("user_id") - return self.col.find({"id": {"$nin": auth_ids}}) - except Exception as e: - logger.error(f"Error in get_regular_users_cursor: {e}", exc_info=True) - return self.col.find({"_id": {"$exists": False}}) - - async def delete_user(self, user_id: int): - try: - await self.col.delete_one({'id': user_id}) - logger.debug(f"Deleted user {user_id}.") - except Exception as e: - logger.error(f"Error in delete_user for user {user_id}: {e}", exc_info=True) - raise - - - async def add_banned_user( - self, user_id: int, banned_by: Optional[int] = None, - reason: Optional[str] = None - ): - try: - ban_data = { - "user_id": user_id, - "banned_at": datetime.datetime.now(datetime.timezone.utc), - "banned_by": banned_by, - "reason": reason - } - await self.banned_users_col.update_one( - {"user_id": user_id}, - {"$set": ban_data}, - upsert=True - ) - logger.debug(f"Added/Updated banned user {user_id}. Reason: {reason}") - except Exception as e: - logger.error(f"Error in add_banned_user for user {user_id}: {e}", exc_info=True) - raise - - async def remove_banned_user(self, user_id: int) -> bool: - try: - result = await self.banned_users_col.delete_one({"user_id": user_id}) - if result.deleted_count > 0: - logger.debug(f"Removed banned user {user_id}.") - return True - return False - except Exception as e: - logger.error(f"Error in remove_banned_user for user {user_id}: {e}", exc_info=True) - return False - - async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]: - try: - return await self.banned_users_col.find_one({"user_id": user_id}) - except Exception as e: - logger.error(f"Error in is_user_banned for user {user_id}: {e}", exc_info=True) - return None - - async def add_banned_channel( - self, channel_id: int, banned_by: Optional[int] = None, - reason: Optional[str] = None - ): - try: - ban_data = { - "channel_id": channel_id, - "banned_at": datetime.datetime.now(datetime.timezone.utc), - "banned_by": banned_by, - "reason": reason - } - await self.banned_channels_col.update_one( - {"channel_id": channel_id}, - {"$set": ban_data}, - upsert=True - ) - logger.debug(f"Added/Updated banned channel {channel_id}. Reason: {reason}") - except Exception as e: - logger.error(f"Error in add_banned_channel for channel {channel_id}: {e}", exc_info=True) - raise - - async def remove_banned_channel(self, channel_id: int) -> bool: - try: - result = await self.banned_channels_col.delete_one({"channel_id": channel_id}) - if result.deleted_count > 0: - logger.debug(f"Removed banned channel {channel_id}.") - return True - return False - except Exception as e: - logger.error(f"Error in remove_banned_channel for channel {channel_id}: {e}", exc_info=True) - return False - - async def is_channel_banned(self, channel_id: int) -> Optional[Dict[str, Any]]: - try: - return await self.banned_channels_col.find_one({"channel_id": channel_id}) - except Exception as e: - logger.error(f"Error in is_channel_banned for channel {channel_id}: {e}", exc_info=True) - return None - - async def save_main_token(self, user_id: int, token_value: str, expires_at: datetime.datetime, created_at: datetime.datetime, activated: bool) -> None: - try: - await self.token_col.update_one( - {"user_id": user_id, "token": token_value}, - {"$set": { - "expires_at": expires_at, - "created_at": created_at, - "activated": activated - } - }, - upsert=True - ) - logger.debug(f"Saved main token {token_value} for user {user_id} with activated status {activated}.") - except Exception as e: - logger.error(f"Error saving main token for user {user_id}: {e}", exc_info=True) - raise - - - async def add_restart_message(self, message_id: int, chat_id: int) -> None: - try: - await self.restart_message_col.insert_one({ - "message_id": message_id, - "chat_id": chat_id, - "timestamp": datetime.datetime.now(datetime.timezone.utc) - }) - logger.debug(f"Added restart message {message_id} for chat {chat_id}.") - except Exception as e: - logger.error(f"Error adding restart message {message_id}: {e}", exc_info=True) - - async def get_restart_message(self) -> Optional[Dict[str, Any]]: - try: - return await self.restart_message_col.find_one(sort=[("timestamp", -1)]) - except Exception as e: - logger.error(f"Error getting restart message: {e}", exc_info=True) - return None - - async def delete_restart_message(self, message_id: int) -> None: - try: - await self.restart_message_col.delete_one({"message_id": message_id}) - logger.debug(f"Deleted restart message {message_id}.") - except Exception as e: - logger.error(f"Error deleting restart message {message_id}: {e}", exc_info=True) - - async def is_user_authorized(self, user_id: int) -> bool: - try: - user = await self.authorized_users_col.find_one({'user_id': user_id}, {'_id': 1}) - return bool(user) - except Exception as e: - logger.error(f"Error in is_user_authorized for user {user_id}: {e}", exc_info=True) - return False - - async def get_file_by_unique_id(self, file_unique_id: str) -> Optional[Dict[str, Any]]: - try: - return await self.files_col.find_one({"file_unique_id": file_unique_id}) - except Exception as e: - logger.error(f"Error getting file by unique_id {file_unique_id}: {e}", exc_info=True) - return None - - async def get_file_by_hash( - self, - public_hash: str, - *, - raise_on_error: bool = True - ) -> Optional[Dict[str, Any]]: - try: - return await self.files_col.find_one({"public_hash": public_hash}) - except Exception as e: - logger.error(f"Error getting file by hash {public_hash}: {e}", exc_info=True) - if raise_on_error: - raise - return None - - async def get_file_by_message_id(self, canonical_message_id: int) -> Optional[Dict[str, Any]]: - try: - return await self.files_col.find_one({"canonical_message_id": canonical_message_id}) - except Exception as e: - logger.error( - f"Error getting file by message_id {canonical_message_id}: {e}", - exc_info=True - ) - return None - - async def create_file_record(self, file_record: Dict[str, Any]) -> None: - try: - await self.files_col.insert_one(file_record) - except Exception as e: - logger.error( - f"Error creating canonical file record for {file_record.get('file_unique_id')}: {e}", - exc_info=True - ) - raise - - async def replace_file_record(self, file_record: Dict[str, Any]) -> None: - try: - await self.files_col.replace_one( - {"file_unique_id": file_record["file_unique_id"]}, - file_record, - upsert=True - ) - except Exception as e: - logger.error( - f"Error replacing canonical file record for {file_record.get('file_unique_id')}: {e}", - exc_info=True - ) - raise - - async def touch_file_record( - self, - public_hash: str, - *, - reused: bool = False, - raise_on_error: bool = False - ) -> bool: - try: - update_doc: Dict[str, Any] = { - "$set": {"last_seen_at": datetime.datetime.now(datetime.timezone.utc)}, - "$inc": {"seen_count": 1} - } - if reused: - update_doc["$inc"]["reuse_count"] = 1 - await self.files_col.update_one({"public_hash": public_hash}, update_doc) - return True - except Exception as e: - logger.error(f"Error touching canonical file {public_hash}: {e}", exc_info=True) - if raise_on_error: - raise - return False - - async def update_file_id( - self, - public_hash: str, - file_id: str, - *, - raise_on_error: bool = False - ) -> bool: - try: - await self.files_col.update_one( - {"public_hash": public_hash}, - { - "$set": { - "file_id": file_id, - "last_seen_at": datetime.datetime.now(datetime.timezone.utc) - } - } - ) - return True - except Exception as e: - logger.error(f"Error updating file_id for {public_hash}: {e}", exc_info=True) - if raise_on_error: - raise - return False - - async def acquire_file_ingest_claim( - self, - file_unique_id: str, - *, - ttl_seconds: int = 60 - ) -> bool: - now = datetime.datetime.now(datetime.timezone.utc) - claim_fields = { - "created_at": now, - "expires_at": now + datetime.timedelta(seconds=ttl_seconds) - } - try: - await self.file_ingest_locks_col.insert_one({ - "_id": file_unique_id, - **claim_fields - }) - return True - except DuplicateKeyError: - try: - result = await self.file_ingest_locks_col.find_one_and_update( - { - "_id": file_unique_id, - "$or": [ - {"expires_at": {"$lte": now}}, - {"expires_at": {"$exists": False}} - ] - }, - { - "$set": claim_fields - }, - return_document=False - ) - return bool(result) - except Exception as e: - logger.error(f"Error updating ingest claim for {file_unique_id}: {e}", exc_info=True) - raise - except Exception as e: - logger.error(f"Error acquiring ingest claim for {file_unique_id}: {e}", exc_info=True) - raise - - async def release_file_ingest_claim(self, file_unique_id: str) -> bool: - try: - await self.file_ingest_locks_col.delete_one({"_id": file_unique_id}) - return True - except Exception as e: - logger.error(f"Error releasing ingest claim for {file_unique_id}: {e}", exc_info=True) - return False - - async def is_file_ingest_claim_active(self, file_unique_id: str) -> bool: - try: - claim = await self.file_ingest_locks_col.find_one( - { - "_id": file_unique_id, - "expires_at": {"$gt": datetime.datetime.now(datetime.timezone.utc)} - }, - {"_id": 1} - ) - return bool(claim) - except Exception as e: - logger.error(f"Error checking ingest claim for {file_unique_id}: {e}", exc_info=True) - raise - - async def close(self): - if self._client: - await self._client.close() - -db = Database(Var.DATABASE_URL, Var.NAME) diff --git a/Thunder/utils/database/__init__.py b/Thunder/utils/database/__init__.py new file mode 100644 index 00000000..077fac21 --- /dev/null +++ b/Thunder/utils/database/__init__.py @@ -0,0 +1,7 @@ +from Thunder.vars import Var + +from .client import Database + +db = Database(Var.DATABASE_URL, Var.NAME) + +__all__ = ["db", "Database"] diff --git a/Thunder/utils/database/ban_repo.py b/Thunder/utils/database/ban_repo.py new file mode 100644 index 00000000..41e126e6 --- /dev/null +++ b/Thunder/utils/database/ban_repo.py @@ -0,0 +1,116 @@ +import datetime +import time +from collections import OrderedDict +from typing import Any + +from Thunder.utils.logger import logger + + +class _BanRepo: + banned_users_col: object + banned_channels_col: object + _banned_cache: OrderedDict + _channel_ban_cache: OrderedDict + _USER_CACHE_TTL: int + + 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 + ) + self._banned_cache.pop(user_id, None) + 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: + self._banned_cache.pop(user_id, None) + 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) -> dict[str, Any] | None: + now = time.time() + if user_id in self._banned_cache: + cached_doc, ts = self._banned_cache[user_id] + if now - ts < self._USER_CACHE_TTL: + self._banned_cache.move_to_end(user_id) + return cached_doc + try: + result = await self.banned_users_col.find_one({"user_id": user_id}) + self._banned_cache[user_id] = (result, now) + if len(self._banned_cache) > 5000: + self._banned_cache.popitem(last=False) + return result + 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: 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 + ) + self._channel_ban_cache.pop(channel_id, None) + 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: + self._channel_ban_cache.pop(channel_id, None) + 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) -> dict[str, Any] | None: + now = time.time() + if channel_id in self._channel_ban_cache: + cached_doc, ts = self._channel_ban_cache[channel_id] + if now - ts < self._USER_CACHE_TTL: + self._channel_ban_cache.move_to_end(channel_id) + return cached_doc + try: + result = await self.banned_channels_col.find_one({"channel_id": channel_id}) + self._channel_ban_cache[channel_id] = (result, now) + if len(self._channel_ban_cache) > 5000: + self._channel_ban_cache.popitem(last=False) + return result + except Exception as e: + logger.error(f"Error in is_channel_banned for channel {channel_id}: {e}", exc_info=True) + return None diff --git a/Thunder/utils/database/client.py b/Thunder/utils/database/client.py new file mode 100644 index 00000000..4c15937b --- /dev/null +++ b/Thunder/utils/database/client.py @@ -0,0 +1,76 @@ +from collections import OrderedDict + +from pymongo import AsyncMongoClient +from pymongo.asynchronous.collection import AsyncCollection +from pymongo.errors import DuplicateKeyError + +from Thunder.utils.logger import logger + +from .ban_repo import _BanRepo +from .file_repo import _FileRepo +from .lock_repo import _LockRepo +from .restart_repo import _RestartRepo +from .token_repo import _TokenRepo +from .user_repo import _UserRepo + + +class Database(_UserRepo, _BanRepo, _FileRepo, _TokenRepo, _LockRepo, _RestartRepo): + _USER_CACHE_TTL = 300 # 5 minutes + + def __init__(self, uri: str, database_name: str, *args, **kwargs): + self._client = AsyncMongoClient( + uri, + maxPoolSize=50, + minPoolSize=5, + serverSelectionTimeoutMS=5000, + connectTimeoutMS=5000, + retryWrites=True, + *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 + self._user_exist_cache: OrderedDict[int, tuple[bool, float]] = OrderedDict() + self._banned_cache: OrderedDict[int, tuple[dict | None, float]] = OrderedDict() + self._channel_ban_cache: OrderedDict[int, tuple[dict | None, float]] = OrderedDict() + + 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 + + async def close(self): + if self._client: + await self._client.close() diff --git a/Thunder/utils/database/file_repo.py b/Thunder/utils/database/file_repo.py new file mode 100644 index 00000000..8d106bc2 --- /dev/null +++ b/Thunder/utils/database/file_repo.py @@ -0,0 +1,110 @@ +import datetime +from typing import Any + +from Thunder.utils.file_record import FileRecord +from Thunder.utils.logger import logger + + +class _FileRepo: + files_col: object + + async def get_file_by_unique_id(self, file_unique_id: str) -> FileRecord | 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 {file_unique_id}: {e}", exc_info=True) + return None + + async def get_file_by_hash( + self, + public_hash: str, + *, + raise_on_error: bool = True + ) -> FileRecord | None: + 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) -> FileRecord | None: + 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: FileRecord) -> 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: FileRecord) -> 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.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.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 diff --git a/Thunder/utils/database/lock_repo.py b/Thunder/utils/database/lock_repo.py new file mode 100644 index 00000000..58e9e115 --- /dev/null +++ b/Thunder/utils/database/lock_repo.py @@ -0,0 +1,71 @@ +import datetime + +from pymongo.errors import DuplicateKeyError + +from Thunder.utils.logger import logger + + +class _LockRepo: + file_ingest_locks_col: object + + async def acquire_file_ingest_claim( + self, + file_unique_id: str, + *, + ttl_seconds: int = 60 + ) -> bool: + now = datetime.datetime.now(datetime.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.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 diff --git a/Thunder/utils/database/restart_repo.py b/Thunder/utils/database/restart_repo.py new file mode 100644 index 00000000..7e6c46a6 --- /dev/null +++ b/Thunder/utils/database/restart_repo.py @@ -0,0 +1,33 @@ +import datetime +from typing import Any + +from Thunder.utils.logger import logger + + +class _RestartRepo: + restart_message_col: object + + 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) diff --git a/Thunder/utils/database/token_repo.py b/Thunder/utils/database/token_repo.py new file mode 100644 index 00000000..cc59b3fc --- /dev/null +++ b/Thunder/utils/database/token_repo.py @@ -0,0 +1,46 @@ +import datetime + +from Thunder.utils.logger import logger + + +class _TokenRepo: + token_col: object + authorized_users_col: object + + 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 + ) + if len(token_value) <= 8: + masked = "****" + elif len(token_value) <= 16: + masked = f"{token_value[:3]}...{token_value[-3:]}" + else: + masked = f"{token_value[:4]}...{token_value[-4:]}" + logger.debug(f"Saved main token {masked} 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 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 diff --git a/Thunder/utils/database/user_repo.py b/Thunder/utils/database/user_repo.py new file mode 100644 index 00000000..e5fc3f85 --- /dev/null +++ b/Thunder/utils/database/user_repo.py @@ -0,0 +1,123 @@ +import datetime +import time +from collections import OrderedDict + +from Thunder.utils.logger import logger + + +class _UserRepo: + _USER_CACHE_TTL: int + col: object + authorized_users_col: object + _user_exist_cache: OrderedDict + + def _new_user(self, user_id: int) -> dict: + try: + return { + 'id': user_id, + 'join_date': datetime.datetime.now(datetime.UTC) + } + except Exception as e: + logger.error(f"Error in new_user for user {user_id}: {e}", exc_info=True) + raise + + 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: + self._user_exist_cache.pop(user_id, None) + 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.""" + now = time.time() + if user_id in self._user_exist_cache: + result, ts = self._user_exist_cache[user_id] + if now - ts < self._USER_CACHE_TTL: + self._user_exist_cache.move_to_end(user_id) + return result + try: + user = await self.col.find_one({'id': user_id}, {'_id': 1}) + result = bool(user) + self._user_exist_cache[user_id] = (result, now) + if len(self._user_exist_cache) > 5000: + self._user_exist_cache.popitem(last=False) + return result + 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 _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.") diff --git a/Thunder/utils/decorators.py b/Thunder/utils/decorators.py index b1209f19..764378a5 100644 --- a/Thunder/utils/decorators.py +++ b/Thunder/utils/decorators.py @@ -1,181 +1,148 @@ -# 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 +from pytdbot import types + +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: types.Message): + try: + from_id = getattr(message, "from_id", None) + if not from_id: + return True + if from_id == Var.OWNER_ID: + return True + + ban_details = await db.is_user_banned(from_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 + ) + ) + except Exception as e: + logger.error(f"Error sending ban message: {e}") + logger.debug(f"Blocked banned user {from_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: types.Message): + try: + from_id = getattr(message, "from_id", None) + if not from_id: + return True + + if not getattr(Var, "TOKEN_ENABLED", False): + return True + + if from_id == Var.OWNER_ID or await allowed(from_id) or await check(from_id): + return True + + temp_token_string = None + try: + temp_token_string = await generate(from_id) + except Exception as e: + logger.error(f"Failed to generate token for user {from_id}: {e}", exc_info=True) + try: + await message.reply_text("Sorry, could not generate an access token link. Please try again later.") + except Exception: + logger.debug(f"Failed to send token generation error to user {from_id}") + return False + + if not temp_token_string: + logger.error(f"Token generation returned empty for user {from_id}") + try: + await message.reply_text("Sorry, could not generate an access token link. Please try again later.") + except Exception: + logger.debug(f"Failed to send token empty error to user {from_id}") + return False + + me = await client.getMe() + if isinstance(me, types.Error): + logger.error(f"Failed to get bot info: {me.message}") + return False + + bot_username = None + if hasattr(me, "usernames") and me.usernames: + bot_username = me.usernames.editable_username or (me.usernames.active_usernames[0] if me.usernames.active_usernames else None) + if not bot_username: + bot_username = getattr(me, "username", None) + + deep_link = f"https://t.me/{bot_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: {e}") + + button = types.InlineKeyboardButton( + text="Activate Access", + type=types.InlineKeyboardButtonTypeUrl(url=short_url) + ) + try: + await message.reply_text( + MSG_TOKEN_INVALID, + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[button]]) + ) + except Exception as e: + logger.error(f"Error sending token message: {e}") + logger.debug(f"Sent token activation link to user {from_id}.") + return False + except Exception as e: + logger.error(f"Error in require_token: {e}", exc_info=True) + return False + + +async def get_shortener_status(client, message: types.Message): + try: + from_id = getattr(message, "from_id", None) + use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False) + if from_id: + try: + if from_id == Var.OWNER_ID or await allowed(from_id): + use_shortener = False + except Exception as e: + logger.warning(f"Error checking allowed status: {e}") + 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: + from_id = None + if hasattr(update, "from_id"): + from_id = update.from_id + elif hasattr(update, "sender_id"): + sender = update.sender_id + if isinstance(sender, types.MessageSenderUser): + from_id = sender.user_id + + if not from_id or from_id != Var.OWNER_ID: + if hasattr(update, "answer"): + await update.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True) + logger.warning(f"Unauthorized access attempt by {from_id}") + return False + return True + except Exception as e: + logger.error(f"Error in owner_only: {e}", exc_info=True) + return False diff --git a/Thunder/utils/file_properties.py b/Thunder/utils/file_properties.py index c83f7ae7..5eb7005b 100644 --- a/Thunder/utils/file_properties.py +++ b/Thunder/utils/file_properties.py @@ -1,99 +1,86 @@ -# 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 + +import pytdbot +from pytdbot import types + +from Thunder.server.exceptions import FileNotFound +from Thunder.utils.compat import ( + _get_file_name, + _get_file_size, + _get_file_unique_id, + _get_media_file, +) +from Thunder.utils.logger import logger +from Thunder.utils.media_helpers import _get_extension_for_content_type + + +def get_media(message: types.Message) -> Any | None: + content = getattr(message, "content", None) + if content is None: + return None + media_map = { + types.MessageAudio: lambda c: c.audio, + types.MessageDocument: lambda c: c.document, + types.MessagePhoto: lambda c: c.photo, + types.MessageSticker: lambda c: c.sticker, + types.MessageAnimation: lambda c: c.animation, + types.MessageVideo: lambda c: c.video, + types.MessageVoiceNote: lambda c: c.voice, + types.MessageVideoNote: lambda c: c.video, + } + for media_type, getter in media_map.items(): + if isinstance(content, media_type): + return getter(content) + return None + + +def get_uniqid(message: types.Message) -> str | None: + return _get_file_unique_id(message) + + +def get_hash(media_msg: types.Message) -> str: + uniq_id = get_uniqid(media_msg) + return uniq_id[:6] if uniq_id else '' + + +def get_fsize(message: types.Message) -> int: + return _get_file_size(message) + + +def parse_fid(message: types.Message) -> Any | None: + media_file = _get_media_file(message) + if media_file: + return media_file + return None + + +def get_fname(msg: types.Message) -> str: + fname = _get_file_name(msg) + if not fname: + content = getattr(msg, "content", None) + ext = ".bin" + if content: + content_type = type(content).__name__.lower() + media_type = content_type.replace("message", "", 1) if content_type.startswith("message") else content_type + ext = _get_extension_for_content_type(media_type) + 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: pytdbot.Client, chat_id: int, message_id: int): + try: + result = await client.getMessage(chat_id=chat_id, message_id=message_id) + if isinstance(result, types.Error): + raise FileNotFound(f"Message not found: {result.message}") + if not result or getattr(result, "empty", False): + raise FileNotFound("Message not found") + + media_file = _get_media_file(result) + if not media_file: + raise FileNotFound("No media in message") + return media_file + except Exception as e: + logger.error(f"Error in get_fids: {e}", exc_info=True) + raise FileNotFound(str(e)) diff --git a/Thunder/utils/file_record.py b/Thunder/utils/file_record.py new file mode 100644 index 00000000..eb9e0e61 --- /dev/null +++ b/Thunder/utils/file_record.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +import datetime +from typing import TypedDict + + +class FileRecord(TypedDict, total=False): + file_unique_id: str + public_hash: str + canonical_message_id: int + file_id: str | None + file_name: str | None + mime_type: str + file_size: int + media_type: str + first_source_chat_id: int | None + first_source_message_id: int | None + created_at: datetime.datetime + last_seen_at: datetime.datetime + seen_count: int + reuse_count: int diff --git a/Thunder/utils/filters.py b/Thunder/utils/filters.py new file mode 100644 index 00000000..9122a1f5 --- /dev/null +++ b/Thunder/utils/filters.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +from collections.abc import Callable +from typing import Any + +import pytdbot +from pytdbot import types + +from .media_helpers import _get_media_content + + +class _FilterPrivate: + def __call__(self, _: Any, message: types.Message) -> bool: + chat = getattr(message, "chat", None) + if chat and isinstance(chat.type, types.ChatTypePrivate): + return True + return False + + +class _FilterIncoming: + def __call__(self, _: Any, message: types.Message) -> bool: + return not getattr(message, "is_outgoing", True) + + +class _FilterCommand: + def __init__(self, command: str): + self.command = command.lower() + + def __call__(self, _: Any, message: types.Message) -> bool: + text = getattr(message, "text", "") or "" + if not text.startswith("/"): + return False + cmd = text.split()[0].split("@")[0][1:].lower() + return cmd == self.command + + +class _FilterRegex: + def __init__(self, pattern: str): + import re + self.pattern = re.compile(pattern) + + def __call__(self, _: Any, update: Any) -> bool: + data = None + payload = getattr(update, "payload", None) + if isinstance(payload, types.CallbackQueryPayloadData): + data = payload.data + if isinstance(data, bytes): + data = data.decode("utf-8", errors="replace") + if data is None: + text = getattr(update, "text", None) + if text is not None: + data = str(text) + if data is None: + return False + return bool(self.pattern.search(data)) + + +class _FilterMedia: + def __call__(self, _: Any, message: types.Message) -> bool: + return _get_media_content(message) is not None + + +class _FilterDocument: + def __call__(self, _: Any, message: types.Message) -> bool: + return isinstance(getattr(message, "content", None), types.MessageDocument) + + +class _FilterVideo: + def __call__(self, _: Any, message: types.Message) -> bool: + return isinstance(getattr(message, "content", None), types.MessageVideo) + + +class _FilterPhoto: + def __call__(self, _: Any, message: types.Message) -> bool: + return isinstance(getattr(message, "content", None), types.MessagePhoto) + + +class _FilterAudio: + def __call__(self, _: Any, message: types.Message) -> bool: + return isinstance(getattr(message, "content", None), types.MessageAudio) + + +class _FilterVoice: + def __call__(self, _: Any, message: types.Message) -> bool: + return isinstance(getattr(message, "content", None), types.MessageVoiceNote) + + +class _FilterAnimation: + def __call__(self, _: Any, message: types.Message) -> bool: + return isinstance(getattr(message, "content", None), types.MessageAnimation) + + +class _FilterVideoNote: + def __call__(self, _: Any, message: types.Message) -> bool: + return isinstance(getattr(message, "content", None), types.MessageVideoNote) + + +class _FilterChannel: + def __call__(self, _: Any, message: types.Message) -> bool: + chat = getattr(message, "chat", None) + if chat and isinstance(chat.type, types.ChatTypeSupergroup): + return chat.type.is_channel + return False + + +class _FilterUser: + def __init__(self, user_ids): + if isinstance(user_ids, int): + self.user_ids = {user_ids} + else: + self.user_ids = set(user_ids) + + def __call__(self, _: Any, message: types.Message) -> bool: + from_id = getattr(message, "from_id", None) + return from_id in self.user_ids + + +class _FilterChat: + def __init__(self, chat_ids): + if isinstance(chat_ids, int): + self.chat_ids = {chat_ids} + else: + self.chat_ids = set(chat_ids) + + def __call__(self, _: Any, message: types.Message) -> bool: + return message.chat_id in self.chat_ids + + +class _FilterAnd: + def __init__(self, *filters): + self.filters = filters + + def __call__(self, client: Any, update: Any) -> bool: + return all(f(client, update) for f in self.filters) + + +class _FilterOr: + def __init__(self, *filters): + self.filters = filters + + def __call__(self, client: Any, update: Any) -> bool: + return any(f(client, update) for f in self.filters) + + +class _FilterNot: + def __init__(self, filt): + self.filt = filt + + def __call__(self, client: Any, update: Any) -> bool: + return not self.filt(client, update) + + +class Filters: + private = pytdbot.filters.create(_FilterPrivate()) + incoming = pytdbot.filters.create(_FilterIncoming()) + media = pytdbot.filters.create(_FilterMedia()) + document = pytdbot.filters.create(_FilterDocument()) + video = pytdbot.filters.create(_FilterVideo()) + photo = pytdbot.filters.create(_FilterPhoto()) + audio = pytdbot.filters.create(_FilterAudio()) + voice = pytdbot.filters.create(_FilterVoice()) + animation = pytdbot.filters.create(_FilterAnimation()) + video_note = pytdbot.filters.create(_FilterVideoNote()) + channel = pytdbot.filters.create(_FilterChannel()) + + @staticmethod + def command(cmd: str) -> pytdbot.filters.Filter: + return pytdbot.filters.create(_FilterCommand(cmd)) + + @staticmethod + def regex(pattern: str) -> pytdbot.filters.Filter: + return pytdbot.filters.create(_FilterRegex(pattern)) + + @staticmethod + def user(user_ids) -> pytdbot.filters.Filter: + return pytdbot.filters.create(_FilterUser(user_ids)) + + @staticmethod + def chat(chat_ids) -> pytdbot.filters.Filter: + return pytdbot.filters.create(_FilterChat(chat_ids)) + + @staticmethod + def create(func: Callable) -> pytdbot.filters.Filter: + return pytdbot.filters.create(func) + + @staticmethod + def and_(*filters) -> pytdbot.filters.Filter: + return pytdbot.filters.create(_FilterAnd(*filters)) + + @staticmethod + def or_(*filters) -> pytdbot.filters.Filter: + return pytdbot.filters.create(_FilterOr(*filters)) + + @staticmethod + def not_(filt) -> pytdbot.filters.Filter: + return pytdbot.filters.create(_FilterNot(filt)) + + @staticmethod + def outgoing() -> pytdbot.filters.Filter: + return pytdbot.filters.create(lambda _, m: getattr(m, "is_outgoing", False)) + + @staticmethod + def group() -> pytdbot.filters.Filter: + def _check(_, m): + chat = getattr(m, "chat", None) + if chat: + return isinstance(chat.type, (types.ChatTypeBasicGroup, types.ChatTypeSupergroup)) and not getattr(chat.type, "is_channel", False) + return False + return pytdbot.filters.create(_check) + + @staticmethod + def supergroup() -> pytdbot.filters.Filter: + def _check(_, m): + chat = getattr(m, "chat", None) + if chat and isinstance(chat.type, types.ChatTypeSupergroup): + return not chat.type.is_channel + return False + return pytdbot.filters.create(_check) diff --git a/Thunder/utils/force_channel.py b/Thunder/utils/force_channel.py index 698ef180..9cb6b540 100644 --- a/Thunder/utils/force_channel.py +++ b/Thunder/utils/force_channel.py @@ -1,89 +1,112 @@ -# 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 pytdbot +from pytdbot import types + +from Thunder.utils.compat import ChatMemberStatus, get_member_status +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: pytdbot.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: + chat = await bot.getChat(chat_id=Var.FORCE_CHANNEL_ID) + if isinstance(chat, types.Error): + logger.error(f"Force channel error: {chat.message}") + return None, None + if chat: + invite_link = None + if hasattr(chat, "invite_link") and chat.invite_link: + invite_link = chat.invite_link + if not invite_link: + username = None + if hasattr(chat, "type") and isinstance(chat.type, types.ChatTypeSupergroup): + username = getattr(chat.type, "supergroup_id", None) + invite_link = f"https://t.me/c/{username}" if username else None + _force_link = invite_link + _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: pytdbot.Client, message: types.Message): + if not Var.FORCE_CHANNEL_ID: + return True + + from_id = getattr(message, "from_id", None) + if from_id is None: + return True + + try: + member = await client.getChatMember( + chat_id=Var.FORCE_CHANNEL_ID, + member_id=types.MessageSenderUser(user_id=from_id) + ) + if isinstance(member, types.Error): + if member.code != 404: + logger.error(f"Error checking force channel: {member.message}") + return False + + if isinstance(member, types.Error): + link, title = await get_force_info(client) + if link and title: + button = types.InlineKeyboardButton( + text="Join", + type=types.InlineKeyboardButtonTypeUrl(url=link) + ) + try: + await message.reply_text( + MSG_COMMUNITY_CHANNEL.format(channel_title=title), + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[button]]) + ) + except Exception: + logger.debug(f"Failed to send force channel join prompt to user {from_id}") + else: + try: + await message.reply_text("You must join the channel to use this bot.") + except Exception: + logger.debug(f"Failed to send plain join channel message to user {from_id}") + return False + + status = get_member_status(member) + if status in [ChatMemberStatus.LEFT, ChatMemberStatus.BANNED]: + link, title = await get_force_info(client) + if link and title: + button = types.InlineKeyboardButton( + text="Join", + type=types.InlineKeyboardButtonTypeUrl(url=link) + ) + try: + await message.reply_text( + MSG_COMMUNITY_CHANNEL.format(channel_title=title), + reply_markup=types.ReplyMarkupInlineKeyboard(rows=[[button]]) + ) + except Exception: + logger.debug(f"Failed to send force channel join prompt to user {from_id} (left/banned)") + else: + try: + await message.reply_text("You must join the channel to use this bot.") + except Exception: + logger.debug(f"Failed to send plain join channel message to user {from_id} (left/banned)") + return False + + return True + 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.") + except Exception: + logger.debug(f"Failed to send unexpected error message to user {from_id}") + return False diff --git a/Thunder/utils/keepalive.py b/Thunder/utils/keepalive.py index d7089c04..8890a32b 100644 --- a/Thunder/utils/keepalive.py +++ b/Thunder/utils/keepalive.py @@ -1,9 +1,12 @@ # Thunder/utils/keepalive.py import asyncio + import aiohttp -from Thunder.vars import Var + from Thunder.utils.logger import logger +from Thunder.vars import Var + async def ping_server(): try: diff --git a/Thunder/utils/logger.py b/Thunder/utils/logger.py index 6609a39b..2c328b30 100644 --- a/Thunder/utils/logger.py +++ b/Thunder/utils/logger.py @@ -1,11 +1,11 @@ # Thunder/utils/logger.py +import atexit import logging -from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener import os import queue -import atexit 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) @@ -13,7 +13,7 @@ logging._srcfile = None logging.logThreads = 0 -logging.logProcesses = 0 +logging.logProcesses = 0 log_queue = queue.Queue(maxsize=10000) diff --git a/Thunder/utils/media_helpers.py b/Thunder/utils/media_helpers.py new file mode 100644 index 00000000..9c133309 --- /dev/null +++ b/Thunder/utils/media_helpers.py @@ -0,0 +1,112 @@ +from __future__ import annotations + +from pytdbot import types + + +def _get_media_content(message: types.Message): + if not hasattr(message, "content") or message.content is None: + return None + content = message.content + media_types = ( + types.MessagePhoto, + types.MessageVideo, + types.MessageDocument, + types.MessageAudio, + types.MessageVoiceNote, + types.MessageAnimation, + types.MessageVideoNote, + types.MessageSticker, + ) + if isinstance(content, media_types): + return content + return None + + +def _get_media_file(message: types.Message) -> types.File | None: + content = getattr(message, "content", None) + if content is None: + return None + if isinstance(content, types.MessageDocument): + return content.document.document if content.document else None + if isinstance(content, types.MessageVideo): + return content.video.video if content.video else None + if isinstance(content, types.MessagePhoto): + sizes = content.photo.sizes if content.photo else None + return sizes[-1].photo if sizes else None + if isinstance(content, types.MessageAudio): + return content.audio.audio if content.audio else None + if isinstance(content, types.MessageVoiceNote): + return content.voice_note.voice if content.voice_note else None + if isinstance(content, types.MessageAnimation): + return content.animation.animation if content.animation else None + if isinstance(content, types.MessageVideoNote): + return content.video_note.video if content.video_note else None + if isinstance(content, types.MessageSticker): + return content.sticker.sticker if content.sticker else None + return None + + +def _get_file_name(message: types.Message) -> str | None: + content = message.content + for attr in ("document", "video", "audio", "animation"): + media = getattr(content, attr, None) + if media and hasattr(media, "file_name") and media.file_name: + return media.file_name + return None + + +def _get_mime_type(message: types.Message) -> str | None: + content = message.content + for attr in ("document", "video", "audio", "animation", "voice_note"): + media = getattr(content, attr, None) + if media and hasattr(media, "mime_type") and media.mime_type: + return media.mime_type + return None + + +def _get_file_size(message: types.Message) -> int: + media_file = _get_media_file(message) + return media_file.size if media_file else 0 + + +def _get_file_unique_id(message: types.Message) -> str | None: + return getattr(message, "remote_unique_file_id", None) + + +def _get_file_id(message: types.Message) -> str | None: + return getattr(message, "remote_file_id", None) + + +def _get_file_int_id(message: types.Message) -> int | None: + media_file = _get_media_file(message) + return media_file.id if media_file else None + + +def _infer_mime_from_content_type(content_type: str) -> str | None: + """Fallback MIME type lookup when TDLib doesn't provide one.""" + _CONTENT_MIME_MAP = { + "document": "application/octet-stream", + "video": "video/mp4", + "audio": "audio/mpeg", + "voicenote": "audio/ogg", + "animation": "video/mp4", + "videonote": "video/mp4", + "sticker": "image/webp", + "photo": "image/jpeg", + } + return _CONTENT_MIME_MAP.get(content_type) + + +def _get_extension_for_content_type(content_type: str) -> str: + """Fallback file extension when no file_name is available.""" + _EXT_MAP = { + "document": ".bin", + "video": ".mp4", + "audio": ".mp3", + "voicenote": ".ogg", + "animation": ".mp4", + "videonote": ".mp4", + "sticker": ".webp", + "photo": ".jpg", + } + return _EXT_MAP.get(content_type, ".bin") diff --git a/Thunder/utils/metrics.py b/Thunder/utils/metrics.py new file mode 100644 index 00000000..e7d818f0 --- /dev/null +++ b/Thunder/utils/metrics.py @@ -0,0 +1,67 @@ +import time +from collections import defaultdict + +_start_time = time.time() +_request_counts: dict[str, int] = defaultdict(int) +_error_counts: dict[int, int] = defaultdict(int) +_MAX_REQUEST_KEYS = 1000 +_MAX_ERROR_KEYS = 100 +_active_streams: int = 0 +_total_bytes_served: int = 0 + + +def record_request(path: str, status: int) -> None: + if len(_request_counts) >= _MAX_REQUEST_KEYS and path not in _request_counts: + min_key = min(_request_counts, key=_request_counts.get) + del _request_counts[min_key] + _request_counts[path] += 1 + if status >= 400: + if len(_error_counts) >= _MAX_ERROR_KEYS and status not in _error_counts: + min_key = min(_error_counts, key=_error_counts.get) + del _error_counts[min_key] + _error_counts[status] += 1 + + +def inc_active_streams() -> None: + global _active_streams + _active_streams += 1 + + +def dec_active_streams() -> None: + global _active_streams + _active_streams = max(0, _active_streams - 1) + + +def add_bytes_served(n: int) -> None: + global _total_bytes_served + _total_bytes_served += n + + +def get_metrics_text() -> str: + uptime = time.time() - _start_time + lines = [ + "# HELP thunder_uptime_seconds Time since bot started", + "# TYPE thunder_uptime_seconds gauge", + f"thunder_uptime_seconds {uptime:.0f}", + "", + "# HELP thunder_active_streams Currently active file streams", + "# TYPE thunder_active_streams gauge", + f"thunder_active_streams {_active_streams}", + "", + "# HELP thunder_bytes_served_total Total bytes served via streaming", + "# TYPE thunder_bytes_served_total counter", + f"thunder_bytes_served_total {_total_bytes_served}", + "", + "# HELP thunder_requests_total Total HTTP requests by path", + "# TYPE thunder_requests_total counter", + ] + for path, count in sorted(_request_counts.items()): + lines.append(f'thunder_requests_total{{path="{path}"}} {count}') + + lines.append("") + lines.append("# HELP thunder_errors_total Total HTTP errors by status code") + lines.append("# TYPE thunder_errors_total counter") + for status, count in sorted(_error_counts.items()): + lines.append(f"thunder_errors_total{{status=\"{status}\"}} {count}") + + return "\n".join(lines) + "\n" diff --git a/Thunder/utils/rate_limiter.py b/Thunder/utils/rate_limiter.py index e7ca1176..aa3d3056 100644 --- a/Thunder/utils/rate_limiter.py +++ b/Thunder/utils/rate_limiter.py @@ -1,437 +1,525 @@ -# Thunder/utils/rate_limiter.py - -import time -import math -import asyncio -from collections import deque -from typing import Callable, Dict, Optional, Tuple -from pyrogram import Client -from pyrogram.types import Message -from pyrogram.errors import FloodWait, RPCError -from Thunder.utils.logger import logger -from Thunder.utils.database import db -from Thunder.utils.messages import ( - MSG_RATE_LIMIT_QUEUE_PRIORITY, - MSG_RATE_LIMIT_QUEUE_REGULAR, - MSG_RATE_LIMIT_QUEUE_FULL -) -from Thunder.vars import Var - - -class QueueFullError(Exception): - pass - - -class RateLimiter: - def __init__(self): - self.request_queue: deque = deque() - self.priority_queue: deque = deque() - self.user_queue_counts: Dict[int, int] = {} - - self.request_event: asyncio.Event = asyncio.Event() - self.request_lock: asyncio.Lock = asyncio.Lock() - - self.user_requests: Dict[int, deque] = {} - self.global_requests: deque = deque() - - self.processing_times: deque = deque(maxlen=100) - self.file_processing_times: Dict[str, deque] = {} - self.average_processing_time: float = 1.0 - - self.auth_cache: Dict[int, Tuple[bool, float]] = {} - self.auth_cache_ttl_seconds: int = 300 - - self._initialization_error = False - self._load_configuration() - - def _load_configuration(self): - try: - self.max_requests_per_period = Var.MAX_FILES_PER_PERIOD - self.rate_limit_period_seconds = Var.RATE_LIMIT_PERIOD_MINUTES * 60 - self.max_queue_size = Var.MAX_QUEUE_SIZE - self.enabled = Var.RATE_LIMIT_ENABLED - self.global_rate_limit_enabled = Var.GLOBAL_RATE_LIMIT - self.max_global_requests_per_minute = Var.MAX_GLOBAL_REQUESTS_PER_MINUTE - - if not self._validate_configuration(): - logger.warning("Rate limiter disabled due to invalid configuration.") - self.enabled = False - else: - logger.debug(f"Rate limiter initialized: enabled={self.enabled}, " - f"max_requests={self.max_requests_per_period}, " - f"period={self.rate_limit_period_seconds}s, " - f"queue_size={self.max_queue_size}, " - f"global_enabled={self.global_rate_limit_enabled}, " - f"max_global_requests={self.max_global_requests_per_minute}") - except Exception as e: - logger.critical(f"Critical error initializing rate limiter, using safe defaults: {e}", exc_info=True) - self.max_requests_per_period = 5 - self.rate_limit_period_seconds = 60 - self.max_queue_size = 100 - self.enabled = False - self.global_rate_limit_enabled = False - self.max_global_requests_per_minute = 60 - self._initialization_error = True - - def _validate_configuration(self) -> bool: - is_valid = True - if self.max_requests_per_period <= 0: - logger.error("Invalid MAX_FILES_PER_PERIOD: must be > 0.") - is_valid = False - if self.rate_limit_period_seconds <= 0: - logger.error("Invalid RATE_LIMIT_PERIOD_MINUTES: must be > 0.") - is_valid = False - if self.max_queue_size <= 0: - logger.error("Invalid MAX_QUEUE_SIZE: must be > 0.") - is_valid = False - if self.global_rate_limit_enabled and self.max_global_requests_per_minute <= 0: - logger.error("Invalid MAX_GLOBAL_REQUESTS_PER_MINUTE: must be > 0 when global rate limit is enabled.") - is_valid = False - return is_valid - - def is_owner(self, user_id: int) -> bool: - return user_id == Var.OWNER_ID - - async def is_authorized_user(self, user_id: int) -> bool: - current_time = time.time() - if user_id in self.auth_cache: - is_auth, timestamp = self.auth_cache[user_id] - if current_time - timestamp < self.auth_cache_ttl_seconds: - return is_auth - - try: - authorized_user = await db.authorized_users_col.find_one({"user_id": user_id}) - is_auth = bool(authorized_user) - self.auth_cache[user_id] = (is_auth, current_time) - return is_auth - except Exception as e: - logger.error(f"Database error checking authorized user {user_id}: {e}") - return False - - async def get_user_priority(self, user_id: int) -> str: - if self.is_owner(user_id): - return 'owner' - if await self.is_authorized_user(user_id): - return 'authorized' - return 'regular' - - async def check_limits(self, user_id: int, record: bool = True) -> bool: - if not self.enabled or self._initialization_error or self.is_owner(user_id): - return True - - current_time = time.time() - - if self.global_rate_limit_enabled: - while self.global_requests and self.global_requests[0] <= current_time - 60: - self.global_requests.popleft() - if len(self.global_requests) >= self.max_global_requests_per_minute: - return False - - user_timestamps = self.user_requests.setdefault(user_id, deque()) - while user_timestamps and user_timestamps[0] <= current_time - self.rate_limit_period_seconds: - user_timestamps.popleft() - if len(user_timestamps) >= self.max_requests_per_period: - return False - - if record: - if self.global_rate_limit_enabled: - self.global_requests.append(current_time) - user_timestamps.append(current_time) - return True - - async def _requeue_request(self, request_data: dict, queue_type: str): - async with self.request_lock: - if queue_type == "priority": - self.priority_queue.appendleft(request_data) - else: - self.request_queue.appendleft(request_data) - self.request_event.set() - logger.debug(f"Re-queued request for user {request_data['user_id']} to {queue_type} queue.") - - async def add_to_queue(self, func: Callable, user_id: int, file_identifier: Optional[str] = None, *args, **kwargs): - if not self.enabled: - await func(*args, **kwargs) - return - - request_data = { - 'func': func, 'user_id': user_id, 'args': args, 'kwargs': kwargs, - 'timestamp': time.time(), 'user_priority': await self.get_user_priority(user_id), - 'file_identifier': file_identifier - } - - async with self.request_lock: - total_queued = len(self.request_queue) + len(self.priority_queue) - if total_queued >= self.max_queue_size: - raise QueueFullError("Queue is full") - - if request_data['user_priority'] == 'authorized': - self.priority_queue.append(request_data) - queue_name = "priority" - else: - self.request_queue.append(request_data) - queue_name = "regular" - - self.user_queue_counts[user_id] = self.user_queue_counts.get(user_id, 0) + 1 - logger.debug(f"Added request for user {user_id} to {queue_name} queue. Total queued: {total_queued + 1}") - self.request_event.set() - - async def request_executor(self): - logger.debug("Request executor started.") - while True: - try: - await self.request_event.wait() - - async with self.request_lock: - queue, queue_type = (self.priority_queue, "priority") if self.priority_queue else (self.request_queue, "regular") - if not queue: - self.request_event.clear() - continue - request_data = queue.popleft() - - user_id = request_data['user_id'] - processed = False - if not self.is_owner(user_id): - if not await self.check_limits(user_id, record=True): - await self._requeue_request(request_data, queue_type) - await asyncio.sleep(0.5) - continue - - logger.debug(f"Processing request for user {user_id} from {queue_type} queue.") - start_time = time.time() - try: - await request_data['func'](*request_data['args'], **request_data['kwargs']) - processing_time = time.time() - start_time - self.processing_times.append(processing_time) - if self.processing_times: - self.average_processing_time = sum(self.processing_times) / len(self.processing_times) - - file_identifier = request_data.get('file_identifier') - if file_identifier: - file_times = self.file_processing_times.setdefault(file_identifier, deque(maxlen=100)) - file_times.append(processing_time) - - processed = True - - except FloodWait as e: - logger.warning(f"FloodWait for user {user_id}, waiting {e.value}s before re-queuing.") - await asyncio.sleep(e.value) - await self._requeue_request(request_data, queue_type) - except Exception as e: - logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) - processed = True - finally: - async with self.request_lock: - if processed and user_id in self.user_queue_counts: - self.user_queue_counts[user_id] -= 1 - if self.user_queue_counts[user_id] <= 0: - self.user_queue_counts.pop(user_id, None) - - except asyncio.CancelledError: - logger.debug("Request executor cancelled, shutting down.") - break - except Exception as e: - logger.critical(f"Critical error in request executor: {e}", exc_info=True) - await asyncio.sleep(5) - - async def shutdown(self): - logger.debug("Shutting down rate limiter and clearing queues...") - async with self.request_lock: - self.request_queue.clear() - self.priority_queue.clear() - self.user_queue_counts.clear() - self.request_event.clear() - logger.debug("Rate limiter queues cleared.") - - def get_queue_status(self) -> dict: - return { - 'regular_queue_size': len(self.request_queue), - 'priority_queue_size': len(self.priority_queue), - 'total_queued': len(self.request_queue) + len(self.priority_queue), - 'max_queue_size': self.max_queue_size, - 'active_users_in_queue': len(self.user_queue_counts), - 'enabled': self.enabled, - } - - async def get_user_queue_position(self, user_id: int) -> dict: - user_priority = await self.get_user_priority(user_id) - position = -1 - queue_to_search = self.priority_queue if user_priority == 'authorized' else self.request_queue - - for idx, req in enumerate(queue_to_search): - if req.get('user_id') == user_id: - position = idx + 1 - break - - effective_position = position - if user_priority == 'regular' and position > -1: - effective_position += len(self.priority_queue) - - return { - 'user_priority': user_priority, - 'position_in_own_queue': position if position > -1 else None, - 'effective_position': effective_position if effective_position > -1 else None, - 'priority_queue_size': len(self.priority_queue), - 'regular_queue_size': len(self.request_queue), - 'bypasses_rate_limit': user_priority == 'owner' - } - - def _get_base_processing_time(self, file_identifier: Optional[str]) -> float: - if file_identifier and file_identifier in self.file_processing_times: - file_times = self.file_processing_times[file_identifier] - if file_times: - return sum(file_times) / len(file_times) - return self.average_processing_time - - async def _calculate_queue_wait(self, user_id: int, effective_processing_time: float) -> float: - pos_info = await self.get_user_queue_position(user_id) - items_ahead = (pos_info['effective_position'] - 1) if pos_info['effective_position'] else 0 - return items_ahead * effective_processing_time - - def _calculate_user_rate_limit_wait(self, user_id: int, future_time: float) -> float: - user_timestamps = self.user_requests.get(user_id, deque()) - future_user_timestamps = deque(ts for ts in user_timestamps if ts > future_time - self.rate_limit_period_seconds) - - if len(future_user_timestamps) >= self.max_requests_per_period: - reset_time = future_user_timestamps[0] + self.rate_limit_period_seconds - return max(0.0, reset_time - future_time) - return 0.0 - - def _calculate_global_rate_limit_wait(self, future_time: float) -> float: - if not self.global_rate_limit_enabled: - return 0.0 - - future_global_requests = deque(ts for ts in self.global_requests if ts > future_time - 60) - - if len(future_global_requests) >= self.max_global_requests_per_minute: - oldest_request_time = future_global_requests[0] - reset_time = oldest_request_time + 60 - return max(0.0, reset_time - future_time) - return 0.0 - - async def estimate_wait_time(self, user_id: int, file_identifier: Optional[str] = None) -> float: - if self.is_owner(user_id): - return 0.0 - - base_processing_time = self._get_base_processing_time(file_identifier) - min_time_per_request = self.rate_limit_period_seconds / self.max_requests_per_period if self.max_requests_per_period > 0 else 0 - effective_processing_time = max(base_processing_time, min_time_per_request) - - if self.global_rate_limit_enabled and self.max_global_requests_per_minute > 0: - min_time_per_global = 60 / self.max_global_requests_per_minute - effective_processing_time = max(effective_processing_time, min_time_per_global) - - queue_wait = await self._calculate_queue_wait(user_id, effective_processing_time) - future_time = time.time() + queue_wait - - rate_limit_wait = self._calculate_user_rate_limit_wait(user_id, future_time) - global_wait = self._calculate_global_rate_limit_wait(future_time) - - return queue_wait + rate_limit_wait + global_wait - - -rate_limiter = RateLimiter() - - -async def request_executor(): - await rate_limiter.request_executor() - - -async def handle_rate_limited_request(bot: Client, message: Message, handler: Callable, *args, **kwargs): - rl_user_id = kwargs.pop('rl_user_id', None) - user_id = rl_user_id if rl_user_id is not None else (message.from_user.id if message and message.from_user else None) - if not isinstance(user_id, int): - logger.error(f"Invalid user_id provided for rate limiting: {user_id}") - return - - file_identifier = message.document.file_unique_id if message and message.document else None - - if rate_limiter.is_owner(user_id): - logger.debug(f"Owner {user_id} bypassing rate limit.") - await handler(bot, message, *args, **kwargs) - return - - if await rate_limiter.check_limits(user_id, record=True): - logger.debug(f"User {user_id} within rate limits, executing immediately.") - await handler(bot, message, *args, **kwargs) - return - - is_channel = rl_user_id is not None and rl_user_id < 0 - - if not is_channel: - try: - user_priority = await rate_limiter.get_user_priority(user_id) - notification_msg = await send_queue_notification( - bot, message, is_priority=(user_priority == 'authorized'), file_identifier=file_identifier - ) - kwargs['notification_msg'] = notification_msg - except Exception as e: - logger.error(f"Error sending queue notification for user {user_id}: {e}", exc_info=True) - - try: - await rate_limiter.add_to_queue(handler, user_id, file_identifier, bot, message, *args, **kwargs) - logger.debug(f"Request for user {user_id} queued.") - except QueueFullError: - logger.warning(f"Queue full, request for user {user_id} rejected.") - if not is_channel: - await send_queue_full_message(bot, message, file_identifier) - except Exception as e: - logger.error(f"Error adding request to queue for user {user_id}: {e}", exc_info=True) - if not is_channel: - await send_queue_full_message(bot, message, file_identifier) - - -async def _send_notification(bot: Client, message: Message, template: str, file_identifier: Optional[str], **format_kwargs): - try: - if message.from_user: - user_id = message.from_user.id - wait_seconds = await rate_limiter.estimate_wait_time(user_id, file_identifier) - wait_estimate = max(1, math.ceil(wait_seconds / 60)) - - text = template.format(wait_estimate=wait_estimate, s="s" if wait_estimate > 1 else "", **format_kwargs) - - try: - return await bot.send_message( - chat_id=message.chat.id, - text=text, - reply_to_message_id=message.id - ) - except FloodWait as e: - await asyncio.sleep(e.value) - return await bot.send_message( - chat_id=message.chat.id, - text=text, - reply_to_message_id=message.id - ) - else: - logger.debug("Skipping notification for channel message (no from_user)") - return None - except (FloodWait, RPCError) as e: - user_id = message.from_user.id if message.from_user else "channel" - logger.warning(f"Error sending notification to user {user_id}: {e}") - except Exception as e: - logger.error(f"Unexpected error sending notification: {e}", exc_info=True) - return None - - -async def send_queue_notification(bot: Client, message: Message, is_priority: bool, file_identifier: Optional[str]): - if is_priority: - template = MSG_RATE_LIMIT_QUEUE_PRIORITY - params = {} - else: - template = MSG_RATE_LIMIT_QUEUE_REGULAR - time_window = rate_limiter.rate_limit_period_seconds // 60 - params = { - "max_requests": rate_limiter.max_requests_per_period, - "time_window": time_window, - "s1": "s" if rate_limiter.max_requests_per_period > 1 else "", - "s2": "s" if time_window > 1 else "" - } - user_id = message.from_user.id if message.from_user else "channel" - logger.debug(f"Sending {'priority' if is_priority else 'regular'} queue notification to user {user_id}") - return await _send_notification(bot, message, template, file_identifier, **params) - - -async def send_queue_full_message(bot: Client, message: Message, file_identifier: Optional[str]): - user_id = message.from_user.id if message.from_user else "channel" - logger.debug(f"Sending queue full message to user {user_id}") - await _send_notification(bot, message, MSG_RATE_LIMIT_QUEUE_FULL, file_identifier) +# Thunder/utils/rate_limiter.py + +import asyncio +import math +import time +from collections import deque +from collections.abc import Callable + +import pytdbot +from pytdbot import types + +from Thunder.utils.compat import _get_file_unique_id +from Thunder.utils.database import db +from Thunder.utils.logger import logger +from Thunder.utils.messages import ( + MSG_RATE_LIMIT_QUEUE_FULL, + MSG_RATE_LIMIT_QUEUE_PRIORITY, + MSG_RATE_LIMIT_QUEUE_REGULAR, +) +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._request_counter: int = 0 + self._requeue_counts: dict[int, int] = {} + self._max_requeues: int = 5 + self._base_requeue_delay: float = 0.5 + self._last_cleanup: float = time.time() + self._cleanup_interval: float = 300.0 + + 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): + request_id = request_data.get('request_id', id(request_data)) + requeue_count = self._requeue_counts.get(request_id, 0) + user_id = request_data['user_id'] + + if requeue_count >= self._max_requeues: + logger.warning(f"Max requeues ({self._max_requeues}) reached for user {user_id}, request {request_id}, dropping.") + self._requeue_counts.pop(request_id, None) + async with self.request_lock: + if user_id in self.user_queue_counts: + self.user_queue_counts[user_id] -= 1 + if self.user_queue_counts[user_id] <= 0: + self.user_queue_counts.pop(user_id, None) + return + + self._requeue_counts[request_id] = requeue_count + 1 + delay = self._base_requeue_delay * (2 ** requeue_count) + 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 {user_id} to {queue_type} queue (attempt {requeue_count + 1}, delay {delay:.1f}s).") + request_data['not_before'] = time.time() + delay + + def _cleanup_stale_entries(self): + now = time.time() + if now - self._last_cleanup < self._cleanup_interval: + return + self._last_cleanup = now + + stale_users = [ + uid for uid, timestamps in self.user_requests.items() + if not timestamps + ] + for uid in stale_users: + del self.user_requests[uid] + + stale_auth = [ + uid for uid, (_, ts) in self.auth_cache.items() + if now - ts > self.auth_cache_ttl_seconds + ] + for uid in stale_auth: + del self.auth_cache[uid] + + stale_files = [ + fid for fid, times in self.file_processing_times.items() + if not times + ] + for fid in stale_files: + del self.file_processing_times[fid] + + if stale_users or stale_auth or stale_files: + logger.debug(f"Cleaned up stale rate limiter entries: {len(stale_users)} users, {len(stale_auth)} auth, {len(stale_files)} files.") + + async def add_to_queue(self, func: Callable, user_id: int, file_identifier: str | None = None, *args, **kwargs): + if not self.enabled: + await func(*args, **kwargs) + return + + self._request_counter += 1 + request_data = { + 'request_id': self._request_counter, + '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() + + self._cleanup_stale_entries() + + 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 + + # Find the first ready request (not_before has passed) to avoid head-of-line blocking + now = time.time() + ready_idx = None + earliest_not_before = None + for i, req in enumerate(queue): + nb = req.get('not_before', 0) + if not nb or now >= nb: + ready_idx = i + break + if earliest_not_before is None or nb < earliest_not_before: + earliest_not_before = nb + + if ready_idx is not None: + request_data = queue[ready_idx] + del queue[ready_idx] + else: + # No ready requests — wait until the earliest not_before + self.request_event.clear() + sleep_time = max(0.1, (earliest_not_before or now) - now) + try: + await asyncio.wait_for(self.request_event.wait(), timeout=sleep_time) + except asyncio.TimeoutError: + pass + self.request_event.set() + continue + + user_id = request_data['user_id'] + request_id = request_data.get('request_id', id(request_data)) + 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) + 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 + self._requeue_counts.pop(request_id, None) + + except Exception as e: + logger.error(f"Error processing queued request for user {user_id}: {e}", exc_info=True) + processed = True + self._requeue_counts.pop(request_id, None) + 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: 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() + + +async def request_executor(): + await rate_limiter.request_executor() + + +async def handle_rate_limited_request(bot: pytdbot.Client, message: types.Message, handler: Callable, *args, **kwargs): + rl_user_id = kwargs.pop('rl_user_id', None) + from_id = getattr(message, "from_id", None) + user_id = rl_user_id if rl_user_id is not None else (from_id if from_id else None) + if not isinstance(user_id, int): + logger.error(f"Invalid user_id provided for rate limiting: {user_id}") + return + + file_identifier = _get_file_unique_id(message) if message 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: pytdbot.Client, message: types.Message, template: str, file_identifier: str | None, **format_kwargs): + try: + from_id = getattr(message, "from_id", None) + if not from_id: + logger.debug("Skipping notification for channel message (no from_id)") + return None + + wait_seconds = await rate_limiter.estimate_wait_time(from_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) + + for attempt in range(3): + result = await bot.sendTextMessage( + chat_id=message.chat_id, + text=text, + reply_to_message_id=message.id + ) + if not isinstance(result, types.Error): + return result + + if hasattr(result, "code") and result.code == 429: + retry_after = getattr(result, "retry_after", 5) + logger.warning(f"FloodWait on notification for user {from_id}, retrying in {retry_after}s (attempt {attempt + 1})") + await asyncio.sleep(retry_after) + else: + logger.warning(f"Error sending notification to user {from_id}: {result}") + return None + + logger.warning(f"Failed to send notification to user {from_id} after 3 attempts") + return None + except Exception as e: + from_id = getattr(message, "from_id", "channel") + logger.warning(f"Error sending notification to user {from_id}: {e}") + return None + + +async def send_queue_notification(bot: pytdbot.Client, message: types.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 "" + } + from_id = getattr(message, "from_id", "channel") + logger.debug(f"Sending {'priority' if is_priority else 'regular'} queue notification to user {from_id}") + return await _send_notification(bot, message, template, file_identifier, **params) + + +async def send_queue_full_message(bot: pytdbot.Client, message: types.Message, file_identifier: str | None): + from_id = getattr(message, "from_id", "channel") + logger.debug(f"Sending queue full message to user {from_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 5c6ef1a1..1f606f16 100644 --- a/Thunder/utils/render_template.py +++ b/Thunder/utils/render_template.py @@ -1,14 +1,12 @@ -# Thunder/utils/render_template.py - -import asyncio import urllib.parse from jinja2 import Environment, FileSystemLoader, select_autoescape -from pyrogram.errors import FloodWait +from pytdbot import types from Thunder.bot import StreamBot -from Thunder.server.exceptions import InvalidHash -from Thunder.utils.file_properties import get_fname, get_uniqid +from Thunder.server.exceptions import InvalidHash, RateLimited +from Thunder.utils.bot_utils import quote_media_name +from Thunder.utils.compat import _get_file_name, _get_file_unique_id from Thunder.utils.logger import logger from Thunder.vars import Var @@ -21,8 +19,8 @@ optimized=True ) + async def render_media_page(file_name: str, src: str, requested_action: str | None = None) -> str: - # NOTE: src must be a pre-encoded URL. Templates use |safe to avoid double-encoding. if requested_action == 'stream': template = template_env.get_template('req.html') context = { @@ -41,22 +39,25 @@ async def render_media_page(file_name: str, src: str, requested_action: str | No async def render_page(message_id: int, secure_hash: str, requested_action: str | None = None) -> str: try: - try: - message = await StreamBot.get_messages(chat_id=int(Var.BIN_CHANNEL), message_ids=message_id) - except FloodWait as e: - await asyncio.sleep(e.value) - message = await StreamBot.get_messages(chat_id=int(Var.BIN_CHANNEL), message_ids=message_id) + result = await StreamBot.getMessage( + chat_id=int(Var.BIN_CHANNEL), message_id=message_id + ) + if isinstance(result, types.Error): + if hasattr(result, "code") and result.code == 429: + raise RateLimited(f"Rate limited: {result.message}") + raise InvalidHash(f"Message not found: {result.message}") + message = result if not message: raise InvalidHash("Message not found") - file_unique_id = get_uniqid(message) - file_name = get_fname(message) + file_unique_id = _get_file_unique_id(message) + file_name = _get_file_name(message) or "file" 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="") + quoted_filename = quote_media_name(file_name) src = urllib.parse.urljoin(Var.URL, f'{secure_hash}{message_id}/{quoted_filename}') return await render_media_page(file_name, src, requested_action) except Exception as e: diff --git a/Thunder/utils/shortener.py b/Thunder/utils/shortener.py index e3d217db..4238b08a 100644 --- a/Thunder/utils/shortener.py +++ b/Thunder/utils/shortener.py @@ -1,146 +1,163 @@ -# Thunder/utils/shortener.py - -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 Thunder.utils.logger import logger - -class ShortenerPlugin(ABC): - @classmethod - @abstractmethod - def matches(cls, domain: str) -> bool: - pass - - @abstractmethod - async def shorten(self, url: str, api_key: str) -> str: - pass - -class LinkvertisePlugin(ShortenerPlugin): - @classmethod - def matches(cls, domain: str) -> bool: - return "linkvertise" in domain - - async def shorten(self, url: str, api_key: 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}", - ]) - -class BitlyPlugin(ShortenerPlugin): - @classmethod - def matches(cls, domain: str) -> bool: - return "bitly.com" in domain - - async def shorten(self, url: str, api_key: str) -> str: - response = self.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"] - 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 = self.session.get(f"http://ouo.io/api/{api_key}?s={url}") - if response.status_code == 200 and response.text: - return response.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 = 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 url - -class GenericShortenerPlugin(ShortenerPlugin): - @classmethod - def matches(cls, domain: str) -> bool: - return True - - async def shorten(self, url: str, api_key: str) -> str: - response = 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) - return url - -class ShortenerSystem: - def __init__(self): - self.session = None - self.plugin = None - self.ready = False - - def _get_plugin_class(self, domain: str): - for plugin_class in ShortenerPlugin.__subclasses__(): - if plugin_class.matches(domain): - return plugin_class - - return GenericShortenerPlugin - - async def initialize(self) -> bool: - if self.ready: - return True - - if not (getattr(Var, "SHORTEN_ENABLED", False) or - getattr(Var, "SHORTEN_MEDIA_LINKS", False)): - return False - - site = getattr(Var, "URL_SHORTENER_SITE", "") - api_key = getattr(Var, "URL_SHORTENER_API_KEY", "") - - if not (site and api_key): - return False - - try: - self.session = cloudscraper.create_scraper( - browser={ - 'browser': 'chrome', - 'platform': 'windows', - 'desktop': True, - 'mobile': False - }, - delay=1 - ) - - 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 - except Exception as e: - logger.error(f"Failed to initialize ShortenerSystem: {e}", exc_info=True) - return False - - async def short_url(self, url: str) -> str: - if not self.ready: - return url - - 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 - -_system = ShortenerSystem() - -async def shorten(url: str) -> str: - if not _system.ready: - await _system.initialize() - return await _system.short_url(url) +import asyncio +from abc import ABC, abstractmethod +from base64 import b64encode +from random import choice, random +from urllib.parse import quote + +import cloudscraper + +from Thunder.utils.logger import logger +from Thunder.vars import Var + + +class ShortenerPlugin(ABC): + def __init__(self): + self.session = None + self.domain = "" + + @classmethod + @abstractmethod + def matches(cls, domain: str) -> bool: + pass + + @abstractmethod + async def shorten(self, url: str, api_key: str) -> str: + pass + + +class LinkvertisePlugin(ShortenerPlugin): + @classmethod + def matches(cls, domain: str) -> bool: + return "linkvertise" in domain + + async def shorten(self, url: str, api_key: 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}", + ]) + + +class BitlyPlugin(ShortenerPlugin): + @classmethod + def matches(cls, domain: str) -> bool: + return "bitly.com" in domain + + async def shorten(self, url: str, api_key: str) -> str: + def _do_request(): + return self.session.post( + "https://api-ssl.bit.ly/v4/shorten", + json={"long_url": url}, + headers={"Authorization": f"Bearer {api_key}"} + ) + response = await asyncio.to_thread(_do_request) + if response.status_code == 200: + return response.json()["link"] + 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: + def _do_request(): + return self.session.get(f"http://ouo.io/api/{api_key}?s={url}") + response = await asyncio.to_thread(_do_request) + if response.status_code == 200 and response.text: + return response.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: + def _do_request(): + return self.session.get(f"http://cutt.ly/api/api.php?key={api_key}&short={url}") + response = await asyncio.to_thread(_do_request) + if response.status_code == 200: + return response.json()["url"]["shortLink"] + return url + + +class GenericShortenerPlugin(ShortenerPlugin): + @classmethod + def matches(cls, domain: str) -> bool: + return True + + async def shorten(self, url: str, api_key: str) -> str: + def _do_request(): + return self.session.get(f"https://{self.domain}/api?api={api_key}&url={quote(url)}") + response = await asyncio.to_thread(_do_request) + if response.status_code == 200: + return response.json().get("shortenedUrl", url) + return url + + +class ShortenerSystem: + def __init__(self): + self.session = None + self.plugin = None + self.ready = False + + def _get_plugin_class(self, domain: str): + for plugin_class in ShortenerPlugin.__subclasses__(): + if plugin_class.matches(domain): + return plugin_class + return GenericShortenerPlugin + + def _init_scraper(self): + self.session = cloudscraper.create_scraper( + browser={'browser': 'chrome', 'platform': 'windows', 'desktop': True, 'mobile': False}, + delay=1 + ) + + async def initialize(self) -> bool: + if self.ready: + return True + + if not (getattr(Var, "SHORTEN_ENABLED", False) or + getattr(Var, "SHORTEN_MEDIA_LINKS", False)): + return False + + site = getattr(Var, "URL_SHORTENER_SITE", "") + api_key = getattr(Var, "URL_SHORTENER_API_KEY", "") + + if not (site and api_key): + return False + + try: + await asyncio.to_thread(self._init_scraper) + 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 + except Exception as e: + logger.error(f"Failed to initialize ShortenerSystem: {e}", exc_info=True) + return False + + async def short_url(self, url: str) -> str: + if not self.ready: + return url + 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 + + +_system = ShortenerSystem() + + +async def shorten(url: str) -> str: + if not _system.ready: + await _system.initialize() + return await _system.short_url(url) diff --git a/Thunder/utils/speedtest.py b/Thunder/utils/speedtest.py index bdd94cc3..7e7d819d 100644 --- a/Thunder/utils/speedtest.py +++ b/Thunder/utils/speedtest.py @@ -1,13 +1,14 @@ # Thunder/utils/speedtest.py import asyncio -from typing import Optional, Tuple, Dict, Any +from typing import Any import speedtest + from Thunder.utils.logger import logger -async def run_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: +async def run_speedtest() -> tuple[dict[str, Any] | None, str | None]: try: return await asyncio.to_thread(_perform_speedtest) except Exception as e: @@ -15,29 +16,30 @@ async def run_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: return None, None -def _perform_speedtest() -> Tuple[Optional[Dict[str, Any]], Optional[str]]: +def _perform_speedtest() -> tuple[dict[str, Any] | None, str | None]: 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: + logger.debug("Failed to generate speedtest share link") return results, None - + except Exception as e: logger.error(f"Speedtest failed: {e}") return None, None diff --git a/Thunder/utils/telegram_helpers.py b/Thunder/utils/telegram_helpers.py new file mode 100644 index 00000000..745aefb9 --- /dev/null +++ b/Thunder/utils/telegram_helpers.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any + +from pytdbot import types + + +def is_error(result: Any) -> bool: + return isinstance(result, types.Error) + + +class ChatMemberStatus: + ADMINISTRATOR = "administrator" + OWNER = "creator" + MEMBER = "member" + LEFT = "left" + BANNED = "banned" + RESTRICTED = "restricted" + + +def get_member_status(chat_member: types.ChatMember) -> str: + status = chat_member.status + if isinstance(status, types.ChatMemberStatusCreator): + return ChatMemberStatus.OWNER + if isinstance(status, types.ChatMemberStatusAdministrator): + return ChatMemberStatus.ADMINISTRATOR + if isinstance(status, types.ChatMemberStatusMember): + return ChatMemberStatus.MEMBER + if isinstance(status, types.ChatMemberStatusLeft): + return ChatMemberStatus.LEFT + if isinstance(status, types.ChatMemberStatusBanned): + return ChatMemberStatus.BANNED + if isinstance(status, types.ChatMemberStatusRestricted): + return ChatMemberStatus.RESTRICTED + return "unknown" diff --git a/Thunder/utils/tokens.py b/Thunder/utils/tokens.py index 76026804..607b133b 100644 --- a/Thunder/utils/tokens.py +++ b/Thunder/utils/tokens.py @@ -1,14 +1,15 @@ # 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 +import secrets +from datetime import UTC, datetime, timedelta +from typing import Any + from Thunder.utils.database import db -from Thunder.vars import Var from Thunder.utils.logger import logger +from Thunder.vars import Var + async def check(user_id: int) -> bool: try: @@ -19,7 +20,7 @@ async def check(user_id: int) -> bool: if user_id == Var.OWNER_ID: logger.debug("Owner access granted") return True - current_time = datetime.utcnow() + current_time = datetime.now(UTC) auth_result = await db.authorized_users_col.find_one( {"user_id": user_id}, {"_id": 1} @@ -41,7 +42,7 @@ async def generate(user_id: int) -> str: try: logger.debug(f"Token generation started for user: {user_id}") existing_token_doc = await db.token_col.find_one( - {"user_id": user_id, "activated": False, "expires_at": {"$gt": datetime.utcnow()}}, + {"user_id": user_id, "activated": False, "expires_at": {"$gt": datetime.now(UTC)}}, {"token": 1} ) if existing_token_doc: @@ -55,7 +56,7 @@ async def generate(user_id: int) -> str: for attempt in range(max_retries): try: ttl_hours = getattr(Var, "TOKEN_TTL_HOURS", 24) - created_at = datetime.utcnow() + created_at = datetime.now(UTC) expires_at = created_at + timedelta(hours=ttl_hours) await db.save_main_token( user_id=user_id, @@ -66,9 +67,6 @@ async def generate(user_id: int) -> str: ) logger.debug(f"New token generated and saved successfully for user: {user_id}") return token_str - except pyrogram.errors.RPCError as e: - logger.error(f"Telegram API error while generating new token for user {user_id}: {e}", exc_info=True) - raise except Exception as e: if attempt < max_retries - 1: delay = base_delay * (2 ** attempt) + random.uniform(0, 0.1) @@ -98,7 +96,7 @@ async def authorize(user_id: int, authorized_by: int) -> bool: auth_data = { "user_id": user_id, "authorized_by": authorized_by, - "authorized_at": datetime.utcnow() + "authorized_at": datetime.now(UTC) } await db.authorized_users_col.update_one( {"user_id": user_id}, @@ -118,14 +116,14 @@ async def deauthorize(user_id: int) -> bool: 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]]: +async def get_user(user_id: int) -> dict[str, Any] | None: try: return await db.token_col.find_one({"user_id": user_id}) except Exception as e: logger.error(f"Error in get_user for user {user_id}: {e}", exc_info=True) return None -async def list_allowed() -> List[Dict[str, Any]]: +async def list_allowed() -> list[dict[str, Any]]: try: cursor = db.authorized_users_col.find( {}, @@ -136,9 +134,9 @@ async def list_allowed() -> List[Dict[str, Any]]: logger.error(f"Error in list_allowed: {e}", exc_info=True) return [] -async def list_tokens() -> List[Dict[str, Any]]: +async def list_tokens() -> list[dict[str, Any]]: try: - current_time = datetime.utcnow() + current_time = datetime.now(UTC) cursor = db.token_col.find( {"expires_at": {"$gt": current_time}}, {"user_id": 1, "expires_at": 1, "created_at": 1, "activated": 1} @@ -150,7 +148,7 @@ async def list_tokens() -> List[Dict[str, Any]]: async def cleanup_expired_tokens() -> int: try: - current_time = datetime.utcnow() + current_time = datetime.now(UTC) logger.debug("Cleaning up expired tokens") result = await db.token_col.delete_many({"expires_at": {"$lte": current_time}}) logger.debug(f"Cleaned up {result.deleted_count} expired tokens") diff --git a/Thunder/vars.py b/Thunder/vars.py index 75090ac1..7a3a7f41 100644 --- a/Thunder/vars.py +++ b/Thunder/vars.py @@ -3,7 +3,7 @@ import os from dotenv import load_dotenv -from typing import Set, Optional + from Thunder.utils.logger import logger load_dotenv("config.env") @@ -11,10 +11,10 @@ def str_to_bool(val: str) -> bool: return val.lower() in ("true", "1", "t", "y", "yes") -def str_to_int_set(val: str) -> Set[int]: +def str_to_int_set(val: str) -> set[int]: if not val: return set() - result: Set[int] = set() + result: set[int] = set() for x in val.split(): try: result.add(int(x)) @@ -23,9 +23,19 @@ def str_to_int_set(val: str) -> Set[int]: return result +def _safe_int(env_key: str, default: int) -> int: + raw = os.getenv(env_key) + if raw is None: + return default + try: + return int(raw) + except ValueError: + logger.warning(f"Non-numeric value '{raw}' for {env_key}, using default {default}") + return default + class Var: - API_ID: int = int(os.getenv("API_ID", "0")) + API_ID: int = _safe_int("API_ID", 0) API_HASH: str = os.getenv("API_HASH", "") BOT_TOKEN: str = os.getenv("BOT_TOKEN", "") @@ -34,21 +44,21 @@ class Var: 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")) + SLEEP_THRESHOLD: int = _safe_int("SLEEP_THRESHOLD", 600) + WORKERS: int = _safe_int("WORKERS", 8) - BIN_CHANNEL: int = int(os.getenv("BIN_CHANNEL", "0")) + BIN_CHANNEL: int = _safe_int("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")) + PORT: int = _safe_int("PORT", 8080) BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0") - PING_INTERVAL: int = int(os.getenv("PING_INTERVAL", "840")) + PING_INTERVAL: int = _safe_int("PING_INTERVAL", 840) NO_PORT: bool = str_to_bool(os.getenv("NO_PORT", "True")) - OWNER_ID: int = int(os.getenv("OWNER_ID", "0")) + OWNER_ID: int = _safe_int("OWNER_ID", 0) if not OWNER_ID: logger.warning("WARNING: OWNER_ID is not set. No user will be granted owner access.") @@ -67,15 +77,15 @@ class Var: logger.critical("DATABASE_URL is required") raise ValueError("DATABASE_URL is required") - MAX_BATCH_FILES: int = int(os.getenv("MAX_BATCH_FILES", "50")) + MAX_BATCH_FILES: int = _safe_int("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", "")) + BANNED_CHANNELS: set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", "")) MULTI_CLIENT: bool = False - FORCE_CHANNEL_ID: Optional[int] = None + FORCE_CHANNEL_ID: int | None = None force_channel_env = os.getenv("FORCE_CHANNEL_ID", "").strip() @@ -86,7 +96,7 @@ class Var: 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")) + TOKEN_TTL_HOURS: int = _safe_int("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")) @@ -94,9 +104,11 @@ class Var: 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")) + MAX_GLOBAL_REQUESTS_PER_MINUTE: int = _safe_int("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")) + MAX_FILES_PER_PERIOD: int = _safe_int("MAX_FILES_PER_PERIOD", 2) + RATE_LIMIT_PERIOD_MINUTES: int = _safe_int("RATE_LIMIT_PERIOD_MINUTES", 1) + MAX_QUEUE_SIZE: int = _safe_int("MAX_QUEUE_SIZE", 100) + + ADMIN_TOKEN: str = os.getenv("ADMIN_TOKEN", "") diff --git a/config_sample.env b/config_sample.env index b83b4343..11f5fcb5 100644 --- a/config_sample.env +++ b/config_sample.env @@ -98,6 +98,13 @@ RATE_LIMIT_PERIOD_MINUTES=1 # Maximum number of requests that can be queued. MAX_QUEUE_SIZE=100 +#################### +## SECURITY SETTINGS +#################### + +# Admin token for /metrics and /status endpoints (optional, leave empty for open access) +ADMIN_TOKEN="" # Example: "my-secret-token" + #################### ## UPDATE SETTINGS #################### diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..c189b639 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,56 @@ +[project] +name = "thunder-f2l" +version = "2.1.0" +description = "High-performance Telegram File-to-Link Bot for direct downloads and streaming" +readme = "README.md" +license = "Apache-2.0" +requires-python = ">=3.13" +authors = [{ name = "fyaz05" }] +homepage = "https://github.com/fyaz05/FileToLink" +repository = "https://github.com/fyaz05/FileToLink" + +dependencies = [ + "aiohttp>=3.9", + "cloudscraper>=1.2", + "jinja2>=3.1", + "pytdbot[tdjson]>=0.9", + "pymongo>=4.7", + "psutil>=5.9", + "python-dotenv>=1.0", + "speedtest-cli>=2.1", + "uvloop>=0.21,<0.22; sys_platform != 'win32'", + "aiofiles>=25.1.0", +] + +[project.scripts] +thunder = "Thunder.__main__:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[dependency-groups] +dev = ["pytest>=8.0", "ruff>=0.4", "pyright>=1.1"] + +[tool.hatch.build.targets.wheel] +packages = ["Thunder"] + +[tool.ruff] +target-version = "py313" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "N", "UP", "B", "SIM"] +ignore = ["E501"] + +[tool.ruff.lint.isort] +known-first-party = ["Thunder"] + +[tool.pyright] +pythonVersion = "3.13" +typeCheckingMode = "basic" +reportMissingModuleSource = false + +[tool.pytest.ini_options] +testpaths = ["tests"] + diff --git a/requirements.txt b/requirements.txt index 0ab99f4f..45a0049d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,10 @@ -aiohttp -cloudscraper -Jinja2 -pyrofork -pymongo -psutil -python-dotenv -speedtest-cli -tgcrypto -uvloop==0.21.0 +aiofiles>=25.1.0 +aiohttp +cloudscraper +Jinja2 +pytdbot[tdjson] +pymongo +psutil>=5.9 +python-dotenv +speedtest-cli +uvloop>=0.21,<0.22; sys_platform != 'win32' diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_canonical_files.py b/tests/test_canonical_files.py new file mode 100644 index 00000000..5eea11e7 --- /dev/null +++ b/tests/test_canonical_files.py @@ -0,0 +1,26 @@ +import os + +os.environ.setdefault("API_ID", "12345") +os.environ.setdefault("API_HASH", "test_hash_value_1234567890abcdef") +os.environ.setdefault("BOT_TOKEN", "123456789:ABCdefGHIjklMNOpqrsTUVwxyz") +os.environ.setdefault("BIN_CHANNEL", "-1001234567890") +os.environ.setdefault("DATABASE_URL", "mongodb://localhost:27017/test") +os.environ.setdefault("OWNER_ID", "99999") + +from Thunder.utils.canonical_files import PUBLIC_HASH_LENGTH, build_public_hash + + +class TestBuildPublicHash: + def test_returns_correct_length(self): + result = build_public_hash("test_unique_id") + assert len(result) == PUBLIC_HASH_LENGTH + + def test_deterministic(self): + assert build_public_hash("abc") == build_public_hash("abc") + + def test_different_inputs_different_outputs(self): + assert build_public_hash("abc") != build_public_hash("def") + + def test_hex_format(self): + result = build_public_hash("test") + assert all(c in "0123456789abcdef" for c in result) diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 00000000..2be462cd --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,37 @@ +import os + +os.environ.setdefault("API_ID", "12345") +os.environ.setdefault("API_HASH", "test_hash_value_1234567890abcdef") +os.environ.setdefault("BOT_TOKEN", "123456789:ABCdefGHIjklMNOpqrsTUVwxyz") +os.environ.setdefault("BIN_CHANNEL", "-1001234567890") +os.environ.setdefault("DATABASE_URL", "mongodb://localhost:27017/test") +os.environ.setdefault("OWNER_ID", "99999") + +from Thunder.utils.compat import ( + ChatMemberStatus, + Filters, +) + + +class TestFilters: + def test_private_filter_exists(self): + assert Filters.private is not None + + def test_command_filter(self): + f = Filters.command("start") + assert f is not None + + def test_regex_filter(self): + f = Filters.regex(r"^test") + assert f is not None + + def test_user_filter(self): + f = Filters.user(12345) + assert f is not None + + +class TestChatMemberStatus: + def test_constants(self): + assert ChatMemberStatus.ADMINISTRATOR == "administrator" + assert ChatMemberStatus.OWNER == "creator" + assert ChatMemberStatus.MEMBER == "member" diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py new file mode 100644 index 00000000..b58e92c4 --- /dev/null +++ b/tests/test_rate_limiter.py @@ -0,0 +1,26 @@ +import os + +os.environ.setdefault("API_ID", "12345") +os.environ.setdefault("API_HASH", "test_hash_value_1234567890abcdef") +os.environ.setdefault("BOT_TOKEN", "123456789:ABCdefGHIjklMNOpqrsTUVwxyz") +os.environ.setdefault("BIN_CHANNEL", "-1001234567890") +os.environ.setdefault("DATABASE_URL", "mongodb://localhost:27017/test") +os.environ.setdefault("OWNER_ID", "99999") + +from Thunder.utils.rate_limiter import RateLimiter + + +class TestRateLimiter: + def test_is_owner(self): + from Thunder.vars import Var + rl = RateLimiter() + assert rl.is_owner(Var.OWNER_ID) is True + assert rl.is_owner(999999) is False + + def test_queue_status(self): + rl = RateLimiter() + status = rl.get_queue_status() + assert "regular_queue_size" in status + assert "priority_queue_size" in status + assert "total_queued" in status + assert "enabled" in status diff --git a/tests/test_stream_routes.py b/tests/test_stream_routes.py new file mode 100644 index 00000000..fe042899 --- /dev/null +++ b/tests/test_stream_routes.py @@ -0,0 +1,74 @@ +import os + +# Set required env vars before any Thunder imports +os.environ.setdefault("API_ID", "12345") +os.environ.setdefault("API_HASH", "test_hash_value_1234567890abcdef") +os.environ.setdefault("BOT_TOKEN", "123456789:ABCdefGHIjklMNOpqrsTUVwxyz") +os.environ.setdefault("BIN_CHANNEL", "-1001234567890") +os.environ.setdefault("DATABASE_URL", "mongodb://localhost:27017/test") +os.environ.setdefault("OWNER_ID", "99999") + +import pytest + +from Thunder.server.exceptions import InvalidHash +from Thunder.server.stream_routes import ( + parse_media_request, + parse_range_header, + validate_public_hash, +) + + +class TestParseRangeHeader: + def test_empty_range(self): + start, end = parse_range_header("", 1000) + assert start == 0 + assert end == 999 + + def test_valid_range(self): + start, end = parse_range_header("bytes=100-200", 1000) + assert start == 100 + assert end == 200 + + def test_range_to_end(self): + start, end = parse_range_header("bytes=500-", 1000) + assert start == 500 + assert end == 999 + + def test_suffix_range(self): + start, end = parse_range_header("bytes=-100", 1000) + assert start == 900 + assert end == 999 + + def test_invalid_range_raises(self): + with pytest.raises(Exception): + parse_range_header("invalid", 1000) + + +class TestParseMediaRequest: + def test_hash_first_pattern(self): + msg_id, hash_val = parse_media_request("abc12342/some_file.mp4", {}) + assert msg_id == 42 + assert hash_val == "abc123" + + def test_id_first_with_query(self): + msg_id, hash_val = parse_media_request("42/some_file.mp4", {"hash": "abc123"}) + assert msg_id == 42 + assert hash_val == "abc123" + + def test_invalid_path_raises(self): + with pytest.raises(InvalidHash): + parse_media_request("invalid", {}) + + +class TestValidatePublicKey: + def test_valid_hash(self): + h = "a" * 20 + assert validate_public_hash(h) == h + + def test_invalid_length_raises(self): + with pytest.raises(InvalidHash): + validate_public_hash("abc") + + def test_invalid_chars_raises(self): + with pytest.raises(InvalidHash): + validate_public_hash("g" * 20) diff --git a/update.py b/update.py index 801e08c7..e481660c 100644 --- a/update.py +++ b/update.py @@ -1,6 +1,9 @@ -from os import path as opath, getenv, rename -from subprocess import run as srun +from os import getenv, rename +from os import path as opath +from subprocess import run as srun, CalledProcessError + from dotenv import load_dotenv + from Thunder.utils.logger import logger load_dotenv('config.env', override=True) @@ -10,32 +13,29 @@ 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: + + try: + srun(["git", "init", "-q"], check=True) + srun(["git", "config", "user.email", "thunder@update.local"], check=True) + srun(["git", "config", "user.name", "Thunder"], check=True) + srun(["git", "add", "."], check=True) + srun(["git", "commit", "-sm", "update", "-q"], check=True) + srun(["git", "remote", "add", "origin", UPSTREAM_REPO], check=True) + srun(["git", "fetch", "origin", "-q"], check=True) + srun(["git", "reset", "--hard", f"origin/{UPSTREAM_BRANCH}", "-q"], check=True) 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!') - + except CalledProcessError as e: + logger.error(f'Something went wrong while updating (exit code {e.returncode}). Check UPSTREAM_REPO!') + except Exception as e: + logger.error(f'Update failed: {e}') + finally: if opath.exists(config_backup): rename(config_backup, 'config.env') diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..76fe2d85 --- /dev/null +++ b/uv.lock @@ -0,0 +1,1031 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "aio-pika" +version = "9.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiormq" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/63/56354526f2e6e915c93bee6e4dedb35888fe82d6bc1a19f35f5a77e795ff/aio_pika-9.6.2.tar.gz", hash = "sha256:c49e9246080dc8ffa1bb0e4aca407bf3d8ad78c3ee3a93df88b68fe65d7a49b9", size = 70851, upload-time = "2026-03-22T19:03:20.878Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/05/256fa313f48bed075056d13593b92ce804be05d75f4f312be24edb82860a/aio_pika-9.6.2-py3-none-any.whl", hash = "sha256:2a5478af920d169795071c9c09c7542cd8cdece60438cf7804533dcbcce93b7f", size = 56269, upload-time = "2026-03-22T19:03:19.558Z" }, +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" }, +] + +[[package]] +name = "aiohappyeyeballs" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/c6/61a2d7b7572279226bb2e7f61d7a19ca7c90da0329c93fa0d560cbf288d8/aiohappyeyeballs-2.6.2.tar.gz", hash = "sha256:e202810ee718bd01fc6ef49e8ea53d023d5cb6b581076d7925aa499fa55dbe64", size = 22591, upload-time = "2026-05-20T15:12:24.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/fc/a7bf5b6e4e617b45f90f2d9d2a68519c249c81dd4fc2658c7a2a61c4f4b7/aiohappyeyeballs-2.6.2-py3-none-any.whl", hash = "sha256:4708045e2d7a6c6bdf8aafa8ed39649eaf926a4543b54560659129e3365953c4", size = 15062, upload-time = "2026-05-20T15:12:23.328Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.5" +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/77/9a/152096d4808df8e4268befa55fba462f440f14beab85e8ad9bf990516918/aiohttp-3.13.5.tar.gz", hash = "sha256:9d98cc980ecc96be6eb4c1994ce35d28d8b1f5e5208a23b421187d1209dbb7d1", size = 7858271, upload-time = "2026-03-31T22:01:03.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/e9/d76bf503005709e390122d34e15256b88f7008e246c4bdbe915cd4f1adce/aiohttp-3.13.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5029cc80718bbd545123cd8fe5d15025eccaaaace5d0eeec6bd556ad6163d61", size = 742930, upload-time = "2026-03-31T21:58:13.155Z" }, + { url = "https://files.pythonhosted.org/packages/57/00/4b7b70223deaebd9bb85984d01a764b0d7bd6526fcdc73cca83bcbe7243e/aiohttp-3.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4bb6bf5811620003614076bdc807ef3b5e38244f9d25ca5fe888eaccea2a9832", size = 496927, upload-time = "2026-03-31T21:58:15.073Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f5/0fb20fb49f8efdcdce6cd8127604ad2c503e754a8f139f5e02b01626523f/aiohttp-3.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a84792f8631bf5a94e52d9cc881c0b824ab42717165a5579c760b830d9392ac9", size = 497141, upload-time = "2026-03-31T21:58:17.009Z" }, + { url = "https://files.pythonhosted.org/packages/3b/86/b7c870053e36a94e8951b803cb5b909bfbc9b90ca941527f5fcafbf6b0fa/aiohttp-3.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57653eac22c6a4c13eb22ecf4d673d64a12f266e72785ab1c8b8e5940d0e8090", size = 1732476, upload-time = "2026-03-31T21:58:18.925Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e5/4e161f84f98d80c03a238671b4136e6530453d65262867d989bbe78244d0/aiohttp-3.13.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5e5f7debc7a57af53fdf5c5009f9391d9f4c12867049d509bf7bb164a6e295b", size = 1706507, upload-time = "2026-03-31T21:58:21.094Z" }, + { url = "https://files.pythonhosted.org/packages/d4/56/ea11a9f01518bd5a2a2fcee869d248c4b8a0cfa0bb13401574fa31adf4d4/aiohttp-3.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c719f65bebcdf6716f10e9eff80d27567f7892d8988c06de12bbbd39307c6e3a", size = 1773465, upload-time = "2026-03-31T21:58:23.159Z" }, + { url = "https://files.pythonhosted.org/packages/eb/40/333ca27fb74b0383f17c90570c748f7582501507307350a79d9f9f3c6eb1/aiohttp-3.13.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d97f93fdae594d886c5a866636397e2bcab146fd7a132fd6bb9ce182224452f8", size = 1873523, upload-time = "2026-03-31T21:58:25.59Z" }, + { url = "https://files.pythonhosted.org/packages/f0/d2/e2f77eef1acb7111405433c707dc735e63f67a56e176e72e9e7a2cd3f493/aiohttp-3.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df334e39d4c2f899a914f1dba283c1aadc311790733f705182998c6f7cae665", size = 1754113, upload-time = "2026-03-31T21:58:27.624Z" }, + { url = "https://files.pythonhosted.org/packages/fb/56/3f653d7f53c89669301ec9e42c95233e2a0c0a6dd051269e6e678db4fdb0/aiohttp-3.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe6970addfea9e5e081401bcbadf865d2b6da045472f58af08427e108d618540", size = 1562351, upload-time = "2026-03-31T21:58:29.918Z" }, + { url = "https://files.pythonhosted.org/packages/ec/a6/9b3e91eb8ae791cce4ee736da02211c85c6f835f1bdfac0594a8a3b7018c/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7becdf835feff2f4f335d7477f121af787e3504b48b449ff737afb35869ba7bb", size = 1693205, upload-time = "2026-03-31T21:58:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/98/fc/bfb437a99a2fcebd6b6eaec609571954de2ed424f01c352f4b5504371dd3/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:676e5651705ad5d8a70aeb8eb6936c436d8ebbd56e63436cb7dd9bb36d2a9a46", size = 1730618, upload-time = "2026-03-31T21:58:34.728Z" }, + { url = "https://files.pythonhosted.org/packages/e4/b6/c8534862126191a034f68153194c389addc285a0f1347d85096d349bbc15/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:9b16c653d38eb1a611cc898c41e76859ca27f119d25b53c12875fd0474ae31a8", size = 1745185, upload-time = "2026-03-31T21:58:36.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/93/4ca8ee2ef5236e2707e0fd5fecb10ce214aee1ff4ab307af9c558bda3b37/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:999802d5fa0389f58decd24b537c54aa63c01c3219ce17d1214cbda3c2b22d2d", size = 1557311, upload-time = "2026-03-31T21:58:39.38Z" }, + { url = "https://files.pythonhosted.org/packages/57/ae/76177b15f18c5f5d094f19901d284025db28eccc5ae374d1d254181d33f4/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec707059ee75732b1ba130ed5f9580fe10ff75180c812bc267ded039db5128c6", size = 1773147, upload-time = "2026-03-31T21:58:41.476Z" }, + { url = "https://files.pythonhosted.org/packages/01/a4/62f05a0a98d88af59d93b7fcac564e5f18f513cb7471696ac286db970d6a/aiohttp-3.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d6d44a5b48132053c2f6cd5c8cb14bc67e99a63594e336b0f2af81e94d5530c", size = 1730356, upload-time = "2026-03-31T21:58:44.049Z" }, + { url = "https://files.pythonhosted.org/packages/e4/85/fc8601f59dfa8c9523808281f2da571f8b4699685f9809a228adcc90838d/aiohttp-3.13.5-cp313-cp313-win32.whl", hash = "sha256:329f292ed14d38a6c4c435e465f48bebb47479fd676a0411936cc371643225cc", size = 432637, upload-time = "2026-03-31T21:58:46.167Z" }, + { url = "https://files.pythonhosted.org/packages/c0/1b/ac685a8882896acf0f6b31d689e3792199cfe7aba37969fa91da63a7fa27/aiohttp-3.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:69f571de7500e0557801c0b51f4780482c0ec5fe2ac851af5a92cfce1af1cb83", size = 458896, upload-time = "2026-03-31T21:58:48.119Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/46572759afc859e867a5bc8ec3487315869013f59281ce61764f76d879de/aiohttp-3.13.5-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:eb4639f32fd4a9904ab8fb45bf3383ba71137f3d9d4ba25b3b3f3109977c5b8c", size = 745721, upload-time = "2026-03-31T21:58:50.229Z" }, + { url = "https://files.pythonhosted.org/packages/13/fe/8a2efd7626dbe6049b2ef8ace18ffda8a4dfcbe1bcff3ac30c0c7575c20b/aiohttp-3.13.5-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:7e5dc4311bd5ac493886c63cbf76ab579dbe4641268e7c74e48e774c74b6f2be", size = 497663, upload-time = "2026-03-31T21:58:52.232Z" }, + { url = "https://files.pythonhosted.org/packages/9b/91/cc8cc78a111826c54743d88651e1687008133c37e5ee615fee9b57990fac/aiohttp-3.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:756c3c304d394977519824449600adaf2be0ccee76d206ee339c5e76b70ded25", size = 499094, upload-time = "2026-03-31T21:58:54.566Z" }, + { url = "https://files.pythonhosted.org/packages/0a/33/a8362cb15cf16a3af7e86ed11962d5cd7d59b449202dc576cdc731310bde/aiohttp-3.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecc26751323224cf8186efcf7fbcbc30f4e1d8c7970659daf25ad995e4032a56", size = 1726701, upload-time = "2026-03-31T21:58:56.864Z" }, + { url = "https://files.pythonhosted.org/packages/45/0c/c091ac5c3a17114bd76cbf85d674650969ddf93387876cf67f754204bd77/aiohttp-3.13.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10a75acfcf794edf9d8db50e5a7ec5fc818b2a8d3f591ce93bc7b1210df016d2", size = 1683360, upload-time = "2026-03-31T21:58:59.072Z" }, + { url = "https://files.pythonhosted.org/packages/23/73/bcee1c2b79bc275e964d1446c55c54441a461938e70267c86afaae6fba27/aiohttp-3.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f7a18f258d124cd678c5fe072fe4432a4d5232b0657fca7c1847f599233c83a", size = 1773023, upload-time = "2026-03-31T21:59:01.776Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ef/720e639df03004fee2d869f771799d8c23046dec47d5b81e396c7cda583a/aiohttp-3.13.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:df6104c009713d3a89621096f3e3e88cc323fd269dbd7c20afe18535094320be", size = 1853795, upload-time = "2026-03-31T21:59:04.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c9/989f4034fb46841208de7aeeac2c6d8300745ab4f28c42f629ba77c2d916/aiohttp-3.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:241a94f7de7c0c3b616627aaad530fe2cb620084a8b144d3be7b6ecfe95bae3b", size = 1730405, upload-time = "2026-03-31T21:59:07.221Z" }, + { url = "https://files.pythonhosted.org/packages/ce/75/ee1fd286ca7dc599d824b5651dad7b3be7ff8d9a7e7b3fe9820d9180f7db/aiohttp-3.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c974fb66180e58709b6fc402846f13791240d180b74de81d23913abe48e96d94", size = 1558082, upload-time = "2026-03-31T21:59:09.484Z" }, + { url = "https://files.pythonhosted.org/packages/c3/20/1e9e6650dfc436340116b7aa89ff8cb2bbdf0abc11dfaceaad8f74273a10/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6e27ea05d184afac78aabbac667450c75e54e35f62238d44463131bd3f96753d", size = 1692346, upload-time = "2026-03-31T21:59:12.068Z" }, + { url = "https://files.pythonhosted.org/packages/d8/40/8ebc6658d48ea630ac7903912fe0dd4e262f0e16825aa4c833c56c9f1f56/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a79a6d399cef33a11b6f004c67bb07741d91f2be01b8d712d52c75711b1e07c7", size = 1698891, upload-time = "2026-03-31T21:59:14.552Z" }, + { url = "https://files.pythonhosted.org/packages/d8/78/ea0ae5ec8ba7a5c10bdd6e318f1ba5e76fcde17db8275188772afc7917a4/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c632ce9c0b534fbe25b52c974515ed674937c5b99f549a92127c85f771a78772", size = 1742113, upload-time = "2026-03-31T21:59:17.068Z" }, + { url = "https://files.pythonhosted.org/packages/8a/66/9d308ed71e3f2491be1acb8769d96c6f0c47d92099f3bc9119cada27b357/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:fceedde51fbd67ee2bcc8c0b33d0126cc8b51ef3bbde2f86662bd6d5a6f10ec5", size = 1553088, upload-time = "2026-03-31T21:59:19.541Z" }, + { url = "https://files.pythonhosted.org/packages/da/a6/6cc25ed8dfc6e00c90f5c6d126a98e2cf28957ad06fa1036bd34b6f24a2c/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f92995dfec9420bb69ae629abf422e516923ba79ba4403bc750d94fb4a6c68c1", size = 1757976, upload-time = "2026-03-31T21:59:22.311Z" }, + { url = "https://files.pythonhosted.org/packages/c1/2b/cce5b0ffe0de99c83e5e36d8f828e4161e415660a9f3e58339d07cce3006/aiohttp-3.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20ae0ff08b1f2c8788d6fb85afcb798654ae6ba0b747575f8562de738078457b", size = 1712444, upload-time = "2026-03-31T21:59:24.635Z" }, + { url = "https://files.pythonhosted.org/packages/6c/cf/9e1795b4160c58d29421eafd1a69c6ce351e2f7c8d3c6b7e4ca44aea1a5b/aiohttp-3.13.5-cp314-cp314-win32.whl", hash = "sha256:b20df693de16f42b2472a9c485e1c948ee55524786a0a34345511afdd22246f3", size = 438128, upload-time = "2026-03-31T21:59:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/22/4d/eaedff67fc805aeba4ba746aec891b4b24cebb1a7d078084b6300f79d063/aiohttp-3.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:f85c6f327bf0b8c29da7d93b1cabb6363fb5e4e160a32fa241ed2dce21b73162", size = 464029, upload-time = "2026-03-31T21:59:29.429Z" }, + { url = "https://files.pythonhosted.org/packages/79/11/c27d9332ee20d68dd164dc12a6ecdef2e2e35ecc97ed6cf0d2442844624b/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:1efb06900858bb618ff5cee184ae2de5828896c448403d51fb633f09e109be0a", size = 778758, upload-time = "2026-03-31T21:59:31.547Z" }, + { url = "https://files.pythonhosted.org/packages/04/fb/377aead2e0a3ba5f09b7624f702a964bdf4f08b5b6728a9799830c80041e/aiohttp-3.13.5-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fee86b7c4bd29bdaf0d53d14739b08a106fdda809ca5fe032a15f52fae5fe254", size = 512883, upload-time = "2026-03-31T21:59:34.098Z" }, + { url = "https://files.pythonhosted.org/packages/bb/a6/aa109a33671f7a5d3bd78b46da9d852797c5e665bfda7d6b373f56bff2ec/aiohttp-3.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:20058e23909b9e65f9da62b396b77dfa95965cbe840f8def6e572538b1d32e36", size = 516668, upload-time = "2026-03-31T21:59:36.497Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/ca078f9f2fa9563c36fb8ef89053ea2bb146d6f792c5104574d49d8acb63/aiohttp-3.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cf20a8d6868cb15a73cab329ffc07291ba8c22b1b88176026106ae39aa6df0f", size = 1883461, upload-time = "2026-03-31T21:59:38.723Z" }, + { url = "https://files.pythonhosted.org/packages/b7/e3/a7ad633ca1ca497b852233a3cce6906a56c3225fb6d9217b5e5e60b7419d/aiohttp-3.13.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:330f5da04c987f1d5bdb8ae189137c77139f36bd1cb23779ca1a354a4b027800", size = 1747661, upload-time = "2026-03-31T21:59:41.187Z" }, + { url = "https://files.pythonhosted.org/packages/33/b9/cd6fe579bed34a906d3d783fe60f2fa297ef55b27bb4538438ee49d4dc41/aiohttp-3.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f1cbf0c7926d315c3c26c2da41fd2b5d2fe01ac0e157b78caefc51a782196cf", size = 1863800, upload-time = "2026-03-31T21:59:43.84Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3f/2c1e2f5144cefa889c8afd5cf431994c32f3b29da9961698ff4e3811b79a/aiohttp-3.13.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:53fc049ed6390d05423ba33103ded7281fe897cf97878f369a527070bd95795b", size = 1958382, upload-time = "2026-03-31T21:59:46.187Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/f31ec3f1013723b3babe3609e7f119c2c2fb6ef33da90061a705ef3e1bc8/aiohttp-3.13.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:898703aa2667e3c5ca4c54ca36cd73f58b7a38ef87a5606414799ebce4d3fd3a", size = 1803724, upload-time = "2026-03-31T21:59:48.656Z" }, + { url = "https://files.pythonhosted.org/packages/0e/b4/57712dfc6f1542f067daa81eb61da282fab3e6f1966fca25db06c4fc62d5/aiohttp-3.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0494a01ca9584eea1e5fbd6d748e61ecff218c51b576ee1999c23db7066417d8", size = 1640027, upload-time = "2026-03-31T21:59:51.284Z" }, + { url = "https://files.pythonhosted.org/packages/25/3c/734c878fb43ec083d8e31bf029daae1beafeae582d1b35da234739e82ee7/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cf81fe010b8c17b09495cbd15c1d35afbc8fb405c0c9cf4738e5ae3af1d65be", size = 1806644, upload-time = "2026-03-31T21:59:53.753Z" }, + { url = "https://files.pythonhosted.org/packages/20/a5/f671e5cbec1c21d044ff3078223f949748f3a7f86b14e34a365d74a5d21f/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:c564dd5f09ddc9d8f2c2d0a301cd30a79a2cc1b46dd1a73bef8f0038863d016b", size = 1791630, upload-time = "2026-03-31T21:59:56.239Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/fb8d0ad63a0b8a99be97deac8c04dacf0785721c158bdf23d679a87aa99e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2994be9f6e51046c4f864598fd9abeb4fba6e88f0b2152422c9666dcd4aea9c6", size = 1809403, upload-time = "2026-03-31T21:59:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/59/0c/bfed7f30662fcf12206481c2aac57dedee43fe1c49275e85b3a1e1742294/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:157826e2fa245d2ef46c83ea8a5faf77ca19355d278d425c29fda0beb3318037", size = 1634924, upload-time = "2026-03-31T22:00:02.116Z" }, + { url = "https://files.pythonhosted.org/packages/17/d6/fd518d668a09fd5a3319ae5e984d4d80b9a4b3df4e21c52f02251ef5a32e/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:a8aca50daa9493e9e13c0f566201a9006f080e7c50e5e90d0b06f53146a54500", size = 1836119, upload-time = "2026-03-31T22:00:04.756Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/15fb7a9d52e112a25b621c67b69c167805cb1f2ab8f1708a5c490d1b52fe/aiohttp-3.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b13560160d07e047a93f23aaa30718606493036253d5430887514715b67c9d9", size = 1772072, upload-time = "2026-03-31T22:00:07.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/df/57ba7f0c4a553fc2bd8b6321df236870ec6fd64a2a473a8a13d4f733214e/aiohttp-3.13.5-cp314-cp314t-win32.whl", hash = "sha256:9a0f4474b6ea6818b41f82172d799e4b3d29e22c2c520ce4357856fced9af2f8", size = 471819, upload-time = "2026-03-31T22:00:10.277Z" }, + { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, +] + +[[package]] +name = "aiormq" +version = "6.9.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pamqp" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/0e/db90154d52d399108903fe603e5110a533c42065180265dd003788264080/aiormq-6.9.4.tar.gz", hash = "sha256:0e7c01b662804e1cc7ace9a17794e8c1192a27fc2afa96162362a6e61ae8e8ef", size = 49232, upload-time = "2026-03-23T09:18:19.493Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/48/1ce3773f392f02ceda37aee168fade9d725483a9592c202d06044cd093ff/aiormq-6.9.4-py3-none-any.whl", hash = "sha256:726a8586695e863fba68cf88842065ab12348c9438dcebdfc9d0bddaf6083277", size = 32166, upload-time = "2026-03-23T09:18:17.523Z" }, +] + +[[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 = "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 = "cachebox" +version = "5.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/36/f6/85f176d2518cf1d1be5f981fc2dadf6b131e33fefd721f36b330e3434d6c/cachebox-5.2.3.tar.gz", hash = "sha256:b1f68246685aa739bbbd2734befb1465363a1e1042407c154feadb065f17a099", size = 63686, upload-time = "2026-04-10T12:21:35.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/3b/16d5c295f6ec2913ef595b39986dc7b7cc179fdd2e73f5ebd1814c38fd51/cachebox-5.2.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:9527c5c70f8735f2d696331d8bcf77254f03b4dc8542046807823bd36ed4e8ba", size = 377408, upload-time = "2026-04-10T12:20:25.444Z" }, + { url = "https://files.pythonhosted.org/packages/cd/87/45f834154f79721e5b64a80ffab4f9710834c4f9c01fa977f94a9116c32a/cachebox-5.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40ac878af00d5969862c1f6bc076de1e34ca248662fce6aecca1761f52e33e32", size = 359274, upload-time = "2026-04-10T12:20:12.127Z" }, + { url = "https://files.pythonhosted.org/packages/46/17/794e5f93e0a172aa14ecd692f6d89bdf094f71eb35fa923d0a0af25cef1c/cachebox-5.2.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5ff26bfd8f7e95b3becf6d5f65c25edaca50fa68078868648b70d79bcccc260", size = 397520, upload-time = "2026-04-10T12:18:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/19/9470b1a96de6e480192b1a92b2fafa72aa052efc2509a5418a5652205b33/cachebox-5.2.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:82e7002dd343afeeba2fcf0e483131b342a27ec3bc34b2214dc617691bda40d6", size = 353183, upload-time = "2026-04-10T12:19:07.797Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2b/72813f80397ed4640e337cbd1a14ab7eaafe33e479291d3623b6a6a55fec/cachebox-5.2.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ccbdc54a6c4b5758408c1083bdfa217bd382894a8331c7d0a54b84ba0cf51e5b", size = 372239, upload-time = "2026-04-10T12:19:20.44Z" }, + { url = "https://files.pythonhosted.org/packages/05/17/47dc9687288fa55486573627089ecd9aae124de5924a4bce008af96d80b6/cachebox-5.2.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df5135a168f143d186b1cc3be0ca16b66446897ab5cedc03bd80bcc926fcd403", size = 392568, upload-time = "2026-04-10T12:19:32.73Z" }, + { url = "https://files.pythonhosted.org/packages/13/95/450765b971a3bed9d7cf003c3833c1976482eb83b0241b6dbb840a25b43b/cachebox-5.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10bedf96db8f9766cc956f9adcc623e604264e5d6fa2e255432f8c2ed7519143", size = 397920, upload-time = "2026-04-10T12:19:59.314Z" }, + { url = "https://files.pythonhosted.org/packages/5f/3e/dd8f4c1f92e58d479913ce9cbaa3227c911128e6046c82f4fd44309f685a/cachebox-5.2.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f22732d0d69bb84ad2dca7480bffdfd0430c647152d488936e152ecbbfee52fb", size = 427332, upload-time = "2026-04-10T12:19:45.888Z" }, + { url = "https://files.pythonhosted.org/packages/7e/20/80d8c26ce63e78da3874a5bb07a3a78de53a2b0356ba80583a4927f0a074/cachebox-5.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:26ae0b68979204d360327f4c0725cfdc95cfc34ab73ab1a8f528e3bd2f6d023c", size = 567494, upload-time = "2026-04-10T12:20:40.373Z" }, + { url = "https://files.pythonhosted.org/packages/10/35/7249885dfed3602b3b48c1e67781197dcdc536c50f72caeabe3944348af8/cachebox-5.2.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:f3d628b816e28a6e7661d460e02dd5b421247cc2cd275814f80ea79621245fc4", size = 669968, upload-time = "2026-04-10T12:20:55.155Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8a/e5b58f0bbd6fef74da5d8e5ab49e67898ce7e6df28c16280a0f2b78461f7/cachebox-5.2.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:64057caa6b741320655cd3c5997fe642dae5dbff571eb530e6f53e58272bb43b", size = 645547, upload-time = "2026-04-10T12:21:09.948Z" }, + { url = "https://files.pythonhosted.org/packages/d8/25/51783a4c6f25ca87ef1b4b762ff0364bd98053a02d597b30d26ff4cf13c5/cachebox-5.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa325306084aa2dc0b21e07723d7700f4d43dece3732c7fdaf7a269dc5e35aa7", size = 611844, upload-time = "2026-04-10T12:21:25.286Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/b26c4b046e296d0e249448fe297626b3caca2e851837712f03c358662cb7/cachebox-5.2.3-cp313-cp313-win32.whl", hash = "sha256:55003089d21c2f5515089c307be063b45558e884a4a1cc9593944374c89975c4", size = 279421, upload-time = "2026-04-10T12:21:54.921Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7f/a49420670393bfea618de7a893d45cae9294cf3293d7b158e7af20e8f39e/cachebox-5.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:dcc5edb6ecf2b516e90b773d232360c5e4ed8fdcda038b19441da2ed9cf208ab", size = 290702, upload-time = "2026-04-10T12:21:41.458Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0b/bf83bda13ef6fc490d208a1d4dd712034624526a88f61713cca0edc9884f/cachebox-5.2.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:a4b7559fa4994c4032dd07466c2041d57e055feb814762e1f73f4e8beef188d0", size = 371704, upload-time = "2026-04-10T12:20:27.253Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ea/aa5162273238e84f9e41b33600c69299572dc1c8f0f768d07660b71be07d/cachebox-5.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f57afada3d9327adf87f3b5cf0094348c6fd49354ab2e9bd20b044648eb094ae", size = 353385, upload-time = "2026-04-10T12:20:13.668Z" }, + { url = "https://files.pythonhosted.org/packages/47/96/3ca013e2e48df5c1d7855669b208f4bf8014ccb842ccf7a3a0eaac07bee0/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8342ff350ce86f062492752d612e9f056ac5dc56375713d75c3bf6e83b4d18db", size = 392181, upload-time = "2026-04-10T12:18:56.385Z" }, + { url = "https://files.pythonhosted.org/packages/63/ca/1bacb4efa0b0ce8065d1fb7c8dc7c382ec4e1cc3f007eb08417732be2725/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:405f9cc8492fc9d953b5a6b9e2b661e99583755c6639ab8d09a287fdf336503c", size = 349494, upload-time = "2026-04-10T12:19:09.505Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/75db4bda3768658f5baa5a54f6a4f643bc2de1a16788e40581a080e803c7/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:94aae393ec1d9b26565d346445bb6afa3963d2a0d3eb5e4188d0e510fab871a0", size = 369216, upload-time = "2026-04-10T12:19:22.224Z" }, + { url = "https://files.pythonhosted.org/packages/f5/82/e1f833be0d57e29a8c5eb0a0275cd34b962f3c7f5b9e0517ec4bf75e7cc3/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8b0b575066fc09f6fae0d4bd30d6ff56584a6870cbe7d202916c5e0d725cfd4", size = 385922, upload-time = "2026-04-10T12:19:34.198Z" }, + { url = "https://files.pythonhosted.org/packages/53/d6/615a3c16c1d63839f2c67644eb414c4dc9769ab2e169d935110fd8e268d5/cachebox-5.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:41e99c1240106d39b63ce7868a6cd8c9da9243fef08848b85d428164e0769fd2", size = 393276, upload-time = "2026-04-10T12:20:00.925Z" }, + { url = "https://files.pythonhosted.org/packages/2f/a6/7844c9c84b170dae1005b22da174639968e64c8055d66a209a1598663771/cachebox-5.2.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:432ca62b99f7eafc21af669d76c88c1b7377db179b89fb6fca3ea93b8f9fff19", size = 421355, upload-time = "2026-04-10T12:19:47.691Z" }, + { url = "https://files.pythonhosted.org/packages/c9/0f/43f62355846cae3dc41cb4daccac0a4bb2b7b8b3c7d77d1b6a220bae6d54/cachebox-5.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e51d9c59006b53447f806145406eb37a7fc3c25553d4fd24c3887f3b268d214e", size = 561656, upload-time = "2026-04-10T12:20:42.161Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fc/a453813c6d000d69a41a06c6a3143a6c4d0d0e41f23c155db2f82ea0edfa/cachebox-5.2.3-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:5e48a405f699fb001b8af120a6e0b4a981277f84eb5dd66a1faa21e4b6fe9485", size = 665791, upload-time = "2026-04-10T12:20:56.842Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a3/f6a9e75f1e602b67b6d67088a9a766adfc4e0a740a9c4b68e4e6207c1006/cachebox-5.2.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8cbfc007ea78af61d75d7d26e5854df53dc5da6877d074afd4b4696c074f4ee7", size = 640975, upload-time = "2026-04-10T12:21:11.641Z" }, + { url = "https://files.pythonhosted.org/packages/a3/15/4ac98277f7fd9d855c8ed337e8e2a3386d17997cce2dd3eadb23dedc08e3/cachebox-5.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6a94d0da8133b3a0707ae11c9ea321f8fc37e3b5a14517019a05d632218b0f56", size = 607242, upload-time = "2026-04-10T12:21:27.27Z" }, + { url = "https://files.pythonhosted.org/packages/9c/0b/ce61907a803f75854e0cc91b84c16e14dce0e4e939efbda26293eb4c8784/cachebox-5.2.3-cp313-cp313t-win32.whl", hash = "sha256:5fee33549877c03c2494ec5359a57a7667f872fe8e296a7f39d3dfe08dd3914c", size = 271619, upload-time = "2026-04-10T12:21:56.768Z" }, + { url = "https://files.pythonhosted.org/packages/b0/06/fece190ad5173d06b2779494aaad5528907f2e55c809618e5b67c2e3dbb5/cachebox-5.2.3-cp313-cp313t-win_amd64.whl", hash = "sha256:67548a05cd41fcc4f7af80a2f97f742fef3d436537ac2e1a1dce0fcba5d41190", size = 283133, upload-time = "2026-04-10T12:21:43.037Z" }, + { url = "https://files.pythonhosted.org/packages/b8/8b/72c0e80aad08e09867ce14a621bce689a733552f20cdf2ef96d4b052da10/cachebox-5.2.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:37fa0891f0defee053c09f5f43f802f731e36e6e6ca055d7d174af07f77232ca", size = 380523, upload-time = "2026-04-10T12:20:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fc/62/33aaade81b181d5191cc39c867c297aa7c65f3191aa9749bf99b77496b88/cachebox-5.2.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dc6315902f2ef4afbf10bc8e08c54ff34de5ce124546b8e0016c9b0d327be21e", size = 362424, upload-time = "2026-04-10T12:20:15.215Z" }, + { url = "https://files.pythonhosted.org/packages/9e/0b/3eedaf9ea4b41c931f4340bfa42056efe2bb5fe3a79649d6c8a1dce585a5/cachebox-5.2.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7df1735ca778480d51b8232fed397ffe3935158f20d34fb1c5ed171b53d5a6e2", size = 399572, upload-time = "2026-04-10T12:18:58.331Z" }, + { url = "https://files.pythonhosted.org/packages/be/69/c79b8a6a5b889ac4a60800bacea3553cb3b86f6fd13b2262bade1cb962c6/cachebox-5.2.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e22451cde8f884051e941b21870e4fc91fcf58d0d8c285bb8964107e1f02445c", size = 353803, upload-time = "2026-04-10T12:19:11.21Z" }, + { url = "https://files.pythonhosted.org/packages/d4/c3/bc7838de51039f8c50506d8dc82f22ff9a652794339a223b12af595e1d2f/cachebox-5.2.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dcbccf3015d9a42bcf41260fa5cc048a5bdb75aa10997d514d6c976117f30ee2", size = 374474, upload-time = "2026-04-10T12:19:23.658Z" }, + { url = "https://files.pythonhosted.org/packages/65/61/e5231ad2ae952ca482f9b9df55df4b96add1a80de28de537c5f574605987/cachebox-5.2.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:311eae5079e256cbbfafdc3dcff1714b6598a767f9c1ef8c3709e74ea0cc12b0", size = 393045, upload-time = "2026-04-10T12:19:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/78/c4/c9b3fa764ac5420a9e079ad53fa8840d4a26b74c4ccda56acbef49cf76ff/cachebox-5.2.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f4d2a80a5cd3380739c67f7d89e596634f5897b8d5a4a3dc1598312cb077535", size = 398700, upload-time = "2026-04-10T12:20:02.513Z" }, + { url = "https://files.pythonhosted.org/packages/9b/3e/c4e3acd4cb04e01c5fb7cc7a4de16059b9594d90672fff85af8670275267/cachebox-5.2.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3977515b727a5203f494c44c4566fb936c4b940351c01d3d8e7b5d104dff4f53", size = 426725, upload-time = "2026-04-10T12:19:49.385Z" }, + { url = "https://files.pythonhosted.org/packages/25/5d/610b79479719951581109d985244d34c97f86a308c3d7c83443e2b1dac46/cachebox-5.2.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c5be17dd5c4fabcfecd5bcf6d54f9c6fb719daed3ef01ac1c03a14af0e2b26c1", size = 570042, upload-time = "2026-04-10T12:20:43.793Z" }, + { url = "https://files.pythonhosted.org/packages/8c/63/cad8a05db4d0c0f5ba6bccb32e57d15c472276de9476f56004445b40711f/cachebox-5.2.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:6d37334fc218fdaee31db8a4f938938716e7c3b1b4059e25de27c8447fc95fde", size = 670974, upload-time = "2026-04-10T12:20:58.528Z" }, + { url = "https://files.pythonhosted.org/packages/54/d1/9cff7c2b9048d1c38b7ad8199ce856596d09720b3bea74043f3bad71970b/cachebox-5.2.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:1e5f1b7e23411b748d919348c3b65db1f9f8927ab8f6f3acae19bd617543df2d", size = 646213, upload-time = "2026-04-10T12:21:13.619Z" }, + { url = "https://files.pythonhosted.org/packages/27/ae/2e1ad162ec13903e84469c8a753baf385f1bc324279d6c7cb6365e7099df/cachebox-5.2.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7b06a75a898b31fd73c4d8bf727a9b9f8b5b7738cccd0ab5e6fd2a9cf659d3c", size = 612787, upload-time = "2026-04-10T12:21:29.271Z" }, + { url = "https://files.pythonhosted.org/packages/c8/8a/07b5ffd841e1ff534bb6e8721c39fdfe0d7cdaac1398e1783b2a0c37bd22/cachebox-5.2.3-cp314-cp314-win32.whl", hash = "sha256:3b798052719f09a2ce7bf9fa9452dc0a7d4dc53b50a2d3aba6ce6ebc12d39df7", size = 278559, upload-time = "2026-04-10T12:21:58.482Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/b88a82ce9ec7a2fa0f09ed1cdd031692c8664c41f9ab71831e177c7ce2df/cachebox-5.2.3-cp314-cp314-win_amd64.whl", hash = "sha256:4afc8b8575e3228a42ad8d819de5fbbecc6bd0b521295966b00244be37ae3b9b", size = 291928, upload-time = "2026-04-10T12:21:44.621Z" }, + { url = "https://files.pythonhosted.org/packages/4a/01/8c79c07c8c6517fb2fe7d479dd87044e38aac5b9af0245b33fcd695eae37/cachebox-5.2.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:0e8a34b82be30d3d9fb7dfaf9a86ec2b3ab9bc264715909ef27fc3d3587324d2", size = 374325, upload-time = "2026-04-10T12:20:30.923Z" }, + { url = "https://files.pythonhosted.org/packages/7f/51/0fc26b923e80ab857ac99d5f7f3784dc941e7b4de361c204835233176ddf/cachebox-5.2.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4d4e336aebf866463878ccd28a4d0ef4003ea216708cf4a02a7f198481b3af81", size = 355444, upload-time = "2026-04-10T12:20:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/c1/6d/a6b399221f8dc4b3e01b37d3240ef5b8a7eb78cd9bfbb99b0e655dd01649/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b102fcdd97b0602bf5d6ba1a571bba3e3d6fa912b89fd768b0da5427408eab8", size = 393978, upload-time = "2026-04-10T12:18:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f1/4c8f998c117c1941a82bd824d6687280c50167f21fea6392e41531d641e2/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:245a79fb2c5d3bff252f4263f76210ef3ad7c2ff9b0234859b26974830a80491", size = 349298, upload-time = "2026-04-10T12:19:12.843Z" }, + { url = "https://files.pythonhosted.org/packages/d1/dd/683bc5a32a0da660d02fa248b880b71a2b834e9b54b8d272b5801282f402/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dd0e8dbd8fd4cf664c645c08f9e10508e133353756705c4a738e90a5406224b5", size = 370619, upload-time = "2026-04-10T12:19:25.298Z" }, + { url = "https://files.pythonhosted.org/packages/81/49/d6c47c78a7769b355076c5b635c2b538c8b88e8ceeb408e104d0f269b515/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fdb74294bdc33e39e26606919a9b2229038d5fac0edb80c9056683c08584d4a9", size = 385988, upload-time = "2026-04-10T12:19:37.638Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/b669555ada7fa1392e4cdb8a19f3367db5c6abef0fde8ab034a9747760df/cachebox-5.2.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bba3e9a7f52fa196b434522f39675f3b32a076976ef2373ded6f1065e99f4d20", size = 394090, upload-time = "2026-04-10T12:20:03.978Z" }, + { url = "https://files.pythonhosted.org/packages/8f/01/42916249e53fe4fcbdf0419fb55dbc09b9f377475376e1d7f4ae9c9bd6cd/cachebox-5.2.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abb21f0f937fb66528f1b9f1a04874d6aa503e78bbb26f4cf33bf67faddbdd68", size = 421632, upload-time = "2026-04-10T12:19:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/a1/54/34eebe18c6ed8ba27b1331b5e3d08bd8bb62f03ba81fbf47a2db0fa646f7/cachebox-5.2.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dab6fd3189b0c746fb03e1915fd947aaca9112cedf26ef3a0c39383acf87d2e5", size = 563871, upload-time = "2026-04-10T12:20:45.417Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b2/f92da0d54e4f18609588709090de8c81dd7c8b20ed6ac30f9b91bedbedf5/cachebox-5.2.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4e7d2935b9df11d3717f99c7237b6780f1f8c70e6a99b69b8430d89929ec825", size = 665677, upload-time = "2026-04-10T12:21:00.512Z" }, + { url = "https://files.pythonhosted.org/packages/43/9d/bf2d3dc949afe4d21fc7eb15b7524255e834b9252df6bba111e6686d1c6f/cachebox-5.2.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:611aa260fe1b2506330ff72f415e2cb4053c9c4e3776ac68fe2eedee0e1b91b1", size = 642067, upload-time = "2026-04-10T12:21:15.727Z" }, + { url = "https://files.pythonhosted.org/packages/6e/4f/a789eda189550d239fbaf165b9810f148e733e97a2a4eda7c4192295c7f8/cachebox-5.2.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a424ffb8514a9cb49bacff7995b7c767625cb2239692bd6524245e8579e375cc", size = 608048, upload-time = "2026-04-10T12:21:31.156Z" }, + { url = "https://files.pythonhosted.org/packages/41/c3/590e161c04ffbd36e33933e6dcca5ffa40b5548e3121a21d77aad42af138/cachebox-5.2.3-cp314-cp314t-win32.whl", hash = "sha256:83988dd8e9075ee837e8407e26db49a9944ae74924d5db57b477444d7d98622c", size = 271694, upload-time = "2026-04-10T12:22:00.589Z" }, + { url = "https://files.pythonhosted.org/packages/66/f4/f60b8506df467261178afe918801df37c02c46ec2b8ce019760a14e2abe7/cachebox-5.2.3-cp314-cp314t-win_amd64.whl", hash = "sha256:dbda6390fa5070a19157ae35ab8066d3fe468634e0e9e21452c68ce7999c7d0c", size = 284212, upload-time = "2026-04-10T12:21:46.241Z" }, +] + +[[package]] +name = "certifi" +version = "2026.5.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "cloudscraper" +version = "1.2.71" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyparsing" }, + { name = "requests" }, + { name = "requests-toolbelt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/25/6d0481860583f44953bd791de0b7c4f6d7ead7223f8a17e776247b34a5b4/cloudscraper-1.2.71.tar.gz", hash = "sha256:429c6e8aa6916d5bad5c8a5eac50f3ea53c9ac22616f6cb21b18dcc71517d0d3", size = 93261, upload-time = "2023-04-25T23:20:19.467Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/97/fc88803a451029688dffd7eb446dc1b529657577aec13aceff1cc9628c5d/cloudscraper-1.2.71-py2.py3-none-any.whl", hash = "sha256:76f50ca529ed2279e220837befdec892626f9511708e200d48d5bb76ded679b0", size = 99652, upload-time = "2023-04-25T23:20:15.974Z" }, +] + +[[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 = "deepdiff" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachebox" }, + { name = "orderly-set" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/6b/6a4a5aaf38535eb332c2856aa08e73ed7c549d0851b1215401af0a2db1a7/deepdiff-9.1.0.tar.gz", hash = "sha256:07e9e366fab4297755153c4eab795ad4ef3cbd0d51660e847f5751c6bd727687", size = 382149, upload-time = "2026-05-15T20:18:05.751Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/26/4a2bad8eb430d8d805a4642c4bff25103a37548d74ab346f8b1e024abcc5/deepdiff-9.1.0-py3-none-any.whl", hash = "sha256:80c0460e1993b04f6f0ca79abf25548b129fd218478c4ebb08f80560f5d10610", size = 184662, upload-time = "2026-05-15T20:18:03.956Z" }, +] + +[[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 = "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.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, +] + +[[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 = "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 = "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 = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "orderly-set" +version = "5.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/88/39c83c35d5e97cc203e9e77a4f93bf87ec89cf6a22ac4818fdcc65d66584/orderly_set-5.5.0.tar.gz", hash = "sha256:e87185c8e4d8afa64e7f8160ee2c542a475b738bc891dc3f58102e654125e6ce", size = 27414, upload-time = "2025-07-10T20:10:55.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/27/fb8d7338b4d551900fa3e580acbe7a0cf655d940e164cb5c00ec31961094/orderly_set-5.5.0-py3-none-any.whl", hash = "sha256:46f0b801948e98f427b412fcabb831677194c05c3b699b80de260374baa0b1e7", size = 13068, upload-time = "2025-07-10T20:10:54.377Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pamqp" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fb/62/35bbd3d3021e008606cd0a9532db7850c65741bbf69ac8a3a0d8cfeb7934/pamqp-3.3.0.tar.gz", hash = "sha256:40b8795bd4efcf2b0f8821c1de83d12ca16d5760f4507836267fd7a02b06763b", size = 30993, upload-time = "2024-01-12T20:37:25.085Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/8d/c1e93296e109a320e508e38118cf7d1fc2a4d1c2ec64de78565b3c445eb5/pamqp-3.3.0-py2.py3-none-any.whl", hash = "sha256:c901a684794157ae39b52cbf700db8c9aae7a470f13528b9d7b4e5f7202f8eb0", size = 33848, upload-time = "2024-01-12T20:37:21.359Z" }, +] + +[[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 = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pymongo" +version = "4.17.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ca/64/50be6fbac9c79fe2e4c17401a467da2d8764d82833d83cec325afe5cab32/pymongo-4.17.0.tar.gz", hash = "sha256:70ffa08ba641468cc068cf46c06b34f01a8ce3489f6411309fcb5ceabe6b2fc0", size = 2523370, upload-time = "2026-04-20T16:39:53.524Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/6b/c1206879708b94e82fcd8b9653440ec271f79a3674d122192df383047f5a/pymongo-4.17.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:809ec74de3b9148ae43fa8df9faf53470f511c8d384f13b99d6f671f2a379f15", size = 985829, upload-time = "2026-04-20T16:38:21.031Z" }, + { url = "https://files.pythonhosted.org/packages/cb/cf/bb044ed85160e5c40f568c7c4f4e8ea16f40764ff5d302e5befbe8f6f814/pymongo-4.17.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a431b737816bf4cddd4fa0fcef04e424ad36b7692734a64150f872fb8f3208be", size = 985899, upload-time = "2026-04-20T16:38:23.409Z" }, + { url = "https://files.pythonhosted.org/packages/74/0a/f6dfd5ea3901e5d6888da8de8ba728971a1d447debab681cfc56f90d1208/pymongo-4.17.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4fab10f8403169ce92f3cea921609d9ee81107306caae06c08f592d4b8ad2b5", size = 2028569, upload-time = "2026-04-20T16:38:25.343Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c5/081f59a1c02ae8c0dc73ae58e563838c44eec81aeafa7d0b93a637841c9b/pymongo-4.17.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20323b0b1c1d33770ad1fc68d429c757734ce9ad3594421c3d6618f10572b1b9", size = 2072916, upload-time = "2026-04-20T16:38:27.291Z" }, + { url = "https://files.pythonhosted.org/packages/31/42/6e41d434297ffe8b30d9c3717916591a4a7be9075a0dcc2fafdfaaaa62ed/pymongo-4.17.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5a5de048e6da5c18e27cc2437e8c15b3b0cdc8385c15b41178b0caa3322a09c2", size = 2173234, upload-time = "2026-04-20T16:38:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/3d/cf/1e4a7db352ef9485831c7268dfe8402f0117b32a9ad54b16e810699e3617/pymongo-4.17.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dff3de1294fbbc1db0ba6b511f77b8e540601d092538a31312e99c8a91a78b1e", size = 2156784, upload-time = "2026-04-20T16:38:32.134Z" }, + { url = "https://files.pythonhosted.org/packages/12/10/6195be29962a61ebb5f4bd9e4c7519890b172f7968a0a0d880398c6ddb02/pymongo-4.17.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faf03e4c2aafd6de626dbd30ba246d369ae33f47f10629d1bbe40f72115027a6", size = 2074446, upload-time = "2026-04-20T16:38:34.004Z" }, + { url = "https://files.pythonhosted.org/packages/37/48/33410b8819837ed370c738587306bdf060b59cef11823be212f4a07703c5/pymongo-4.17.0-cp313-cp313-win32.whl", hash = "sha256:c9786665926a09630c5d420c79762cfadbff35a9438bcbc4c81a9fb5ab9228b7", size = 948435, upload-time = "2026-04-20T16:38:35.922Z" }, + { url = "https://files.pythonhosted.org/packages/6f/77/c0ed522f798a286b99acaa7914ed8d9c80ab091f97f57c59ffed72906e5e/pymongo-4.17.0-cp313-cp313-win_amd64.whl", hash = "sha256:5960519b4d7168f1ecdd3ea10c81b2aedeb9423651aca953cfbc8e76705d3b38", size = 972847, upload-time = "2026-04-20T16:38:37.888Z" }, + { url = "https://files.pythonhosted.org/packages/97/f0/c39480a2db385fde23861d0c8acda41cdaf1d43e46579db72c5c013a2e81/pymongo-4.17.0-cp313-cp313-win_arm64.whl", hash = "sha256:0ff6bd2f735ab5356541e3e57d5b7dbfbc3f2ee1ccb10b6b0f82d58af69d1d8e", size = 951575, upload-time = "2026-04-20T16:38:40.544Z" }, + { url = "https://files.pythonhosted.org/packages/da/49/2b0250762a89737ed6f9cea238331baca061b89a8ddd10dd17fee52c3970/pymongo-4.17.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff5aa3f1c7e3f08eb0e7a016c91ba468b1850ccfd63d9b1f12f56350f4974cef", size = 1040945, upload-time = "2026-04-20T16:38:42.783Z" }, + { url = "https://files.pythonhosted.org/packages/89/1c/7a9b5447a08be20e84b6e5b17330917e8d6d9507daa3cd099a9309f11ad7/pymongo-4.17.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e816db649ba5d7de0568cf3a9f287a9dc9aad21cf0ca667ab156a7ef47fca0b0", size = 1041187, upload-time = "2026-04-20T16:38:45.358Z" }, + { url = "https://files.pythonhosted.org/packages/78/a1/71704f61632dfc90407a5834fe5f6132854937c4a3648f6c05c351d85a45/pymongo-4.17.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:12c4fded3a9f1d6a687e36ebd384ac6d00b9b00de1969aa74048e7051ec2a713", size = 2294806, upload-time = "2026-04-20T16:38:47.734Z" }, + { url = "https://files.pythonhosted.org/packages/ad/b9/aff42be75108b96c2469b1d9329b912c15108f3e7ef32fdc86da8423c330/pymongo-4.17.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2db66aa8dd253a0fc1fad3b0d23d5b3993f7ebde02fbbd7727128debf2853675", size = 2348231, upload-time = "2026-04-20T16:38:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/f2/30/44c115b8ba1479942c15fd9480eb29a7da0ba68acd56983423ba0deb4a94/pymongo-4.17.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3987e96e7c7be4083d42e8ac2cc6c0d5b78db9973c90fce42ae800b616ca6b20", size = 2467614, upload-time = "2026-04-20T16:38:52.665Z" }, + { url = "https://files.pythonhosted.org/packages/d2/84/21ee95c8bf0ca7acae7ec7eb365d740bf8fc0156c194baf2c3bdfcb85ec0/pymongo-4.17.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cee36b3c0d0354f880fa7a7fdcdaf2bb5e542c2281e25c1bfadf8cfe21eba7d2", size = 2445970, upload-time = "2026-04-20T16:38:55.175Z" }, + { url = "https://files.pythonhosted.org/packages/06/89/081d7f1809d5ca09d1e47e49f2111b245f5694de3a7af32cd3a353a6f43f/pymongo-4.17.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:320b34457b20bbcc79997801f95d25ce00472915ca5241167242b42c4359e027", size = 2348605, upload-time = "2026-04-20T16:38:57.557Z" }, + { url = "https://files.pythonhosted.org/packages/ea/c3/0d949f9d3f2a341c1f635c398c16615e96f89f51ff424ed81e914cf1a4de/pymongo-4.17.0-cp314-cp314-win32.whl", hash = "sha256:df4a644af9ae132d4bfdb2e9516ea51a615fd881caddfbfbd071cf1354844479", size = 1004119, upload-time = "2026-04-20T16:39:00.309Z" }, + { url = "https://files.pythonhosted.org/packages/f7/55/5c3a3db1048054c695c75c5964cc8bedc2247fdb5a75ef6fab4ec8bb013e/pymongo-4.17.0-cp314-cp314-win_amd64.whl", hash = "sha256:c797f8a80957134f6dd9690367a0f8f5906d672119af2c6aa55f0c527b656bed", size = 1032314, upload-time = "2026-04-20T16:39:02.665Z" }, + { url = "https://files.pythonhosted.org/packages/e0/19/e235f39906134cb0ffd5574c5a59c355ef5380f0499644ab94994afbb109/pymongo-4.17.0-cp314-cp314-win_arm64.whl", hash = "sha256:68fca71e05ee5da23a8d73cee8379dfb3d26e609a377cae731d742771ed96946", size = 1007627, upload-time = "2026-04-20T16:39:04.678Z" }, + { url = "https://files.pythonhosted.org/packages/1e/e0/c4c1a86791415b14c684fa0908f9da96de91594a3fd1fa1b8dc689fbb800/pymongo-4.17.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b4384700cffc3f1dd98e088bc0072dedf6d7d68a230bb4b972665cf69c071c1e", size = 1099151, upload-time = "2026-04-20T16:39:06.969Z" }, + { url = "https://files.pythonhosted.org/packages/81/4b/69c67f3e23fd9b23b9bedc7ebd23754881cc9d5c5d5b2a9811e96b07f475/pymongo-4.17.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:93641192644fa1ee0f34030e774fd31022a27ad11ba22cb1716142231524f8bd", size = 1099346, upload-time = "2026-04-20T16:39:08.996Z" }, + { url = "https://files.pythonhosted.org/packages/a2/19/a5208f62f9508a26d73acc69bd3821b8c8adae253679a3c26d2f9652f0d5/pymongo-4.17.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:75bc3aa5b94fdb7138d357ec6ca61cd97e0c79f4f7f0bd3efe9639b15cc50942", size = 2619034, upload-time = "2026-04-20T16:39:11.049Z" }, + { url = "https://files.pythonhosted.org/packages/77/27/426cba1ec5973082a56d4150798529bfdf4151c31391ed1fbbecb23ef2ac/pymongo-4.17.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e8f8e23c6df7c6d6929f5e734980b227706e73ee847517c9ba5af90f7fc466", size = 2689939, upload-time = "2026-04-20T16:39:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/ef/2e/f70993d1255e33f6ee59a4ec4371cc65bff7a7e3fda7d55c3386f25287e8/pymongo-4.17.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:15d3f3d732aecac1f8d481bde4029755615639bd3076f258a2147210aec8515a", size = 2824994, upload-time = "2026-04-20T16:39:16.057Z" }, + { url = "https://files.pythonhosted.org/packages/b3/eb/87b0e988ba889e1fcc3430c2cfc166b251872c813e92b43174298bee17ff/pymongo-4.17.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5f62862d0f87be481fa1fe8cb811994486773c94a2b61e509285e3f2890763", size = 2801745, upload-time = "2026-04-20T16:39:18.476Z" }, + { url = "https://files.pythonhosted.org/packages/67/4c/3f83412d086f682d4d468761d66ddc49cf161e786ea74073045eb4491c60/pymongo-4.17.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64837adbbd72073301af51bb0fc80e3d7707fe5527cea1033ba0320f0b2f881b", size = 2684636, upload-time = "2026-04-20T16:39:20.878Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/b75f6f4ab6c8beb50b0270a4f1e2530b5774f5e116563440e1677ca1820f/pymongo-4.17.0-cp314-cp314t-win32.whl", hash = "sha256:b93b22eedc62598cf5ee9d8c8007a8e9121c50fd88137012d8985500e9dc3151", size = 1056356, upload-time = "2026-04-20T16:39:22.996Z" }, + { url = "https://files.pythonhosted.org/packages/e4/5e/648c8a238eef18a25ed8a169ea6542d4a860bbec3e95b3d9badac2935c71/pymongo-4.17.0-cp314-cp314t-win_amd64.whl", hash = "sha256:3689ea34f6b647c7d1e7bdc60fcfb214b2789ed1359a7fb96569c69f50e5f18f", size = 1090964, upload-time = "2026-04-20T16:39:24.989Z" }, + { url = "https://files.pythonhosted.org/packages/dc/cb/d9780b66939c4fc1f024bcc7be23a2abcfe06a9745ca8fa76dc73395482e/pymongo-4.17.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9543d8f84c2e5608565c08ac679774811e6730770d8a645439b073422a4276fb", size = 1058526, upload-time = "2026-04-20T16:39:27.924Z" }, +] + +[[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 = "pyright" +version = "1.1.409" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/4e/3aa27f74211522dba7e9cbc3e74de779c6d4b654c54e50a4840623be8014/pyright-1.1.409.tar.gz", hash = "sha256:986ee05beca9e077c165758ad123667c679e050059a2546aa02473930394bc93", size = 4430434, upload-time = "2026-04-23T11:02:03.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/6b/330d8ebae582b30c2959a1ef4c3bc344ebde48c2ff0c3f113c4710735e11/pyright-1.1.409-py3-none-any.whl", hash = "sha256:aa3ea228cab90c845c7a60d28db7a844c04315356392aa09fafcee98c8c22fb3", size = 6438161, upload-time = "2026-04-23T11:02:01.309Z" }, +] + +[[package]] +name = "pytdbot" +version = "0.9.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aio-pika" }, + { name = "deepdiff" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/89/aa003c8965dfe70a34816bfd4a7fe6bf28a974a8250c6964a3408168b568/pytdbot-0.9.10.tar.gz", hash = "sha256:f4bae674d8d022db8adc4d1fdbaa4f1b3034ea722e3f90b1d2b6b2ce1405db00", size = 518334, upload-time = "2026-02-22T22:21:11.29Z" } + +[package.optional-dependencies] +tdjson = [ + { name = "tdjson" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +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/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[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 = "requests-toolbelt" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/61/d7545dafb7ac2230c70d38d31cbfe4cc64f7144dc41f6e4e4b78ecd9f5bb/requests-toolbelt-1.0.0.tar.gz", hash = "sha256:7681a0a3d047012b5bdc0ee37d7f8f07ebe76ab08caeccfc3921ce23c88d5bc6", size = 206888, upload-time = "2023-05-01T04:11:33.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, +] + +[[package]] +name = "ruff" +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +] + +[[package]] +name = "speedtest-cli" +version = "2.1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/d2/32c8a30768b788d319f94cde3a77e0ccc1812dca464ad8062d3c4d703e06/speedtest-cli-2.1.3.tar.gz", hash = "sha256:5e2773233cedb5fa3d8120eb7f97bcc4974b5221b254d33ff16e2f1d413d90f0", size = 24721, upload-time = "2021-04-08T13:51:33.627Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/39/65259b7054368b370d3183762484fa2c779ddc41633894d895f9d1720f45/speedtest_cli-2.1.3-py2.py3-none-any.whl", hash = "sha256:75ff32c91af9ac1ce2b905476d6e92bd9eb2c0783f9e7d1939d74605c7d0b9ea", size = 23973, upload-time = "2021-04-08T13:51:32.028Z" }, +] + +[[package]] +name = "tdjson" +version = "1.8.64.post1" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/1c/963eee102cd9387fa5dd7d343eee882c72c1c3d3d2f1a6f6277027b507ab/tdjson-1.8.64.post1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e4b806f9bc1aa67128f7491cedf7fb09bc2ceff76d9d409681dcffc913134f38", size = 14089202, upload-time = "2026-05-08T03:44:13.597Z" }, + { url = "https://files.pythonhosted.org/packages/cf/be/675ad768a38fe1ec9c528492b43bda95936a69510a3cb440c7bb20e52686/tdjson-1.8.64.post1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ac8cfad1c38e24f2eac30ee1955f92c94fd85f675c811daa1a0e402a422308ca", size = 16731296, upload-time = "2026-05-08T03:44:16.383Z" }, + { url = "https://files.pythonhosted.org/packages/a5/55/0e6ecb5bd1f00e16854646ec85c0ee662a738a38fff23da86e4c4eb34661/tdjson-1.8.64.post1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:95b7e85f6af9ebe3a9a699eb0d002273c82376a5c76e444bc7d4feaf200e469b", size = 16952051, upload-time = "2026-05-08T03:44:19.486Z" }, + { url = "https://files.pythonhosted.org/packages/c8/f6/1a98ade4dd9bd0cfdd6df2695d7a78a64667d9a6051fa0b240e734c02bb8/tdjson-1.8.64.post1-cp313-cp313-win_amd64.whl", hash = "sha256:8aacf50374d01478c8f2a5a08e7c0a150108465bb9f0f35a351817b1c9ece1c7", size = 12190442, upload-time = "2026-05-08T03:44:22.147Z" }, + { url = "https://files.pythonhosted.org/packages/ca/73/cdb092e1f25740fa7cc90b4d8f32c7c7cfe3177b2198c002d0e26419b052/tdjson-1.8.64.post1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3b7bb4cc21d575af51b40662d0f341788eca7a204814c17e2ec3f6a0f0645283", size = 14089209, upload-time = "2026-05-08T03:44:24.886Z" }, + { url = "https://files.pythonhosted.org/packages/6b/c6/de220025bc041fcf49d56bb7bad5ba769d582af415efe25e1cbad91b3921/tdjson-1.8.64.post1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c767c5cb7a1e5372631731c2b323556d78e2323c1494bc566f76a8f8bbb83203", size = 16731364, upload-time = "2026-05-08T03:44:28.402Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/2d4fc045d38e4a0532210b6f9f438b5f0e7b9584241892717bf11bddd685/tdjson-1.8.64.post1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:24b8a3763fdc21f66a3c7e57447b065f5721275781d9d00004d354497938f345", size = 16952058, upload-time = "2026-05-08T03:44:31.602Z" }, + { url = "https://files.pythonhosted.org/packages/29/1b/61d9a2e7cc82546414301a802192e72b73c46fa64199ba03c47a4393c047/tdjson-1.8.64.post1-cp314-cp314-win_amd64.whl", hash = "sha256:4d3f542b2db48ef1368e651091d918c1a01ced7611cfcfa4225911894ba633b0", size = 12558495, upload-time = "2026-05-08T03:44:34.427Z" }, + { url = "https://files.pythonhosted.org/packages/17/86/a03e6eb4e0250e21b4072d76d8ccdc57eda682c7ae99b0eee1877a1754b9/tdjson-1.8.64.post1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8545dd29ec5f780758f43600a72f5ea63bafb299a9cd7645018176408e57277a", size = 14089430, upload-time = "2026-05-08T03:44:36.834Z" }, + { url = "https://files.pythonhosted.org/packages/62/cd/60f79f9b86f611abd6c8fd0af9173e7413898ae2e7521abeb94d60b1f322/tdjson-1.8.64.post1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2209c2bd1eecd52212a9d2831af3f80674c9f997406990aa21af0d9ee091e7bb", size = 16733583, upload-time = "2026-05-08T03:44:39.461Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c0/308c49290443a732e312399c892c17881acf522728f2c71b4e8ab4c37c80/tdjson-1.8.64.post1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1f62e804bdffee8838a1d359f9f23bd9ebd3dc4782ae2aa37d8bf2ef4e4452f8", size = 16953984, upload-time = "2026-05-08T03:44:42.606Z" }, + { url = "https://files.pythonhosted.org/packages/92/03/f327ff7a011244bf1d5a3fd77d7ceffe90206598c6e7b0c8f91dfb94f198/tdjson-1.8.64.post1-cp314-cp314t-win_amd64.whl", hash = "sha256:3e0ade64cd7964b001cd8f5711cd33472283c179baea596f2cf64283e517e343", size = 12559592, upload-time = "2026-05-08T03:44:45.254Z" }, +] + +[[package]] +name = "thunder-f2l" +version = "2.1.0" +source = { editable = "." } +dependencies = [ + { name = "aiofiles" }, + { name = "aiohttp" }, + { name = "cloudscraper" }, + { name = "jinja2" }, + { name = "psutil" }, + { name = "pymongo" }, + { name = "pytdbot", extra = ["tdjson"] }, + { name = "python-dotenv" }, + { name = "speedtest-cli" }, + { name = "uvloop", marker = "sys_platform != 'win32'" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pyright" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiofiles", specifier = ">=25.1.0" }, + { name = "aiohttp", specifier = ">=3.9" }, + { name = "cloudscraper", specifier = ">=1.2" }, + { name = "jinja2", specifier = ">=3.1" }, + { name = "psutil", specifier = ">=5.9" }, + { name = "pymongo", specifier = ">=4.7" }, + { name = "pytdbot", extras = ["tdjson"], specifier = ">=0.9" }, + { name = "python-dotenv", specifier = ">=1.0" }, + { name = "speedtest-cli", specifier = ">=2.1" }, + { name = "uvloop", marker = "sys_platform != 'win32'", specifier = ">=0.21,<0.22" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "pyright", specifier = ">=1.1" }, + { name = "pytest", specifier = ">=8.0" }, + { name = "ruff", specifier = ">=0.4" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[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.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c0/854216d09d33c543f12a44b393c402e89a920b1a0a7dc634c42de91b9cf6/uvloop-0.21.0.tar.gz", hash = "sha256:3bf12b0fda68447806a7ad847bfa591613177275d35b6724b1ee573faa3704e3", size = 2492741, upload-time = "2024-10-14T23:38:35.489Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/8d/2cbef610ca21539f0f36e2b34da49302029e7c9f09acef0b1c3b5839412b/uvloop-0.21.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bfd55dfcc2a512316e65f16e503e9e450cab148ef11df4e4e679b5e8253a5281", size = 1468123, upload-time = "2024-10-14T23:38:00.688Z" }, + { url = "https://files.pythonhosted.org/packages/93/0d/b0038d5a469f94ed8f2b2fce2434a18396d8fbfb5da85a0a9781ebbdec14/uvloop-0.21.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:787ae31ad8a2856fc4e7c095341cccc7209bd657d0e71ad0dc2ea83c4a6fa8af", size = 819325, upload-time = "2024-10-14T23:38:02.309Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/0a687f39e78c4c1e02e3272c6b2ccdb4e0085fda3b8352fecd0410ccf915/uvloop-0.21.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5ee4d4ef48036ff6e5cfffb09dd192c7a5027153948d85b8da7ff705065bacc6", size = 4582806, upload-time = "2024-10-14T23:38:04.711Z" }, + { url = "https://files.pythonhosted.org/packages/d2/19/f5b78616566ea68edd42aacaf645adbf71fbd83fc52281fba555dc27e3f1/uvloop-0.21.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3df876acd7ec037a3d005b3ab85a7e4110422e4d9c1571d4fc89b0fc41b6816", size = 4701068, upload-time = "2024-10-14T23:38:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/47/57/66f061ee118f413cd22a656de622925097170b9380b30091b78ea0c6ea75/uvloop-0.21.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bd53ecc9a0f3d87ab847503c2e1552b690362e005ab54e8a48ba97da3924c0dc", size = 4454428, upload-time = "2024-10-14T23:38:08.416Z" }, + { url = "https://files.pythonhosted.org/packages/63/9a/0962b05b308494e3202d3f794a6e85abe471fe3cafdbcf95c2e8c713aabd/uvloop-0.21.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a5c39f217ab3c663dc699c04cbd50c13813e31d917642d459fdcec07555cc553", size = 4660018, upload-time = "2024-10-14T23:38:10.888Z" }, +] + +[[package]] +name = "yarl" +version = "1.24.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, +]