├── .gitignore
├── .python-version
├── Dockerfile
├── LICENSE
├── Procfile
├── README.md
├── Thunder
├── __init__.py
├── __main__.py
├── bot
│ ├── __init__.py
│ ├── clients.py
│ └── plugins
│ │ ├── admin.py
│ │ ├── callbacks.py
│ │ ├── common.py
│ │ └── stream.py
├── server
│ ├── __init__.py
│ ├── exceptions.py
│ └── stream_routes.py
├── template
│ ├── dl.html
│ └── req.html
├── utils
│ ├── bot_utils.py
│ ├── broadcast_helper.py
│ ├── config_parser.py
│ ├── custom_dl.py
│ ├── database.py
│ ├── decorators.py
│ ├── error_handling.py
│ ├── file_properties.py
│ ├── force_channel.py
│ ├── human_readable.py
│ ├── keepalive.py
│ ├── logger.py
│ ├── messages.py
│ ├── render_template.py
│ ├── retry_api.py
│ ├── shortener.py
│ ├── time_format.py
│ └── tokens.py
└── vars.py
├── config_sample.env
└── requirements.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | config.env
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.so
6 | .Python
7 | build/
8 | develop-eggs/
9 | dist/
10 | downloads/
11 | eggs/
12 | .eggs/
13 | lib/
14 | lib64/
15 | parts/
16 | sdist/
17 | var/
18 | wheels/
19 | *.egg-info/
20 | .installed.cfg
21 | *.egg
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
1 | 3.13
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:slim
2 |
3 | ENV PYTHONUNBUFFERED=1 \
4 | PYTHONDONTWRITEBYTECODE=1
5 |
6 | WORKDIR /app
7 |
8 | RUN apt-get update && \
9 | apt-get install -y --no-install-recommends \
10 | git \
11 | build-essential \
12 | libssl-dev \
13 | && apt-get clean && \
14 | rm -rf /var/lib/apt/lists/*
15 |
16 | COPY requirements.txt .
17 |
18 | RUN pip install --upgrade pip && \
19 | pip install --no-cache-dir -r requirements.txt
20 |
21 | COPY . .
22 |
23 | CMD ["python", "-m", "Thunder"]
24 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | This is free and unencumbered software released into the public domain.
2 |
3 | Anyone is free to copy, modify, publish, use, compile, sell, or
4 | distribute this software, either in source code form or as a compiled
5 | binary, for any purpose, commercial or non-commercial, and by any
6 | means.
7 |
8 | In jurisdictions that recognize copyright laws, the author or authors
9 | of this software dedicate any and all copyright interest in the
10 | software to the public domain. We make this dedication for the benefit
11 | of the public at large and to the detriment of our heirs and
12 | successors. We intend this dedication to be an overt act of
13 | relinquishment in perpetuity of all present and future rights to this
14 | software under copyright law.
15 |
16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
19 | IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
20 | OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
21 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
22 | OTHER DEALINGS IN THE SOFTWARE.
23 |
24 | For more information, please refer to
25 |
--------------------------------------------------------------------------------
/Procfile:
--------------------------------------------------------------------------------
1 | web: python -m Thunder
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
⚡ Thunder
4 |
5 |
6 |
7 | High-performance Telegram File to Link Bot
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | ℹ️ About •
29 | ✨ Features •
30 | 🔍 How It Works •
31 | 📋 Prerequisites •
32 | ⚙️ Configuration •
33 | 📦 Deployment •
34 | 📱 Usage •
35 | ⌨️ Commands
36 |
37 |
38 |
39 |
40 | ## ℹ️ About The Project
41 |
42 | > **Thunder** is a powerful, high-performance Telegram bot that transforms media files into streamable direct links. Share and access files via HTTP(S) links instead of downloading from Telegram, for a seamless media experience.
43 |
44 | **Perfect for:**
45 |
46 | - 📤 Download Telegram media at high speed.
47 | - 🎬 Content creators sharing media files.
48 | - 👥 Communities distributing resources.
49 | - 🎓 Educational platforms sharing materials.
50 | - 🌍 Anyone needing to share Telegram media.
51 |
52 | ---
53 |
54 | ## ✨ Features
55 |
56 | ### 🚀 Core Functionality
57 |
58 | - **Generate Direct Links:** Convert Telegram media files into direct streaming links.
59 | - **Permanent Links:** Links remain active as long as the file exists in the storage channel.
60 | - **Multi-Client Support:** Distribute workload across multiple Telegram accounts.
61 | - **HTTP/HTTPS Streaming:** Stream media with custom player support for all devices and browsers.
62 |
63 | ### 🧩 Advanced Features
64 |
65 | - **MongoDB Integration:** Store user data and file info with advanced database capabilities.
66 | - **User Authentication:** Require users to join channels before generating links.
67 | - **Admin Commands:** Manage users and control bot behavior.
68 | - **Custom Domain Support:** Use your own domain for streaming links.
69 | - **Customizable Templates:** Personalize HTML templates for download pages.
70 |
71 | ### ⚙️ Technical Capabilities
72 |
73 | - **Asynchronous Architecture:** Built with aiohttp and asyncio for high concurrency.
74 | - **Rate Limiting:** Prevent abuse with advanced rate-limiting.
75 | - **Media Info Display:** Show file size, duration, format, and more.
76 | - **Multiple File Types:** Supports videos, audio, documents, images, stickers, and more.
77 | - **Forwarding Control:** Restrict or allow file forwarding.
78 | - **Caching System:** Reduce Telegram API calls and improve responsiveness.
79 |
80 | ---
81 |
82 | ## 🔍 How It Works
83 |
84 | 1. **Upload:** User sends a media file to the bot. The bot forwards it to a storage channel and stores metadata.
85 | 2. **Link Generation:** A unique, secure link is generated and sent to the user.
86 | 3. **Streaming:** When the link is opened, the server authenticates, retrieves the file from Telegram, and streams it directly to the browser.
87 | 4. **Load Balancing:** Multiple Telegram clients distribute workload, with automatic switching and smart queuing for high traffic.
88 |
89 | ---
90 |
91 | ## 📋 Prerequisites
92 |
93 | - 🐍 Python 3.13 or higher
94 | - 🍃 MongoDB
95 | - 🔑 Telegram API ID and Hash ([my.telegram.org/apps](https://my.telegram.org/apps))
96 | - 🤖 Bot Token from [@BotFather](https://t.me/BotFather)
97 | - 🌐 Server with public IP or domain
98 | - 📦 Telegram storage channel
99 |
100 | ---
101 |
102 | ## ⚙️ Configuration
103 |
104 | Rename `config_sample.env` to `config.env` and edit the following variables:
105 |
106 | ### Required
107 |
108 | | Variable | Description | Example |
109 | |------------------|---------------------------------------------|-----------------------------------------|
110 | | `API_ID` | Telegram API ID from my.telegram.org | `12345` |
111 | | `API_HASH` | Telegram API Hash from my.telegram.org | `abc123def456` |
112 | | `BOT_TOKEN` | Bot token from @BotFather | `123456789:ABCdefGHIjklMNOpqrsTUVwxyz` |
113 | | `BIN_CHANNEL` | Channel ID for storing files (add bot as admin) | `-1001234567890` |
114 | | `OWNER_ID` | Your Telegram user ID(s) (space-separated) | `12345678 87654321` |
115 | | `OWNER_USERNAME` | Your Telegram username (without @) | `yourusername` |
116 | | `FQDN` | Your domain name or server IP | `files.yourdomain.com` |
117 | | `HAS_SSL` | Set to "True" if using HTTPS | `True` or `False` |
118 | | `PORT` | Web server port | `8080` |
119 | | `NO_PORT` | Hide port in URLs | `True` or `False` |
120 |
121 | ### Optional
122 |
123 | | Variable | Description | Default | Example |
124 | |----------------------|------------------------------------------|-----------|-------------------------------|
125 | | `MULTI_BOT_TOKENS` | Additional bot tokens for load balancing | *(empty)* | `MULTI_TOKEN1=` |
126 | | `FORCE_CHANNEL_ID` | Channel ID users must join | *(empty)* | `-1001234567890` |
127 | | `BANNED_CHANNELS` | Space-separated banned channel IDs | *(empty)* | `-1001234567890 -100987654321`|
128 | | `SLEEP_THRESHOLD` | Threshold for client switching | `60` | `30` |
129 | | `WORKERS` | Number of async workers | `100` | `200` |
130 | | `DATABASE_URL` | MongoDB connection string | *(empty)* | `mongodb+srv://user:pass@host/db` |
131 | | `NAME` | Bot application name | `ThunderF2L` | `MyFileBot` |
132 | | `BIND_ADDRESS` | Address to bind web server | `0.0.0.0` | `127.0.0.1` |
133 | | `PING_INTERVAL` | Ping interval in seconds | `840` | `1200` |
134 | | `CACHE_SIZE` | Cache size in MB | `100` | `200` |
135 | | `TOKEN_ENABLED` | Enable token authentication system | `False` | `True` |
136 | | `SHORTEN_ENABLED` | Enable URL shortening for tokens | `False` | `True` |
137 | | `SHORTEN_MEDIA_LINKS`| Enable URL shortening for media links | `False` | `True` |
138 | | `URL_SHORTENER_API_KEY` | API key for URL shortening service | `""` | `"abc123def456"` |
139 | | `URL_SHORTENER_SITE` | URL shortening service to use | `""` | `"example.com"` |
140 |
141 | > ℹ️ For all options, see `config_sample.env`.
142 |
143 | ---
144 |
145 | ## 📦 Deployment
146 |
147 | ### Using Docker (Recommended)
148 |
149 | ```bash
150 | # Ensure you have configured config.env as per the Configuration section
151 | # Build and run with Docker
152 | docker build -t Thunder .
153 | docker run -d --name Thunder -p 8080:8080 Thunder
154 | ```
155 |
156 | ### Manual Installation
157 |
158 | ```bash
159 | # Ensure you have completed the "Getting Started" and "Configuration" sections
160 | # Run the bot
161 | python -m Thunder
162 | ```
163 |
164 | ### ⚡ Scaling for High Traffic
165 |
166 | - Use multiple bot instances.
167 | - Increase `WORKERS` in `config.env` based on your server's capabilities.
168 |
169 | ---
170 |
171 | ## 📱 Usage
172 |
173 | ### Basic
174 |
175 | 1. **Start:** Send `/start` to your bot.
176 | 2. **Authenticate:** Join required channels if configured by the admin.
177 | 3. **Token Authentication:** If token system is enabled, you'll need a valid token to use the bot. When you try to use a feature requiring authorization, the bot will automatically generate a token for you with an activation link.
178 | 4. **Upload:** Send any media file to the bot.
179 | 5. **Get Link:** Receive a direct streaming link.
180 | 6. **Share:** Anyone with the link can stream or download the file.
181 |
182 | ### Advanced
183 |
184 | - **Batch Processing:** Forward multiple files to the bot for batch link generation.
185 | - **Custom Thumbnails:** Send a photo with `/set_thumbnail` as its caption to set a custom thumbnail for subsequent files.
186 | - **Remove Thumbnail:** Use `/del_thumbnail` to remove a previously set custom thumbnail.
187 | - **User Settings:** Users might have access to a settings menu (if implemented) to configure preferences.
188 |
189 | ---
190 |
191 | ## ⌨️ Commands
192 |
193 | ### User Commands
194 |
195 | | Command | Description |
196 | |-----------|-------------|
197 | | `/start` | Start the bot and get a welcome message. Also used for token activation. |
198 | | `/link` | Generate a direct link for a file in a group. Supports batch files by replying to the first file in a group (e.g., `/link 5`). |
199 | | `/dc` | Get the data center (DC) of a user or file. Use `/dc id`, or reply to a file or user. Works in both groups and private chats. |
200 | | `/ping` | Check if the bot is online and measure response time. |
201 | | `/about` | Get information about the bot. |
202 | | `/help` | Show help and usage instructions. |
203 |
204 | ### Admin Commands (for Bot Owner)
205 |
206 | | Command | Description |
207 | |----------------|----------------------------------------------------------------------|
208 | | `/status` | Check bot status, uptime, and resource usage. |
209 | | `/broadcast` | Send a message to all users (supports text, media, buttons). |
210 | | `/stats` | View usage statistics and analytics. |
211 | | `/ban` | Ban a user (reply to message or use user ID). |
212 | | `/unban` | Unban a user. |
213 | | `/log` | Send bot logs. |
214 | | `/restart` | Restart the bot. |
215 | | `/shell` | Execute a shell command (Use with extreme caution!). |
216 | | `/users` | Show total number of users. |
217 | | `/authorize` | Permanently authorize a user to use the bot (bypasses token system). |
218 | | `/unauthorize` | Remove permanent authorization from a user. |
219 | | `/listauth` | List all permanently authorized users. |
220 |
221 | ### Commands for @BotFather
222 |
223 | Paste the following into the BotFather "Edit Commands" section for your bot:
224 |
225 | ```text
226 | start - Start the bot and get a welcome message
227 | link - Generate a direct link for a file (supports batch in groups)
228 | dc - Get the data center (DC) of a user or file
229 | ping - Check if the bot is online
230 | about - Get information about the bot
231 | help - Show help and usage instructions
232 | status - (Admin) Check bot status, uptime, and resource usage
233 | broadcast - (Admin) Send a message to all users
234 | stats - (Admin) View usage statistics and analytics
235 | ban - (Admin) Ban a user
236 | unban - (Admin) Unban a user
237 | log - (Admin) Send bot logs
238 | restart - (Admin) Restart the bot
239 | shell - (Admin) Execute a shell command
240 | users - (Admin) Show total number of users
241 | authorize - (Admin) Grant permanent access to a user
242 | unauthorize - (Admin) Remove permanent access from a user
243 | listauth - (Admin) List all authorized users
244 | ```
245 |
246 | ---
247 |
248 | ## 🔑 Token System
249 |
250 | Thunder Bot includes an optional token-based access control system that allows admins to control who can use the bot.
251 |
252 | ### How It Works
253 |
254 | 1. Enable the token system by setting `TOKEN_ENABLED=True` in your config.
255 | 2. Users without a valid token will receive an "Access Denied" message when trying to use the bot
256 | 3. Admins can authorize users permanently, or users receive automatically generated tokens
257 |
258 | ### Admin Commands
259 |
260 | | Command | Description |
261 | |---------|-------------|
262 | | `/authorize ` | Grant a user permanent access to the bot |
263 | | `/deauthorize ` | Remove a user's permanent access |
264 | | `/listauth` | List all permanently authorized users |
265 |
266 | ---
267 |
268 |
269 | Reverse Proxy with Cloudflare SSL
270 |
271 | ## Reverse Proxy Setup for File Streaming Bot with Cloudflare SSL
272 |
273 | This guide will help you set up a secure reverse proxy using **NGINX** for your file streaming bot with **Cloudflare SSL protection**.
274 |
275 | ---
276 |
277 | ## ✅ What You Need
278 |
279 | - A **VPS or server** running Ubuntu/Debian with NGINX installed
280 | - Your **file streaming bot** running on a local port (e.g., `5063`)
281 | - A **subdomain** (e.g., `dl.yoursite.com`) set up in **Cloudflare**
282 | - **Cloudflare Origin Certificate** files:
283 | - `cert.pem` (Certificate file)
284 | - `key.key` (Private key file)
285 |
286 | ---
287 |
288 | ## 🔐 Step 1: Configure Cloudflare
289 |
290 | **Set up DNS:**
291 | - Go to your domain in [Cloudflare Dashboard](https://dash.cloudflare.com)
292 | - Navigate to **DNS** → Add an `A` record:
293 | - **Name:** `dl` (or your preferred subdomain)
294 | - **Content:** Your server's IP address
295 | - **Proxy Status:** **Proxied (orange cloud)**
296 |
297 | **Configure SSL:**
298 | - Go to **SSL/TLS** → **Overview**
299 | - Set encryption mode to **Full (strict)**
300 | - Create your **Origin Certificate** if you haven't already
301 |
302 | ---
303 |
304 | ## 🛡️ Step 2: Set Up SSL Certificates
305 |
306 | Create a folder for your SSL certificates:
307 |
308 | ```bash
309 | sudo mkdir -p /etc/ssl/cloudflare/dl.yoursite.com
310 | ```
311 |
312 | **If you have the certificate files already:**
313 | ```bash
314 | sudo mv cert.pem key.key /etc/ssl/cloudflare/dl.yoursite.com/
315 | ```
316 |
317 | **If you need to create them:**
318 | ```bash
319 | # Create certificate file
320 | sudo nano /etc/ssl/cloudflare/dl.yoursite.com/cert.pem
321 | # Paste your Origin Certificate here and save
322 |
323 | # Create private key file
324 | sudo nano /etc/ssl/cloudflare/dl.yoursite.com/key.key
325 | # Paste your Private Key here and save
326 | ```
327 |
328 | **Make the files secure:**
329 | ```bash
330 | sudo chmod 600 /etc/ssl/cloudflare/dl.yoursite.com/key.key
331 | sudo chmod 644 /etc/ssl/cloudflare/dl.yoursite.com/cert.pem
332 | ```
333 |
334 | ---
335 |
336 | ## 🛠️ Step 3: Create NGINX Configuration
337 |
338 | Create a new configuration file:
339 | ```bash
340 | sudo nano /etc/nginx/sites-available/dl.yoursite.conf
341 | ```
342 |
343 | **Paste this configuration** (replace `dl.yoursite.com` and `5063` with your values):
344 |
345 | ```nginx
346 | server {
347 | listen 443 ssl;
348 | listen [::]:443 ssl;
349 | server_name dl.yoursite.com;
350 |
351 | # SSL Configuration
352 | ssl_certificate /etc/ssl/cloudflare/dl.yoursite.com/cert.pem;
353 | ssl_certificate_key /etc/ssl/cloudflare/dl.yoursite.com/key.key;
354 |
355 | # Basic security
356 | add_header X-Frame-Options DENY;
357 | add_header X-Content-Type-Options nosniff;
358 |
359 | # Logging
360 | access_log /var/log/nginx/dl.yoursite.com.access.log;
361 | error_log /var/log/nginx/dl.yoursite.com.error.log;
362 |
363 | location / {
364 | # Forward requests to your bot
365 | proxy_pass http://localhost:5063;
366 | proxy_set_header Host $host;
367 | proxy_set_header X-Real-IP $remote_addr;
368 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
369 | proxy_set_header X-Forwarded-Proto $scheme;
370 |
371 | # Settings for file streaming
372 | proxy_buffering off;
373 | proxy_request_buffering off;
374 |
375 | # Allow large files
376 | client_max_body_size 0;
377 | }
378 | }
379 |
380 | # Redirect HTTP to HTTPS
381 | server {
382 | listen 80;
383 | listen [::]:80;
384 | server_name dl.yoursite.com;
385 |
386 | return 301 https://$host$request_uri;
387 | }
388 | ```
389 |
390 | **Enable the configuration:**
391 | ```bash
392 | sudo ln -s /etc/nginx/sites-available/dl.yoursite.conf /etc/nginx/sites-enabled/
393 | ```
394 |
395 | ---
396 |
397 | ## 🔄 Step 4: Test and Apply Changes
398 |
399 | **Check if configuration is correct:**
400 | ```bash
401 | sudo nginx -t
402 | ```
403 |
404 | **If no errors, restart NGINX:**
405 | ```bash
406 | sudo systemctl reload nginx
407 | ```
408 |
409 | ---
410 |
411 | ## ✅ Step 5: Test Your Setup
412 |
413 | **Test if your site is working:**
414 | ```bash
415 | curl -I https://dl.yoursite.com
416 | ```
417 |
418 | **Test a file download:**
419 | ```bash
420 | curl -I https://dl.yoursite.com/dl/
421 | ```
422 |
423 | ---
424 |
425 | ## 🎉 Done!
426 |
427 | Your reverse proxy is now securely streaming files behind Cloudflare, powered by NGINX!
428 |
429 |
430 |
431 | ---
432 |
433 | ## 🤝 Contributing
434 |
435 | Contributions are welcome! Please follow these steps:
436 |
437 | 1. Fork the repository.
438 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`).
439 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`).
440 | 4. Push to the branch (`git push origin feature/AmazingFeature`).
441 | 5. Open a Pull Request.
442 |
443 | ---
444 |
445 | ## 📄 License
446 |
447 | This repository is unlicensed and provided as-is without any warranty. No permission is granted to use, copy, modify, or distribute this software for any purpose.
448 |
449 | ---
450 |
451 | ## 👏 Acknowledgements
452 |
453 | - [Kurigram](https://github.com/KurimuzonAkuma/pyrogram/) – Telegram MTProto API Client Library
454 | - [AIOHTTP](https://github.com/aio-libs/aiohttp) – Async HTTP client/server framework
455 | - [Motor](https://github.com/mongodb/motor) – Async MongoDB driver
456 | - [TgCrypto](https://github.com/pyrogram/tgcrypto) – Fast cryptography library for Telegram
457 | - All contributors who have helped improve the project.
458 |
459 | ---
460 |
461 | ## 📢 Disclaimer
462 |
463 | > This project is not affiliated with Telegram. Use at your own risk and responsibility.
464 | > Comply with Telegram's Terms of Service and your local regulations regarding content distribution.
465 |
466 | ---
467 |
468 |
469 | ⭐ Like this project? Give it a star! ⭐
470 | 🐛 Found a bug or have a feature request? Open an issue
471 |
472 |
--------------------------------------------------------------------------------
/Thunder/__init__.py:
--------------------------------------------------------------------------------
1 | # Thunder/__init__.py
2 |
3 | import time
4 |
5 | # Record the start time of the module
6 | StartTime = time.time()
7 |
8 | # Define the version
9 | __version__ = "1.8.5"
--------------------------------------------------------------------------------
/Thunder/__main__.py:
--------------------------------------------------------------------------------
1 | # Thunder/__main__.py
2 |
3 | import os
4 | import sys
5 | import glob
6 | import asyncio
7 | import importlib.util
8 | from pathlib import Path
9 | from datetime import datetime
10 |
11 | from pyrogram import idle
12 | from aiohttp import web
13 | from Thunder import __version__
14 | from Thunder.bot import StreamBot
15 | from Thunder.vars import Var
16 | from Thunder.server import web_server
17 | from Thunder.utils.keepalive import ping_server
18 | from Thunder.bot.clients import initialize_clients
19 | from Thunder.utils.logger import logger
20 | from Thunder.utils.database import db
21 | from Thunder.utils.messages import MSG_ADMIN_RESTART_DONE
22 |
23 |
24 | # Constants
25 | PLUGIN_PATH = "Thunder/bot/plugins/*.py"
26 | VERSION = __version__
27 |
28 |
29 | def print_banner():
30 | """Print a visually appealing banner at startup."""
31 | banner = f"""
32 | ╔═══════════════════════════════════════════════════════════════════╗
33 | ║ ║
34 | ║ ████████╗██╗ ██╗██╗ ██╗███╗ ██╗██████╗ ███████╗██████╗ ║
35 | ║ ╚══██╔══╝██║ ██║██║ ██║████╗ ██║██╔══██╗██╔════╝██╔══██╗ ║
36 | ║ ██║ ███████║██║ ██║██╔██╗ ██║██║ ██║█████╗ ██████╔╝ ║
37 | ║ ██║ ██╔══██║██║ ██║██║╚██╗██║██║ ██║██╔══╝ ██╔══██╗ ║
38 | ║ ██║ ██║ ██║╚██████╔╝██║ ╚████║██████╔╝███████╗██║ ██║ ║
39 | ║ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝╚═════╝ ╚══════╝╚═╝ ╚═╝ ║
40 | ║ ║
41 | ║ File Streaming Bot v{VERSION} ║
42 | ╚═══════════════════════════════════════════════════════════════════╝
43 | """
44 | logger.info(banner)
45 |
46 |
47 | async def import_plugins():
48 | """Import all plugins from the plugins directory."""
49 | logger.info("╔══════════════════ IMPORTING PLUGINS ═══════════════════╗")
50 | plugins = glob.glob(PLUGIN_PATH)
51 |
52 | if not plugins:
53 | logger.warning("No plugins found to import!")
54 | return 0
55 |
56 | success_count = 0
57 | failed_plugins = []
58 |
59 | for file_path in plugins:
60 | try:
61 | plugin_path = Path(file_path)
62 | plugin_name = plugin_path.stem
63 | import_path = f"Thunder.bot.plugins.{plugin_name}"
64 |
65 | spec = importlib.util.spec_from_file_location(import_path, plugin_path)
66 | if spec is None or spec.loader is None:
67 | logger.error(f"Invalid plugin specification for {plugin_name}")
68 | failed_plugins.append(plugin_name)
69 | continue
70 |
71 | module = importlib.util.module_from_spec(spec)
72 | sys.modules[import_path] = module
73 | spec.loader.exec_module(module)
74 |
75 | success_count += 1
76 | logger.info(f"▶ Successfully imported: {plugin_name}")
77 | except Exception as e:
78 | plugin_name = Path(file_path).stem
79 | logger.error(f"✖ Failed to import plugin {plugin_name}: {e}")
80 | failed_plugins.append(plugin_name)
81 |
82 | logger.info(f"▶ Total: {len(plugins)} | Success: {success_count} | Failed: {len(failed_plugins)}")
83 |
84 | if failed_plugins:
85 | logger.warning(f"▶ Failed plugins: {', '.join(failed_plugins)}")
86 |
87 | logger.info("╚════════════════════════════════════════════════════════╝")
88 | return success_count
89 |
90 |
91 | async def start_services():
92 | """Initialize and start all essential services for the bot."""
93 | start_time = datetime.now()
94 |
95 | print_banner()
96 |
97 | logger.info("╔══════════════ INITIALIZING BOT SERVICES ═══════════════╗")
98 | # Initialize Telegram Bot
99 | logger.info("▶ Starting Telegram Bot initialization...")
100 | try:
101 | await StreamBot.start()
102 | bot_info = await StreamBot.get_me()
103 | StreamBot.username = bot_info.username
104 | logger.info(f"✓ Bot initialized successfully as @{StreamBot.username}")
105 |
106 | # Check for restart message
107 | restart_message_data = await db.get_restart_message()
108 | if restart_message_data:
109 | logger.debug(f"Found restart message: {restart_message_data}")
110 | try:
111 | await StreamBot.edit_message_text(
112 | chat_id=restart_message_data["chat_id"],
113 | message_id=restart_message_data["message_id"],
114 | text=MSG_ADMIN_RESTART_DONE
115 | )
116 | await db.delete_restart_message(restart_message_data["message_id"])
117 | logger.debug("Restart message updated and deleted from DB.")
118 | except Exception as e:
119 | logger.error(f"Error processing restart message: {e}")
120 | else:
121 | logger.debug("No restart message found, starting normally.")
122 |
123 | except Exception as e:
124 | logger.error(f"✖ Failed to initialize Telegram Bot: {e}")
125 | return
126 |
127 | # Initialize Clients
128 | logger.info("▶ Starting Client initialization...")
129 | try:
130 | await initialize_clients()
131 | logger.info("✓ Clients initialized successfully")
132 | except Exception as e:
133 | logger.error(f"✖ Failed to initialize clients: {e}")
134 | return
135 | # Import Plugins
136 | await import_plugins()
137 |
138 | # Initialize Web Server
139 | logger.info("▶ Starting Web Server initialization...")
140 | try:
141 | app_runner = web.AppRunner(await web_server())
142 | await app_runner.setup()
143 | bind_address = Var.BIND_ADDRESS
144 | site = web.TCPSite(app_runner, bind_address, Var.PORT)
145 | await site.start()
146 | logger.info(f"✓ Web Server started at {bind_address}:{Var.PORT}")
147 |
148 | # Start keep-alive ping service
149 | logger.info("▶ Starting keep-alive service...")
150 | asyncio.create_task(ping_server())
151 | logger.info("✓ Keep-alive service started")
152 | except Exception as e:
153 | logger.error(f"✖ Failed to start Web Server: {e}")
154 | return
155 |
156 | # Print completion message
157 | elapsed_time = (datetime.now() - start_time).total_seconds()
158 | logger.info("╠════════════════════════════════════════════════════════╣")
159 | logger.info(f"▶ Bot Name: {bot_info.first_name}")
160 | logger.info(f"▶ Username: @{bot_info.username}")
161 | logger.info(f"▶ Server: {bind_address}:{Var.PORT}")
162 | logger.info(f"▶ Owner: {Var.OWNER_USERNAME}")
163 | logger.info(f"▶ Startup Time: {elapsed_time:.2f} seconds")
164 | logger.info("╚════════════════════════════════════════════════════════╝")
165 | logger.info("▶ Bot is now running! Press CTRL+C to stop.")
166 |
167 | # Keep the bot running
168 | await idle()
169 |
170 |
171 | if __name__ == '__main__':
172 | loop = asyncio.get_event_loop()
173 | try:
174 | loop.run_until_complete(start_services())
175 | except KeyboardInterrupt:
176 | logger.info("╔═══════════════════════════════════════════════════════╗")
177 | logger.info("║ Bot stopped by user (CTRL+C) ║")
178 | logger.info("╚═══════════════════════════════════════════════════════╝")
179 | except Exception as e:
180 | logger.error(f"An unexpected error occurred: {e}")
181 | finally:
182 | loop.close()
183 |
--------------------------------------------------------------------------------
/Thunder/bot/__init__.py:
--------------------------------------------------------------------------------
1 | # Thunder/bot/__init__.py
2 |
3 | from pyrogram import Client
4 | import pyromod.listen
5 | from Thunder.vars import Var
6 | from os import getcwd
7 | from Thunder.utils.logger import logger
8 |
9 | # Initialize the main bot client
10 | StreamBot = Client(
11 | name="Web Streamer",
12 | api_id=Var.API_ID,
13 | api_hash=Var.API_HASH,
14 | bot_token=Var.BOT_TOKEN,
15 | sleep_threshold=Var.SLEEP_THRESHOLD,
16 | workers=Var.WORKERS
17 | )
18 |
19 | # Dictionary to hold multiple client instances if needed
20 | multi_clients = {}
21 |
22 | # Dictionary to manage workloads and distribution across clients
23 | work_loads = {}
24 |
--------------------------------------------------------------------------------
/Thunder/bot/clients.py:
--------------------------------------------------------------------------------
1 | # Thunder/bot/clients.py
2 |
3 | import asyncio
4 | from pyrogram import Client
5 | from Thunder.vars import Var
6 | from Thunder.utils.config_parser import TokenParser
7 | from Thunder.bot import multi_clients, work_loads, StreamBot
8 | from Thunder.utils.logger import logger
9 |
10 |
11 | async def initialize_clients():
12 | """Initializes multiple Pyrogram client instances based on tokens found in the environment."""
13 |
14 | logger.info("╔═══════════════ INITIALIZING PRIMARY CLIENT ═══════════════╗")
15 | multi_clients[0] = StreamBot
16 | work_loads[0] = 0
17 | logger.info("✓ Primary client initialized successfully")
18 | logger.info("╚═══════════════════════════════════════════════════════════╝")
19 |
20 | # Parse tokens from the environment
21 | logger.info("╔═══════════════ PARSING ADDITIONAL TOKENS ═════════════════╗")
22 | try:
23 | all_tokens = TokenParser().parse_from_env()
24 | if not all_tokens:
25 | logger.info("▶ No additional clients found. Default client will be used.")
26 | logger.info("╚═══════════════════════════════════════════════════════════╝")
27 | return
28 | except Exception as e:
29 | logger.error(f"▶ Error parsing additional tokens: {e}")
30 | logger.info("▶ Default client will be used.")
31 | logger.info("╚═══════════════════════════════════════════════════════════╝")
32 | return
33 |
34 | logger.info(f"▶ Found {len(all_tokens)} additional tokens")
35 | logger.info("╚═══════════════════════════════════════════════════════════╝")
36 |
37 | async def start_client(client_id, token):
38 | """Starts an individual Pyrogram client."""
39 | try:
40 | logger.info(f"▶ Initializing Client ID: {client_id}...")
41 | if client_id == len(all_tokens):
42 | await asyncio.sleep(2)
43 | logger.info("▶ This is the last client. Initialization may take a while, please wait...")
44 |
45 | client = await Client(
46 | name=str(client_id),
47 | api_id=Var.API_ID,
48 | api_hash=Var.API_HASH,
49 | bot_token=token,
50 | sleep_threshold=Var.SLEEP_THRESHOLD,
51 | no_updates=True,
52 | in_memory=True
53 | ).start()
54 | work_loads[client_id] = 0
55 | logger.info(f"✓ Client ID {client_id} started successfully")
56 | return client_id, client
57 | except Exception as e:
58 | logger.error(f"✖ Failed to start Client ID {client_id}. Error: {e}")
59 | return None
60 |
61 | # Start all clients concurrently and filter out any that failed
62 | logger.info("╔═════════════ STARTING ADDITIONAL CLIENTS ═════════════════╗")
63 | clients = await asyncio.gather(*[start_client(i, token) for i, token in all_tokens.items() if token])
64 | clients = [client for client in clients if client] # Filter out None values
65 |
66 | # Update the global multi_clients dictionary
67 | multi_clients.update(dict(clients))
68 |
69 | if len(multi_clients) > 1:
70 | Var.MULTI_CLIENT = True
71 | logger.info("╔════════════════ MULTI-CLIENT SUMMARY ══════════════════╗")
72 | logger.info(f"✓ Multi-Client Mode Enabled")
73 | logger.info(f"✓ Total Clients: {len(multi_clients)} (Including primary client)")
74 |
75 | # Display workload distribution
76 | logger.info("▶ Initial workload distribution:")
77 | for client_id, load in work_loads.items():
78 | logger.info(f" • Client {client_id}: {load} tasks")
79 |
80 | logger.info("╚════════════════════════════════════════════════════════╝")
81 | else:
82 | logger.info("╔════════════════════════════════════════════════════════╗")
83 | logger.info("▶ No additional clients were initialized")
84 | logger.info("▶ Default client will handle all requests")
85 | logger.info("╚════════════════════════════════════════════════════════╝")
86 |
87 | logger.info("╔════════════════════════════════════════════════════════╗")
88 | logger.info("✓ Client initialization completed successfully")
89 | logger.info("╚════════════════════════════════════════════════════════╝")
90 |
--------------------------------------------------------------------------------
/Thunder/bot/plugins/admin.py:
--------------------------------------------------------------------------------
1 | # Thunder/bot/plugins/admin.py
2 |
3 | import os
4 | import sys
5 | import time
6 | import asyncio
7 | import shutil
8 | import psutil
9 | import random
10 | import string
11 | import html
12 | import uuid
13 | from datetime import datetime
14 | from pyrogram.client import Client
15 | from pyrogram import filters, StopPropagation
16 | from pyrogram.enums import ParseMode
17 | from pyrogram.types import (
18 | InlineKeyboardButton,
19 | InlineKeyboardMarkup,
20 | Message,
21 | LinkPreviewOptions
22 | )
23 | from pyrogram.errors import (
24 | FloodWait,
25 | UserDeactivated,
26 | ChatWriteForbidden,
27 | UserIsBlocked,
28 | PeerIdInvalid,
29 | FileReferenceExpired,
30 | FileReferenceInvalid,
31 | BadRequest
32 | )
33 |
34 | from Thunder.bot import StreamBot, multi_clients, work_loads
35 | from Thunder.vars import Var
36 | from Thunder import StartTime, __version__
37 | from Thunder.utils.human_readable import humanbytes
38 | from Thunder.utils.time_format import get_readable_time
39 | from Thunder.utils.logger import logger, LOG_FILE
40 | from Thunder.utils.tokens import authorize, deauthorize, list_allowed
41 | from Thunder.utils.database import db
42 | from Thunder.utils.messages import *
43 | from Thunder.utils.retry_api import retry_send_message
44 |
45 | broadcast_ids = {}
46 | RATE_LIMIT = 25
47 | BATCH_SIZE = 30
48 | MAX_CONCURRENT = 10
49 |
50 | class RateLimiter:
51 | def __init__(self, rate=RATE_LIMIT):
52 | self.rate = rate
53 | self.tokens = rate
54 | self.last_update = time.time()
55 | self.lock = asyncio.Lock()
56 |
57 | async def acquire(self):
58 | async with self.lock:
59 | now = time.time()
60 | elapsed = now - self.last_update
61 | self.tokens = min(self.rate, self.tokens + elapsed * self.rate)
62 | self.last_update = now
63 |
64 | if self.tokens >= 1:
65 | self.tokens -= 1
66 | return
67 |
68 | wait_time = (1 - self.tokens) / self.rate
69 | await asyncio.sleep(wait_time)
70 | self.tokens = 0
71 |
72 | rate_limiter = RateLimiter()
73 |
74 | def generate_unique_id(length=6):
75 | while True:
76 | random_id = ''.join(random.choices(string.ascii_letters + string.digits, k=length))
77 | if random_id not in broadcast_ids:
78 | return random_id
79 |
80 | async def get_users_in_batches(batch_size=BATCH_SIZE):
81 | users_cursor = await db.get_all_users()
82 | current_batch = []
83 | async for user in users_cursor:
84 | current_batch.append(user)
85 | if len(current_batch) >= batch_size:
86 | yield current_batch
87 | current_batch = []
88 | if current_batch:
89 | yield current_batch
90 |
91 | async def handle_flood_wait(e, attempt=0):
92 | wait_time = max(e.value, 1) * (1.5 ** attempt)
93 | wait_time = min(wait_time, 300)
94 | await asyncio.sleep(wait_time + random.uniform(0.1, 0.5))
95 |
96 | @StreamBot.on_message(filters.command("users") & filters.private & filters.user(Var.OWNER_ID))
97 | async def get_total_users(client, message):
98 | try:
99 | total_users = await db.total_users_count()
100 | reply_markup = InlineKeyboardMarkup([
101 | [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
102 | ])
103 | await message.reply_text(
104 | MSG_DB_STATS.format(total_users=total_users),
105 | quote=True,
106 | parse_mode=ParseMode.MARKDOWN,
107 | reply_markup=reply_markup
108 | )
109 | except Exception:
110 | await message.reply_text(MSG_DB_ERROR)
111 |
112 | @StreamBot.on_message(filters.command("broadcast") & filters.private & filters.user(Var.OWNER_ID))
113 | async def broadcast_message(client, message):
114 | if not message.reply_to_message:
115 | await message.reply_text(MSG_INVALID_BROADCAST_CMD, quote=True)
116 | return
117 |
118 | broadcast_id = generate_unique_id()
119 | broadcast_ids[broadcast_id] = {
120 | "total": 0,
121 | "processed": 0,
122 | "success": 0,
123 | "failed": 0,
124 | "deleted": 0,
125 | "start_time": time.time(),
126 | "is_cancelled": False,
127 | "last_update": 0
128 | }
129 |
130 | reply_markup = InlineKeyboardMarkup([
131 | [InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_broadcast_{broadcast_id}")]
132 | ])
133 | output = await message.reply_text(MSG_BROADCAST_START, reply_markup=reply_markup)
134 |
135 | try:
136 | start_time = time.time()
137 | total_users = await db.total_users_count()
138 | broadcast_ids[broadcast_id]["total"] = total_users
139 |
140 | stats = broadcast_ids[broadcast_id]
141 | self_id = client.me.id
142 |
143 | async def update_progress():
144 | while stats["processed"] < stats["total"] and not stats["is_cancelled"]:
145 | current_time = time.time()
146 | if current_time - stats["last_update"] >= 5:
147 | try:
148 | progress_text = MSG_BROADCAST_PROGRESS.format(
149 | total_users=stats["total"],
150 | processed=stats["processed"],
151 | elapsed_time=get_readable_time(int(current_time - start_time)),
152 | successes=stats["success"],
153 | failures=stats["failed"]
154 | )
155 | await output.edit_text(progress_text)
156 | stats["last_update"] = current_time
157 | except BadRequest as e:
158 | if "Message is not modified" not in str(e):
159 | break
160 | except Exception:
161 | break
162 | await asyncio.sleep(2)
163 |
164 | progress_task = asyncio.create_task(update_progress())
165 |
166 | async def send_to_user(user_id: int):
167 | if stats["is_cancelled"] or user_id == self_id:
168 | return
169 |
170 | await rate_limiter.acquire()
171 |
172 | for attempt in range(3):
173 | if stats["is_cancelled"]:
174 | return
175 |
176 | try:
177 | if message.reply_to_message.text or message.reply_to_message.caption:
178 | await client.send_message(
179 | chat_id=user_id,
180 | text=message.reply_to_message.text or message.reply_to_message.caption,
181 | parse_mode=ParseMode.MARKDOWN,
182 | link_preview_options=LinkPreviewOptions(is_disabled=True)
183 | )
184 | elif message.reply_to_message.media:
185 | await message.reply_to_message.copy(chat_id=user_id)
186 |
187 | stats["success"] += 1
188 | break
189 |
190 | except FloodWait as e:
191 | await handle_flood_wait(e, attempt)
192 | continue
193 |
194 | except (UserDeactivated, ChatWriteForbidden, UserIsBlocked, PeerIdInvalid):
195 | try:
196 | await db.delete_user(user_id)
197 | stats["deleted"] += 1
198 | except Exception:
199 | pass
200 | stats["failed"] += 1
201 | break
202 |
203 | except (FileReferenceExpired, FileReferenceInvalid):
204 | if attempt == 2:
205 | stats["failed"] += 1
206 | break
207 | await asyncio.sleep(1)
208 |
209 | except Exception:
210 | stats["failed"] += 1
211 | break
212 |
213 | stats["processed"] += 1
214 |
215 | semaphore = asyncio.Semaphore(MAX_CONCURRENT)
216 |
217 | async def process_user(user_id):
218 | async with semaphore:
219 | await send_to_user(int(user_id))
220 |
221 | async for user_batch in get_users_in_batches():
222 | if stats["is_cancelled"]:
223 | break
224 |
225 | batch_tasks = [process_user(user['id']) for user in user_batch]
226 | await asyncio.gather(*batch_tasks, return_exceptions=True)
227 |
228 | if not stats["is_cancelled"]:
229 | await asyncio.sleep(1)
230 |
231 | progress_task.cancel()
232 | try:
233 | await progress_task
234 | except asyncio.CancelledError:
235 | pass
236 |
237 | elapsed_time = get_readable_time(time.time() - start_time)
238 |
239 | try:
240 | await output.delete()
241 | except Exception:
242 | pass
243 |
244 | completion_text = MSG_BROADCAST_COMPLETE.format(
245 | elapsed_time=elapsed_time,
246 | total_users=stats["total"],
247 | successes=stats["success"],
248 | failures=stats["failed"],
249 | deleted_accounts=stats["deleted"]
250 | )
251 |
252 | reply_markup = InlineKeyboardMarkup([
253 | [InlineKeyboardButton(MSG_ADMIN_RESTART_BROADCAST, callback_data="restart_broadcast")]
254 | ])
255 | await message.reply_text(
256 | completion_text,
257 | parse_mode=ParseMode.MARKDOWN,
258 | link_preview_options=LinkPreviewOptions(is_disabled=True),
259 | reply_markup=reply_markup
260 | )
261 |
262 | except Exception as e:
263 | await message.reply_text(
264 | MSG_BROADCAST_FAILED.format(error=str(e), error_id=uuid.uuid4().hex[:8])
265 | )
266 | finally:
267 | broadcast_ids.pop(broadcast_id, None)
268 |
269 | @StreamBot.on_message(filters.command("cancel_broadcast") & filters.private & filters.user(Var.OWNER_ID))
270 | async def cancel_broadcast(client, message):
271 | if not broadcast_ids:
272 | await message.reply_text(MSG_NO_ACTIVE_BROADCASTS)
273 | return
274 |
275 | if len(broadcast_ids) == 1:
276 | broadcast_id = list(broadcast_ids.keys())[0]
277 | broadcast_ids[broadcast_id]["is_cancelled"] = True
278 | await message.reply_text(MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id))
279 | return
280 |
281 | keyboard = []
282 | for broadcast_id, info in broadcast_ids.items():
283 | progress = f"{info['processed']}/{info['total']}" if info['total'] else "Unknown"
284 | elapsed = get_readable_time(time.time() - info['start_time'])
285 | keyboard.append([
286 | InlineKeyboardButton(
287 | f"Cancel {broadcast_id} ({progress}) - {elapsed}",
288 | callback_data=f"cancel_broadcast_{broadcast_id}"
289 | )
290 | ])
291 |
292 | await message.reply_text(
293 | MSG_MULTIPLE_BROADCASTS,
294 | reply_markup=InlineKeyboardMarkup(keyboard)
295 | )
296 |
297 | @StreamBot.on_callback_query(filters.regex(r"^cancel_broadcast_(.+)$"))
298 | async def handle_cancel_broadcast(client, callback_query):
299 | broadcast_id = callback_query.data.split("_")[-1]
300 | if broadcast_id in broadcast_ids:
301 | broadcast_ids[broadcast_id]["is_cancelled"] = True
302 | await callback_query.edit_message_text(
303 | MSG_CANCELLING_BROADCAST.format(broadcast_id=broadcast_id)
304 | )
305 | else:
306 | await callback_query.edit_message_text(MSG_BROADCAST_NOT_FOUND)
307 |
308 | @StreamBot.on_message(filters.command("status") & filters.private & filters.user(Var.OWNER_ID))
309 | async def show_status(client, message):
310 | try:
311 | uptime = get_readable_time(int(time.time() - StartTime))
312 | workloads_text = MSG_ADMIN_BOT_WORKLOAD_HEADER
313 | workloads = {
314 | f"🔹 Bot {c + 1}": load
315 | for c, (bot, load) in enumerate(
316 | sorted(work_loads.items(), key=lambda x: x[1], reverse=True)
317 | )
318 | }
319 |
320 | for bot_name, load in workloads.items():
321 | workloads_text += MSG_ADMIN_BOT_WORKLOAD_ITEM.format(bot_name=bot_name, load=load)
322 |
323 | stats_text = MSG_SYSTEM_STATUS.format(
324 | uptime=uptime,
325 | active_bots=len(multi_clients),
326 | workloads=workloads_text,
327 | version=__version__
328 | )
329 |
330 | reply_markup = InlineKeyboardMarkup([
331 | [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
332 | ])
333 | await message.reply_text(stats_text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup)
334 | except Exception:
335 | await message.reply_text(MSG_STATUS_ERROR)
336 |
337 | @StreamBot.on_message(filters.command("stats") & filters.private & filters.user(Var.OWNER_ID))
338 | async def show_stats(client, message):
339 | try:
340 | current_time = get_readable_time(int(time.time() - StartTime))
341 | total, used, free = shutil.disk_usage('.')
342 |
343 | stats_text = MSG_SYSTEM_STATS.format(
344 | uptime=current_time,
345 | total=humanbytes(total),
346 | used=humanbytes(used),
347 | free=humanbytes(free),
348 | upload=humanbytes(psutil.net_io_counters().bytes_sent),
349 | download=humanbytes(psutil.net_io_counters().bytes_recv)
350 | )
351 |
352 | stats_text += MSG_PERFORMANCE_STATS.format(
353 | cpu_percent=psutil.cpu_percent(interval=0.5),
354 | ram_percent=psutil.virtual_memory().percent,
355 | disk_percent=psutil.disk_usage('.').percent
356 | )
357 |
358 | reply_markup = InlineKeyboardMarkup([
359 | [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
360 | ])
361 | await message.reply_text(stats_text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup)
362 | except Exception:
363 | await message.reply_text(MSG_STATUS_ERROR)
364 |
365 | @StreamBot.on_message(filters.command("restart") & filters.private & filters.user(Var.OWNER_ID))
366 | async def restart_bot(client, message):
367 | try:
368 | sent_message = await message.reply_text(MSG_RESTARTING)
369 | await db.add_restart_message(sent_message.id, message.chat.id)
370 | os.execv(sys.executable, [sys.executable, "-m", "Thunder"])
371 | except Exception:
372 | await message.reply_text(MSG_RESTART_FAILED)
373 |
374 | @StreamBot.on_message(filters.command("log") & filters.private & filters.user(Var.OWNER_ID))
375 | async def send_logs(client, message):
376 | try:
377 | if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) == 0:
378 | await message.reply_text(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY)
379 | return
380 |
381 | await client.send_document(
382 | chat_id=message.chat.id,
383 | document=LOG_FILE,
384 | caption=MSG_LOG_FILE_CAPTION,
385 | parse_mode=ParseMode.MARKDOWN
386 | )
387 | except Exception as e:
388 | await message.reply_text(MSG_LOG_ERROR.format(error=str(e)))
389 |
390 | @StreamBot.on_message(filters.command("authorize") & filters.private & filters.user(Var.OWNER_ID))
391 | async def authorize_command(client, message):
392 | if len(message.command) < 2:
393 | await message.reply_text(MSG_AUTHORIZE_USAGE)
394 | return
395 |
396 | try:
397 | user_id_to_authorize = int(message.command[1])
398 | authorized = await authorize(user_id_to_authorize, authorized_by=message.from_user.id)
399 | await message.reply_text(
400 | MSG_AUTHORIZE_SUCCESS.format(user_id=user_id_to_authorize) if authorized
401 | else MSG_AUTHORIZE_FAILED.format(user_id=user_id_to_authorize)
402 | )
403 | except ValueError:
404 | await message.reply_text(MSG_INVALID_USER_ID)
405 | except Exception:
406 | await message.reply_text(MSG_ERROR_GENERIC)
407 |
408 | @StreamBot.on_message(filters.command("deauthorize") & filters.private & filters.user(Var.OWNER_ID))
409 | async def deauthorize_command(client, message):
410 | if len(message.command) < 2:
411 | await message.reply_text(MSG_DEAUTHORIZE_USAGE)
412 | return
413 |
414 | try:
415 | user_id_to_deauthorize = int(message.command[1])
416 | deauthorized = await deauthorize(user_id_to_deauthorize)
417 | await message.reply_text(
418 | MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id_to_deauthorize) if deauthorized
419 | else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id_to_deauthorize)
420 | )
421 | except ValueError:
422 | await message.reply_text(MSG_INVALID_USER_ID)
423 | except Exception:
424 | await message.reply_text(MSG_ERROR_GENERIC)
425 |
426 | @StreamBot.on_message(filters.command("listauth") & filters.private & filters.user(Var.OWNER_ID))
427 | async def list_authorized_command(client, message):
428 | try:
429 | authorized_users = await list_allowed()
430 | if not authorized_users:
431 | await message.reply_text(MSG_NO_AUTH_USERS)
432 | return
433 |
434 | message_text = MSG_ADMIN_AUTH_LIST_HEADER
435 | for i, user in enumerate(authorized_users, 1):
436 | message_text += MSG_AUTH_USER_INFO.format(
437 | i=i,
438 | user_id=user['user_id'],
439 | authorized_by=user['authorized_by'],
440 | auth_time=user['authorized_at']
441 | )
442 |
443 | reply_markup = InlineKeyboardMarkup([
444 | [InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
445 | ])
446 | await message.reply_text(
447 | message_text,
448 | parse_mode=ParseMode.MARKDOWN,
449 | link_preview_options=LinkPreviewOptions(is_disabled=True),
450 | reply_markup=reply_markup
451 | )
452 | except Exception:
453 | await message.reply_text(MSG_ERROR_GENERIC)
454 |
455 | @StreamBot.on_message(filters.command("ban") & filters.private & filters.user(Var.OWNER_ID))
456 | async def ban_user_command(client, message):
457 | if len(message.command) < 2:
458 | await message.reply_text(MSG_BAN_USAGE)
459 | return
460 |
461 | try:
462 | user_id_to_ban = int(message.command[1])
463 | if user_id_to_ban in (Var.OWNER_ID if isinstance(Var.OWNER_ID, list) else [Var.OWNER_ID]):
464 | await message.reply_text(MSG_CANNOT_BAN_OWNER)
465 | return
466 |
467 | reason = " ".join(message.command[2:]) if len(message.command) > 2 else MSG_ADMIN_NO_BAN_REASON
468 | ban_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
469 |
470 | await db.add_banned_user(
471 | user_id=user_id_to_ban,
472 | reason=reason,
473 | ban_time=ban_time,
474 | banned_by=message.from_user.id
475 | )
476 |
477 | reply_text = MSG_ADMIN_USER_BANNED.format(user_id=user_id_to_ban)
478 | if reason != MSG_ADMIN_NO_BAN_REASON:
479 | reply_text += MSG_BAN_REASON_SUFFIX.format(reason=reason)
480 | await message.reply_text(reply_text)
481 |
482 | try:
483 | await retry_send_message(client, chat_id=user_id_to_ban, text=MSG_USER_BANNED_NOTIFICATION)
484 | except Exception:
485 | pass
486 |
487 | except ValueError:
488 | await message.reply_text(MSG_INVALID_USER_ID)
489 | except Exception as e:
490 | await message.reply_text(MSG_BAN_ERROR.format(error=str(e)))
491 |
492 | @StreamBot.on_message(filters.command("unban") & filters.private & filters.user(Var.OWNER_ID))
493 | async def unban_user_command(client, message):
494 | if len(message.command) < 2:
495 | await message.reply_text(MSG_UNBAN_USAGE)
496 | return
497 |
498 | try:
499 | user_id_to_unban = int(message.command[1])
500 | removed = await db.remove_banned_user(user_id=user_id_to_unban)
501 |
502 | if removed:
503 | await message.reply_text(MSG_ADMIN_USER_UNBANNED.format(user_id=user_id_to_unban))
504 | try:
505 | await retry_send_message(client, chat_id=user_id_to_unban, text=MSG_USER_UNBANNED_NOTIFICATION)
506 | except Exception:
507 | pass
508 | else:
509 | await message.reply_text(MSG_USER_NOT_IN_BAN_LIST.format(user_id=user_id_to_unban))
510 |
511 | except ValueError:
512 | await message.reply_text(MSG_INVALID_USER_ID)
513 | except Exception as e:
514 | await message.reply_text(MSG_UNBAN_ERROR.format(error=str(e)))
515 |
516 | @StreamBot.on_message(filters.command("shell") & filters.private & filters.user(Var.OWNER_ID))
517 | async def run_shell_command(client: Client, message: Message):
518 | if len(message.command) < 2:
519 | await message.reply_text(MSG_SHELL_USAGE, parse_mode=ParseMode.HTML)
520 | return
521 |
522 | command_to_run = " ".join(message.command[1:])
523 | reply_msg = await message.reply_text(
524 | MSG_SHELL_EXECUTING.format(command=html.escape(command_to_run)),
525 | parse_mode=ParseMode.HTML,
526 | quote=True
527 | )
528 |
529 | try:
530 | process = await asyncio.create_subprocess_shell(
531 | command_to_run,
532 | stdout=asyncio.subprocess.PIPE,
533 | stderr=asyncio.subprocess.PIPE
534 | )
535 | stdout, stderr = await process.communicate()
536 |
537 | output = ""
538 | if stdout:
539 | output += MSG_SHELL_OUTPUT_STDOUT.format(output=html.escape(stdout.decode().strip()))
540 | if stderr:
541 | output += MSG_SHELL_OUTPUT_STDERR.format(error=html.escape(stderr.decode().strip()))
542 | if not output:
543 | output = MSG_SHELL_NO_OUTPUT
544 |
545 | except Exception as e:
546 | output = MSG_SHELL_ERROR.format(error=html.escape(str(e)))
547 |
548 | if len(output) > 4096:
549 | try:
550 | filename = f"shell_output_{int(time.time())}.txt"
551 | with open(filename, "w", encoding="utf-8") as file:
552 | if stdout:
553 | file.write(f"STDOUT:\n{stdout.decode()}\n\n")
554 | if stderr:
555 | file.write(f"STDERR:\n{stderr.decode()}")
556 |
557 | await client.send_document(
558 | chat_id=message.chat.id,
559 | document=filename,
560 | caption=MSG_SHELL_OUTPUT.format(command=html.escape(command_to_run)),
561 | parse_mode=ParseMode.HTML
562 | )
563 | os.remove(filename)
564 | except Exception:
565 | await message.reply_text("Output too large and file creation failed", parse_mode=ParseMode.HTML)
566 | else:
567 | await message.reply_text(output, parse_mode=ParseMode.HTML)
568 |
569 | try:
570 | await reply_msg.delete()
571 | except Exception:
572 | pass
573 |
574 | @StreamBot.on_callback_query(filters.regex("close_panel"))
575 | async def close_panel(client, callback_query):
576 | try:
577 | await callback_query.answer()
578 | await callback_query.message.delete()
579 |
580 | if callback_query.message.reply_to_message:
581 | try:
582 | ctx = callback_query.message.reply_to_message
583 | await ctx.delete()
584 | if ctx.reply_to_message:
585 | await ctx.reply_to_message.delete()
586 | except Exception:
587 | pass
588 |
589 | except Exception:
590 | pass
591 | finally:
592 | raise StopPropagation
593 |
594 | @StreamBot.on_callback_query(filters.regex("restart_broadcast"))
595 | async def restart_broadcast(client, callback_query):
596 | try:
597 | await callback_query.edit_message_text(
598 | MSG_ERROR_BROADCAST_INSTRUCTION,
599 | parse_mode=ParseMode.MARKDOWN
600 | )
601 | except Exception:
602 | pass
603 |
--------------------------------------------------------------------------------
/Thunder/bot/plugins/callbacks.py:
--------------------------------------------------------------------------------
1 | # Thunder/bot/plugins/callbacks.py
2 |
3 | import uuid
4 |
5 | from pyrogram import Client, filters, StopPropagation
6 | from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, LinkPreviewOptions
7 | from pyrogram.errors import MessageNotModified
8 | from Thunder.bot import StreamBot
9 | from Thunder.vars import Var
10 | from Thunder.utils.logger import logger
11 | from Thunder.utils.messages import *
12 | from Thunder.utils.decorators import owner_only
13 | from Thunder.utils.retry_api import retry_get_chat
14 |
15 | async def handle_callback_error(callback_query, error, operation="callback"):
16 | error_id = uuid.uuid4().hex[:8]
17 | logger.error(f"Error in {operation}: {error}")
18 | await callback_query.answer(
19 | MSG_ERROR_GENERIC_CALLBACK.format(error_id=error_id),
20 | show_alert=True
21 | )
22 |
23 | async def get_force_channel_button(client):
24 | if not Var.FORCE_CHANNEL_ID:
25 | return None
26 | try:
27 | chat = await retry_get_chat(client, Var.FORCE_CHANNEL_ID)
28 | invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
29 | if invite_link:
30 | return [InlineKeyboardButton(
31 | MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title),
32 | url=invite_link
33 | )]
34 | else:
35 | logger.warning(f"Could not construct invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID}")
36 | return None
37 | except Exception as e:
38 | logger.error(f"Error creating force channel button: {e}")
39 | return None
40 |
41 | @StreamBot.on_callback_query(filters.regex(r"^help_command$"))
42 | async def help_callback(client: Client, callback_query: CallbackQuery):
43 | try:
44 | await callback_query.answer()
45 | buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]]
46 | force_button = await get_force_channel_button(client)
47 | if force_button:
48 | buttons.append(force_button)
49 | buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")])
50 | await callback_query.message.edit_text(
51 | text=MSG_HELP,
52 | reply_markup=InlineKeyboardMarkup(buttons),
53 | link_preview_options=LinkPreviewOptions(is_disabled=True)
54 | )
55 | logger.debug(f"User {callback_query.from_user.id} accessed help panel.")
56 | except MessageNotModified:
57 | logger.debug(f"Help panel already displayed for user {callback_query.from_user.id}")
58 | except Exception as e:
59 | await handle_callback_error(callback_query, e, "help_callback")
60 | finally:
61 | raise StopPropagation
62 |
63 | @StreamBot.on_callback_query(filters.regex(r"^about_command$"))
64 | async def about_callback(client: Client, callback_query: CallbackQuery):
65 | try:
66 | await callback_query.answer()
67 | buttons = [
68 | [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")],
69 | [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"),
70 | InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
71 | ]
72 | await callback_query.message.edit_text(
73 | text=MSG_ABOUT,
74 | reply_markup=InlineKeyboardMarkup(buttons),
75 | link_preview_options=LinkPreviewOptions(is_disabled=True)
76 | )
77 | logger.debug(f"User {callback_query.from_user.id} accessed about panel.")
78 | except MessageNotModified:
79 | logger.debug(f"About panel already displayed for user {callback_query.from_user.id}")
80 | except Exception as e:
81 | await handle_callback_error(callback_query, e, "about_callback")
82 | finally:
83 | raise StopPropagation
84 |
85 | @StreamBot.on_callback_query(filters.regex(r"^restart_broadcast$"))
86 | @owner_only
87 | async def restart_broadcast_callback(client: Client, callback_query: CallbackQuery):
88 | try:
89 | await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True)
90 | buttons = [
91 | [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
92 | InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
93 | ]
94 | await callback_query.message.edit_text(
95 | MSG_ERROR_BROADCAST_INSTRUCTION,
96 | reply_markup=InlineKeyboardMarkup(buttons),
97 | link_preview_options=LinkPreviewOptions(is_disabled=True)
98 | )
99 | logger.debug(f"User {callback_query.from_user.id} viewed broadcast restart instruction.")
100 | except Exception as e:
101 | await handle_callback_error(callback_query, e, "restart_broadcast_callback")
102 | finally:
103 | raise StopPropagation
104 |
105 | @StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
106 | async def close_panel_callback(client: Client, callback_query: CallbackQuery):
107 | try:
108 | await callback_query.answer()
109 | await callback_query.message.delete()
110 | logger.debug(f"User {callback_query.from_user.id} closed panel")
111 | if callback_query.message.reply_to_message:
112 | try:
113 | ctx = callback_query.message.reply_to_message
114 | await ctx.delete()
115 | if ctx.reply_to_message:
116 | await ctx.reply_to_message.delete()
117 | except Exception as e:
118 | logger.warning(f"Error deleting command messages: {e}")
119 | except Exception as e:
120 | await handle_callback_error(callback_query, e, "close_panel_callback")
121 | finally:
122 | raise StopPropagation
123 |
124 | @StreamBot.on_callback_query(group=999)
125 | async def fallback_callback(client: Client, callback_query: CallbackQuery):
126 | try:
127 | logger.debug(f"Unhandled callback query: {callback_query.data} from user {callback_query.from_user.id}")
128 | await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True)
129 | except Exception as e:
130 | logger.error(f"Error in fallback_callback: {e}")
131 | raise StopPropagation
132 |
--------------------------------------------------------------------------------
/Thunder/bot/plugins/common.py:
--------------------------------------------------------------------------------
1 | # Thunder/bot/plugins/common.py
2 |
3 | import time
4 | import asyncio
5 | import uuid
6 | from datetime import datetime, timedelta
7 | from typing import Tuple, Optional
8 | from urllib.parse import quote_plus
9 | from pyrogram import filters, StopPropagation
10 | from pyrogram.client import Client
11 | from pyrogram.errors import RPCError, MessageNotModified
12 | from pyrogram.types import (
13 | InlineKeyboardButton,
14 | InlineKeyboardMarkup,
15 | Message,
16 | User,
17 | CallbackQuery,
18 | LinkPreviewOptions
19 | )
20 | from Thunder.bot import StreamBot
21 | from Thunder.vars import Var
22 | from Thunder.utils.database import db
23 | from Thunder.utils.messages import *
24 | from Thunder.utils.human_readable import humanbytes
25 | from Thunder.utils.file_properties import get_media_file_size, get_name
26 | from Thunder.utils.force_channel import force_channel_check
27 | from Thunder.utils.logger import logger
28 | from Thunder.utils.tokens import check
29 | from Thunder.utils.decorators import check_banned
30 | from Thunder.utils.bot_utils import (
31 | notify_channel,
32 | log_new_user,
33 | generate_media_links,
34 | handle_user_error,
35 | generate_dc_text,
36 | get_user_safely
37 | )
38 |
39 | def has_media(message):
40 | return any(
41 | hasattr(message, attr) and getattr(message, attr)
42 | for attr in ["document", "photo", "video", "audio", "voice",
43 | "sticker", "animation", "video_note"]
44 | )
45 |
46 | @check_banned
47 | @StreamBot.on_message(filters.command("start") & filters.private)
48 | async def start_command(bot: Client, message: Message):
49 | user_id = message.from_user.id if message.from_user else None
50 | user_id_str = str(user_id) if user_id else 'unknown'
51 |
52 | try:
53 | if message.from_user:
54 | await log_new_user(bot, user_id, message.from_user.first_name)
55 |
56 | if len(message.command) == 2:
57 | payload = message.command[1]
58 |
59 | token_doc = await db.token_col.find_one({"token": payload})
60 |
61 | if token_doc:
62 | if token_doc["user_id"] == user_id:
63 | if not token_doc.get("activated", False):
64 | token_expires_at = token_doc.get("expires_at")
65 | if isinstance(token_expires_at, datetime) and token_expires_at > datetime.utcnow():
66 | await db.token_col.update_one(
67 | {"token": payload, "user_id": user_id},
68 | {"$set": {"activated": True}}
69 | )
70 | duration_hours = getattr(Var, "TOKEN_TTL_HOURS", 24)
71 | await message.reply_text(
72 | MSG_TOKEN_ACTIVATED.format(duration_hours=duration_hours),
73 | link_preview_options=LinkPreviewOptions(is_disabled=True),
74 | quote=True
75 | )
76 | else:
77 | await message.reply_text(
78 | MSG_TOKEN_FAILED.format(reason="This activation link has expired.", error_id=uuid.uuid4().hex[:8]),
79 | link_preview_options=LinkPreviewOptions(is_disabled=True),
80 | quote=True
81 | )
82 | else:
83 | await message.reply_text(
84 | MSG_TOKEN_FAILED.format(reason="Token has already been activated.", error_id=uuid.uuid4().hex[:8]),
85 | link_preview_options=LinkPreviewOptions(is_disabled=True),
86 | quote=True
87 | )
88 | else:
89 | logger.warning(f"User {user_id} attempted to activate token {payload} belonging to user {token_doc.get('user_id')}")
90 | await message.reply_text(
91 | MSG_TOKEN_FAILED.format(reason="This activation link is not for your account.", error_id=uuid.uuid4().hex[:8]),
92 | link_preview_options=LinkPreviewOptions(is_disabled=True),
93 | quote=True
94 | )
95 | return
96 |
97 | try:
98 | msg_id = int(payload)
99 | retrieved_messages = await bot.get_messages(chat_id=Var.BIN_CHANNEL, message_ids=msg_id)
100 | if not retrieved_messages:
101 | await handle_user_error(message, MSG_ERROR_FILE_INVALID)
102 | return
103 |
104 | file_msg = retrieved_messages[0] if isinstance(retrieved_messages, list) else retrieved_messages
105 | if not file_msg:
106 | await handle_user_error(message, MSG_ERROR_FILE_INVALID)
107 | return
108 |
109 | stream_link, download_link, file_name, file_size = await generate_media_links(file_msg)
110 | reply_text = MSG_LINKS.format(
111 | file_name=file_name,
112 | file_size=file_size,
113 | download_link=download_link,
114 | stream_link=stream_link
115 | )
116 |
117 | await message.reply_text(
118 | text=reply_text,
119 | reply_markup=InlineKeyboardMarkup([
120 | [
121 | InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
122 | InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=download_link)
123 | ]
124 | ]),
125 | quote=True,
126 | link_preview_options=LinkPreviewOptions(is_disabled=True)
127 | )
128 | return
129 | except ValueError:
130 | logger.warning(f"Invalid /start payload from user {user_id_str}: {payload}")
131 | await message.reply_text(
132 | MSG_START_INVALID_PAYLOAD.format(error_id=uuid.uuid4().hex[:8]),
133 | quote=True,
134 | link_preview_options=LinkPreviewOptions(is_disabled=True)
135 | )
136 | return
137 | except Exception as e:
138 | logger.error(f"Error processing /start payload '{payload}' for user {user_id_str}: {e}")
139 | await handle_user_error(message, MSG_FILE_ACCESS_ERROR)
140 | return
141 |
142 | parts = message.text.strip().split(maxsplit=1)
143 | if len(message.command) == 1 or (len(parts) > 1 and parts[1].lower() == "start"):
144 | welcome_text = MSG_WELCOME.format(user_name=message.from_user.first_name if message.from_user else "Guest")
145 |
146 | if Var.FORCE_CHANNEL_ID:
147 | error_id_context = uuid.uuid4().hex[:8]
148 | try:
149 | chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
150 | invite_link = getattr(chat, 'invite_link', None)
151 | chat_username = getattr(chat, 'username', None)
152 | chat_title = getattr(chat, 'title', 'Channel')
153 |
154 | if not invite_link and chat_username:
155 | invite_link = f"https://t.me/{chat_username}"
156 |
157 | if invite_link:
158 | welcome_text += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=chat_title)}"
159 | else:
160 | logger.warning(f"(ID: {error_id_context}) Could not retrieve invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /start message text (Channel: {chat_title}, User: {user_id_str}).")
161 | except Exception as e:
162 | logger.error(f"(ID: {error_id_context}) Error adding force channel link to /start message text (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e}")
163 |
164 | reply_markup_buttons = [
165 | [
166 | InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
167 | InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command"),
168 | ],
169 | [
170 | InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"),
171 | InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")
172 | ]
173 | ]
174 |
175 | if Var.FORCE_CHANNEL_ID:
176 | try:
177 | chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
178 | invite_link = getattr(chat, 'invite_link', None)
179 | chat_username = getattr(chat, 'username', None)
180 | if not invite_link and chat_username: invite_link = f"https://t.me/{chat_username}"
181 | if invite_link:
182 | reply_markup_buttons.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=getattr(chat, 'title', 'Channel')), url=invite_link)])
183 | except Exception as e:
184 | logger.error(f"Error adding force channel button to /start message (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e}")
185 |
186 | reply_markup = InlineKeyboardMarkup(reply_markup_buttons)
187 | await message.reply_text(text=welcome_text, reply_markup=reply_markup, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
188 | return
189 |
190 | except Exception as e:
191 | logger.error(f"Error in start_command for user {user_id_str}: {e}")
192 | await handle_user_error(message, MSG_ERROR_GENERIC)
193 |
194 | @check_banned
195 | @StreamBot.on_message(filters.command("help") & filters.private)
196 | async def help_command(bot: Client, message: Message):
197 | try:
198 | user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
199 | if message.from_user:
200 | await log_new_user(bot, message.from_user.id, message.from_user.first_name)
201 |
202 | help_text = MSG_HELP
203 | buttons = [
204 | [InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]
205 | ]
206 |
207 | if Var.FORCE_CHANNEL_ID:
208 | error_id_context_help = uuid.uuid4().hex[:8]
209 | try:
210 | chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
211 | invite_link = getattr(chat, 'invite_link', None)
212 | chat_username = getattr(chat, 'username', None)
213 | chat_title = getattr(chat, 'title', 'Channel')
214 |
215 | if not invite_link and chat_username:
216 | invite_link = f"https://t.me/{chat_username}"
217 |
218 | if invite_link:
219 | buttons.append([
220 | InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat_title), url=invite_link)
221 | ])
222 | else:
223 | logger.warning(f"(ID: {error_id_context_help}) Could not retrieve or construct invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /help command (User: {user_id_str}).")
224 | except Exception as e_help:
225 | logger.error(f"(ID: {error_id_context_help}) Error adding force channel button to /help command (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_help}")
226 |
227 | buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")])
228 | reply_markup = InlineKeyboardMarkup(buttons)
229 |
230 | await message.reply_text(
231 | text=help_text,
232 | reply_markup=reply_markup,
233 | quote=True,
234 | link_preview_options=LinkPreviewOptions(is_disabled=True)
235 | )
236 | except Exception as e:
237 | logger.error(f"Error in help_command: {e}")
238 | await handle_user_error(message, MSG_ERROR_GENERIC)
239 |
240 | @check_banned
241 | @StreamBot.on_message(filters.command("about") & filters.private)
242 | async def about_command(bot: Client, message: Message):
243 | try:
244 | if message.from_user:
245 | await log_new_user(bot, message.from_user.id, message.from_user.first_name)
246 |
247 | about_text = MSG_ABOUT
248 | buttons = [
249 | [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")],
250 | [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"),
251 | InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
252 | ]
253 | reply_markup = InlineKeyboardMarkup(buttons)
254 |
255 | await message.reply_text(
256 | text=about_text,
257 | reply_markup=reply_markup,
258 | quote=True,
259 | link_preview_options=LinkPreviewOptions(is_disabled=True)
260 | )
261 | except Exception as e:
262 | logger.error(f"Error in about_command: {e}")
263 | await handle_user_error(message, MSG_ERROR_GENERIC)
264 |
265 | @check_banned
266 | @force_channel_check
267 | @StreamBot.on_message(filters.command("dc"))
268 | async def dc_command(bot: Client, message: Message):
269 | try:
270 | if not message.from_user and not message.reply_to_message:
271 | await handle_user_error(message, MSG_DC_ANON_ERROR)
272 | return
273 |
274 | async def process_dc_info(user: User):
275 | dc_text = await generate_dc_text(user)
276 | buttons = []
277 |
278 | if user.username:
279 | profile_url = f"https://t.me/{user.username}"
280 | else:
281 | profile_url = f"tg://user?id={user.id}"
282 |
283 | buttons.append([
284 | InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=profile_url)
285 | ])
286 | buttons.append([
287 | InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")
288 | ])
289 |
290 | dc_keyboard = InlineKeyboardMarkup(buttons)
291 | await message.reply_text(
292 | dc_text,
293 | reply_markup=dc_keyboard,
294 | quote=True,
295 | link_preview_options=LinkPreviewOptions(is_disabled=True)
296 | )
297 |
298 | async def process_file_dc_info(file_msg: Message):
299 | try:
300 | file_name = get_name(file_msg) or "Untitled File"
301 | file_size = humanbytes(get_media_file_size(file_msg))
302 |
303 | file_type_map = {
304 | "document": MSG_FILE_TYPE_DOCUMENT,
305 | "photo": MSG_FILE_TYPE_PHOTO,
306 | "video": MSG_FILE_TYPE_VIDEO,
307 | "audio": MSG_FILE_TYPE_AUDIO,
308 | "voice": MSG_FILE_TYPE_VOICE,
309 | "sticker": MSG_FILE_TYPE_STICKER,
310 | "animation": MSG_FILE_TYPE_ANIMATION,
311 | "video_note": MSG_FILE_TYPE_VIDEO_NOTE
312 | }
313 |
314 | file_type_attr = next((attr for attr in file_type_map if getattr(file_msg, attr, None)), "unknown")
315 | file_type_display = file_type_map.get(file_type_attr, MSG_FILE_TYPE_UNKNOWN)
316 |
317 | actual_media = None
318 | if file_type_attr == "photo" and file_msg.photo and file_msg.photo.sizes:
319 | actual_media = file_msg.photo.sizes[-1]
320 | elif file_type_attr != "unknown":
321 | potential = getattr(file_msg, file_type_attr, None)
322 | if potential:
323 | actual_media = potential
324 |
325 | dc_id = MSG_DC_UNKNOWN
326 | if hasattr(file_msg, 'raw') and hasattr(file_msg.raw, 'media'):
327 | if hasattr(file_msg.raw.media, 'document') and hasattr(file_msg.raw.media.document, 'dc_id'):
328 | dc_id = file_msg.raw.media.document.dc_id
329 |
330 | dc_text = MSG_DC_FILE_INFO.format(
331 | file_name=file_name,
332 | file_size=file_size,
333 | file_type=file_type_display,
334 | dc_id=dc_id
335 | )
336 |
337 | await message.reply_text(
338 | dc_text,
339 | quote=True,
340 | link_preview_options=LinkPreviewOptions(is_disabled=True)
341 | )
342 | except Exception as e:
343 | logger.error(f"Error processing file info for DC command: {e}")
344 | await handle_user_error(message, MSG_DC_FILE_ERROR)
345 |
346 | args = message.text.strip().split(maxsplit=1)
347 | if len(args) > 1:
348 | query = args[1].strip()
349 | user = await get_user_safely(bot, query)
350 | if user:
351 | await process_dc_info(user)
352 | else:
353 | await handle_user_error(message, MSG_ERROR_USER_INFO)
354 | return
355 |
356 | if message.reply_to_message:
357 | if has_media(message.reply_to_message):
358 | await process_file_dc_info(message.reply_to_message)
359 | return
360 | elif message.reply_to_message.from_user:
361 | await process_dc_info(message.reply_to_message.from_user)
362 | return
363 | else:
364 | await handle_user_error(message, MSG_DC_INVALID_USAGE)
365 | return
366 |
367 | if message.from_user:
368 | await process_dc_info(message.from_user)
369 | else:
370 | await handle_user_error(message, MSG_DC_ANON_ERROR)
371 | except Exception as e:
372 | logger.error(f"Error in dc_command: {e}")
373 | await handle_user_error(message, MSG_ERROR_GENERIC)
374 |
375 | @check_banned
376 | @force_channel_check
377 | @StreamBot.on_message(filters.command("ping") & filters.private)
378 | async def ping_command(bot: Client, message: Message):
379 | try:
380 | start_time = time.time()
381 | sent_msg = await message.reply_text(MSG_PING_START, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
382 | end_time = time.time()
383 | time_taken_ms = (end_time - start_time) * 1000
384 |
385 | buttons = [
386 | [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
387 | InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
388 | ]
389 |
390 | await sent_msg.edit_text(
391 | MSG_PING_RESPONSE.format(time_taken_ms=time_taken_ms),
392 | reply_markup=InlineKeyboardMarkup(buttons),
393 | link_preview_options=LinkPreviewOptions(is_disabled=True)
394 | )
395 | except Exception as e:
396 | logger.error(f"Error in ping_command: {e}")
397 | await handle_user_error(message, MSG_ERROR_GENERIC)
398 |
399 | async def handle_callback_error(callback_query, error, operation="callback"):
400 | error_id = uuid.uuid4().hex[:8]
401 | logger.error(f"Error in {operation}: {error}")
402 | try:
403 | await callback_query.answer(
404 | MSG_ERROR_GENERIC_CALLBACK.format(error_id=error_id),
405 | show_alert=True
406 | )
407 | except Exception as e:
408 | logger.error(f"Failed to send error callback: {e}")
409 |
410 | @StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
411 | async def close_panel_callback(client: Client, callback_query: CallbackQuery):
412 | try:
413 | await callback_query.answer()
414 | await callback_query.message.delete()
415 | if callback_query.message.reply_to_message:
416 | try:
417 | ctx = callback_query.message.reply_to_message
418 | await ctx.delete()
419 | if ctx.reply_to_message:
420 | await ctx.reply_to_message.delete()
421 | except Exception as e:
422 | logger.warning(f"Error deleting command messages: {e}")
423 | except Exception as e:
424 | await handle_callback_error(callback_query, e, "close_panel_callback")
425 | finally:
426 | raise StopPropagation
427 |
--------------------------------------------------------------------------------
/Thunder/bot/plugins/stream.py:
--------------------------------------------------------------------------------
1 | # Thunder/bot/plugins/stream.py
2 |
3 | import time
4 | import asyncio
5 | import random
6 | import uuid
7 | from urllib.parse import quote
8 | from typing import Optional, Tuple, Dict, Union, List, Set
9 | from datetime import datetime, timedelta
10 | from pyrogram import Client, filters, enums
11 | from pyrogram.errors import (
12 | FloodWait,
13 | RPCError,
14 | MediaEmpty,
15 | FileReferenceExpired,
16 | FileReferenceInvalid,
17 | MessageNotModified
18 | )
19 | from pyrogram.types import (
20 | InlineKeyboardMarkup,
21 | InlineKeyboardButton,
22 | InlineKeyboardMarkup,
23 | Message,
24 | CallbackQuery,
25 | LinkPreviewOptions
26 | )
27 | from pyrogram.enums import ChatMemberStatus
28 | from Thunder.bot import StreamBot
29 | from Thunder.utils.database import db
30 | from Thunder.utils.messages import *
31 | from Thunder.utils.file_properties import get_hash, get_media_file_size, get_name
32 | from Thunder.utils.human_readable import humanbytes
33 | from Thunder.utils.logger import logger
34 | from Thunder.vars import Var
35 | from Thunder.utils.decorators import check_banned, require_token, shorten_link
36 | from Thunder.utils.force_channel import force_channel_check
37 | from Thunder.utils.shortener import shorten
38 | from Thunder.utils.bot_utils import (
39 | notify_owner,
40 | handle_user_error,
41 | log_new_user,
42 | generate_media_links,
43 | send_links_to_user,
44 | check_admin_privileges
45 | )
46 |
47 | class LRUCache:
48 | def __init__(self, max_size=1000, ttl=86400):
49 | self.cache = {}
50 | self.max_size = max_size
51 | self.ttl = ttl
52 | self.access_order = []
53 | self._lock = asyncio.Lock()
54 |
55 | async def get(self, key):
56 | async with self._lock:
57 | if key not in self.cache:
58 | return None
59 | item = self.cache[key]
60 | if time.time() - item['timestamp'] > self.ttl:
61 | del self.cache[key]
62 | self.access_order.remove(key)
63 | return None
64 | self.access_order.remove(key)
65 | self.access_order.append(key)
66 | return item
67 |
68 | async def set(self, key, value):
69 | async with self._lock:
70 | if key in self.cache:
71 | self.access_order.remove(key)
72 | elif len(self.cache) >= self.max_size:
73 | lru_key = self.access_order.pop(0)
74 | del self.cache[lru_key]
75 | self.cache[key] = value
76 | self.access_order.append(key)
77 |
78 | async def delete(self, key):
79 | async with self._lock:
80 | if key in self.cache:
81 | del self.cache[key]
82 | self.access_order.remove(key)
83 |
84 | async def clean_expired(self):
85 | count = 0
86 | current_time = time.time()
87 | async with self._lock:
88 | for key in list(self.cache.keys()):
89 | if current_time - self.cache[key]['timestamp'] > self.ttl:
90 | del self.cache[key]
91 | self.access_order.remove(key)
92 | count += 1
93 | return count
94 |
95 | CACHE = LRUCache(max_size=getattr(Var, "CACHE_SIZE", 1000), ttl=86400)
96 |
97 | class RateLimiter:
98 | def __init__(self, max_calls, time_period):
99 | self.max_calls = max_calls
100 | self.time_period = time_period
101 | self.calls = {}
102 | self._lock = asyncio.Lock()
103 |
104 | async def is_rate_limited(self, user_id):
105 | async with self._lock:
106 | now = time.time()
107 | if user_id not in self.calls:
108 | self.calls[user_id] = []
109 | self.calls[user_id] = [
110 | ts for ts in self.calls[user_id]
111 | if now - ts <= self.time_period
112 | ]
113 | if len(self.calls[user_id]) >= self.max_calls:
114 | return True
115 | self.calls[user_id].append(now)
116 | return False
117 |
118 | async def get_reset_time(self, user_id):
119 | async with self._lock:
120 | if user_id not in self.calls or not self.calls[user_id]:
121 | return 0
122 | now = time.time()
123 | oldest_call = min(self.calls[user_id])
124 | return max(0, self.time_period - (now - oldest_call))
125 |
126 | rate_limiter = RateLimiter(max_calls=20, time_period=60)
127 |
128 | async def handle_flood_wait(e):
129 | wait_time = e.value
130 | logger.warning(f"FloodWait encountered. Sleeping for {wait_time} seconds.")
131 | jitter = random.uniform(0, 0.1 * wait_time)
132 | await asyncio.sleep(wait_time + jitter + 1)
133 |
134 | def get_file_unique_id(media_message):
135 | media_types = [
136 | 'document', 'video', 'audio', 'photo', 'animation',
137 | 'voice', 'video_note', 'sticker'
138 | ]
139 | for media_type in media_types:
140 | media = getattr(media_message, media_type, None)
141 | if media:
142 | return media.file_unique_id
143 | return None
144 |
145 | async def forward_media(media_message):
146 | for retry in range(3):
147 | try:
148 | result = await media_message.copy(chat_id=Var.BIN_CHANNEL)
149 | await asyncio.sleep(0.2)
150 | return result
151 | except Exception:
152 | try:
153 | result = await media_message.forward(chat_id=Var.BIN_CHANNEL)
154 | await asyncio.sleep(0.2)
155 | return result
156 | except FloodWait as flood_error:
157 | if retry < 2:
158 | await handle_flood_wait(flood_error)
159 | else:
160 | raise
161 | except Exception as forward_error:
162 | if retry == 2:
163 | logger.error(f"Error forwarding media: {forward_error}")
164 | raise
165 | await asyncio.sleep(0.5)
166 | raise Exception("Failed to forward media after multiple attempts")
167 |
168 | async def log_request(log_msg, user, stream_link, online_link):
169 | try:
170 | if getattr(user, 'title', None):
171 | source_info = f"{user.title} (Chat/Channel)"
172 | else:
173 | first = getattr(user, 'first_name', '') or ''
174 | last = getattr(user, 'last_name', '') or ''
175 | display_name = f"{first} {last}".strip() or "Unknown"
176 | source_info = f"{display_name}"
177 | id_ = user.id
178 | await log_msg.reply_text(
179 | MSG_NEW_FILE_REQUEST.format(
180 | source_info=source_info,
181 | id_=id_,
182 | online_link=online_link,
183 | stream_link=stream_link
184 | ),
185 | link_preview_options=LinkPreviewOptions(is_disabled=True),
186 | quote=True
187 | )
188 | await asyncio.sleep(0.3)
189 | except Exception:
190 | pass
191 |
192 | async def process_media_message(client, command_message, media_message, notify=True, shortener=True):
193 | retries = 0
194 | max_retries = 3
195 | cache_key = get_file_unique_id(media_message)
196 | if cache_key is None:
197 | if notify:
198 | await command_message.reply_text(MSG_ERROR_FILE_ID_EXTRACT, quote=True)
199 | return None
200 | cached_data = await CACHE.get(cache_key)
201 | if cached_data:
202 | if notify:
203 | await send_links_to_user(
204 | client,
205 | command_message,
206 | cached_data['media_name'],
207 | cached_data['media_size'],
208 | cached_data['stream_link'],
209 | cached_data['online_link']
210 | )
211 | return cached_data['online_link']
212 | while retries < max_retries:
213 | try:
214 | log_msg = await forward_media(media_message)
215 | stream_link, online_link, media_name, media_size = await generate_media_links(log_msg, shortener=shortener)
216 | await CACHE.set(cache_key, {
217 | 'media_name': media_name,
218 | 'media_size': media_size,
219 | 'stream_link': stream_link,
220 | 'online_link': online_link,
221 | 'message_id': log_msg.id,
222 | 'timestamp': time.time()
223 | })
224 | if notify:
225 | await send_links_to_user(
226 | client,
227 | command_message,
228 | media_name,
229 | media_size,
230 | stream_link,
231 | online_link
232 | )
233 | await log_request(log_msg, command_message.from_user, stream_link, online_link)
234 | return online_link
235 | except FloodWait as e:
236 | await handle_flood_wait(e)
237 | retries += 1
238 | continue
239 | except (FileReferenceExpired, FileReferenceInvalid):
240 | retries += 1
241 | await asyncio.sleep(0.3)
242 | continue
243 | except MediaEmpty:
244 | if notify:
245 | await command_message.reply_text(MSG_MEDIA_ERROR, quote=True)
246 | return None
247 | except Exception as e:
248 | logger.error(f"Error processing media: {e}")
249 | if retries < max_retries - 1:
250 | retries += 1
251 | await asyncio.sleep(0.3)
252 | continue
253 | if notify:
254 | await handle_user_error(
255 | command_message,
256 | MSG_ERROR_PROCESSING_MEDIA
257 | )
258 | await notify_owner(
259 | client,
260 | MSG_CRITICAL_ERROR.format(error=e, error_id=uuid.uuid4().hex[:8])
261 | )
262 | return None
263 | return None
264 |
265 | async def retry_failed_media(client, command_message, media_messages, status_msg=None, shortener=True):
266 | results = []
267 | for i, msg in enumerate(media_messages):
268 | try:
269 | result = await process_media_message(client, command_message, msg, notify=False, shortener=shortener)
270 | if result:
271 | results.append(result)
272 | if status_msg and i % 2 == 0:
273 | try:
274 | await status_msg.edit(f"🔄 **Retrying Failed Files:** {len(results)}/{len(media_messages)} processed")
275 | except MessageNotModified:
276 | pass
277 | except Exception as e:
278 | logger.error(f"Error retrying media: {e}")
279 | return results
280 |
281 | async def process_multiple_messages(client, command_message, reply_msg, num_files, status_msg, shortener=True):
282 | chat_id = command_message.chat.id
283 | start_message_id = reply_msg.id
284 | end_message_id = start_message_id + num_files - 1
285 | message_ids = list(range(start_message_id, end_message_id + 1))
286 | last_status_text = ""
287 | try:
288 | batch_size = 5
289 | processed_count = 0
290 | failed_count = 0
291 | download_links = []
292 | failed_messages = []
293 |
294 | async def process_single_message(msg):
295 | try:
296 | return await process_media_message(
297 | client,
298 | command_message,
299 | msg,
300 | notify=False
301 | )
302 | except FloodWait as e:
303 | await handle_flood_wait(e)
304 | return await process_media_message(client, command_message, msg, notify=False)
305 | except Exception as e:
306 | logger.error(f"Message {msg.id} error: {e}")
307 | return None
308 |
309 | for i in range(0, len(message_ids), batch_size):
310 | batch_ids = message_ids[i:i+batch_size]
311 | new_status_text = MSG_PROCESSING_BATCH.format(
312 | batch_number=(i//batch_size)+1,
313 | total_batches=(len(message_ids)+batch_size-1)//batch_size,
314 | file_count=len(batch_ids)
315 | )
316 | if new_status_text != last_status_text:
317 | try:
318 | await status_msg.edit(new_status_text)
319 | last_status_text = new_status_text
320 | except MessageNotModified:
321 | pass
322 | await asyncio.sleep(1.0)
323 | messages = []
324 | for retry in range(3):
325 | try:
326 | messages = await client.get_messages(
327 | chat_id=chat_id,
328 | message_ids=batch_ids
329 | )
330 | break
331 | except FloodWait as e:
332 | await handle_flood_wait(e)
333 | except Exception as e:
334 | if retry == 2:
335 | logger.error(f"Failed to get batch {i//batch_size+1}: {e}")
336 | await asyncio.sleep(0.5)
337 | for msg in messages:
338 | if msg and msg.media:
339 | try:
340 | result = await process_media_message(
341 | client, command_message, msg, notify=False, shortener=shortener
342 | )
343 | if result:
344 | download_links.append(result)
345 | processed_count += 1
346 | else:
347 | failed_count += 1
348 | failed_messages.append(msg)
349 | except Exception as e:
350 | failed_count += 1
351 | failed_messages.append(msg)
352 | logger.error(f"Failed to process {msg.id}: {e}")
353 | await asyncio.sleep(1.0)
354 | if processed_count % 5 == 0 or processed_count + failed_count == len(message_ids):
355 | new_status_text = MSG_PROCESSING_STATUS.format(
356 | processed=processed_count,
357 | total=num_files,
358 | failed=failed_count
359 | )
360 | if new_status_text != last_status_text:
361 | try:
362 | await status_msg.edit(new_status_text)
363 | last_status_text = new_status_text
364 | except MessageNotModified:
365 | pass
366 |
367 | if failed_messages and len(failed_messages) < num_files / 2:
368 | new_status_text = MSG_RETRYING_FILES.format(count=len(failed_messages))
369 | if new_status_text != last_status_text:
370 | try:
371 | await status_msg.edit(new_status_text)
372 | last_status_text = new_status_text
373 | except MessageNotModified:
374 | pass
375 | retry_results = await retry_failed_media(
376 | client, command_message, failed_messages, status_msg, shortener
377 | )
378 | if retry_results:
379 | download_links.extend(retry_results)
380 | processed_count += len(retry_results)
381 | failed_count -= len(retry_results)
382 |
383 | def chunk_list(lst, n):
384 | for i in range(0, len(lst), n):
385 | yield lst[i:i + n]
386 |
387 | for chunk in chunk_list(download_links, 20):
388 | links_text = "\n".join(chunk)
389 | group_message_content = MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n`{links_text}`"
390 | dm_prefix = MSG_DM_BATCH_PREFIX.format(chat_title=command_message.chat.title)
391 | dm_message_text = f"{dm_prefix}\n{group_message_content}"
392 | try:
393 | await command_message.reply_text(
394 | group_message_content,
395 | quote=True,
396 | link_preview_options=LinkPreviewOptions(is_disabled=True),
397 | parse_mode=enums.ParseMode.MARKDOWN
398 | )
399 | except Exception as e:
400 | logger.error(f"Error sending batch links to original chat {command_message.chat.id}: {e}")
401 | if command_message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
402 | try:
403 | await client.send_message(
404 | chat_id=command_message.from_user.id,
405 | text=dm_message_text,
406 | link_preview_options=LinkPreviewOptions(is_disabled=True),
407 | parse_mode=enums.ParseMode.MARKDOWN
408 | )
409 | except Exception as e:
410 | logger.error(f"Error sending batch links to user DM {command_message.from_user.id}: {e}")
411 | await command_message.reply_text(
412 | MSG_ERROR_DM_FAILED,
413 | quote=True
414 | )
415 | if len(chunk) > 10:
416 | await asyncio.sleep(0.5)
417 |
418 | new_status_text = MSG_PROCESSING_RESULT.format(
419 | processed=processed_count,
420 | total=num_files,
421 | failed=failed_count
422 | )
423 | if new_status_text != last_status_text:
424 | try:
425 | await status_msg.edit(new_status_text)
426 | last_status_text = new_status_text
427 | except MessageNotModified:
428 | pass
429 | except Exception as e:
430 | logger.error(f"Error in batch processing: {e}")
431 | new_status_text = MSG_PROCESSING_ERROR.format(
432 | error=str(e),
433 | processed=processed_count,
434 | total=num_files,
435 | error_id=uuid.uuid4().hex[:8]
436 | )
437 | if new_status_text != last_status_text:
438 | try:
439 | await status_msg.edit(new_status_text)
440 | last_status_text = new_status_text
441 | except MessageNotModified:
442 | pass
443 |
444 | @StreamBot.on_message(filters.command("link") & ~filters.private)
445 | @check_banned
446 | @require_token
447 | @shorten_link
448 | @force_channel_check
449 | async def link_handler(client, message, shortener=True):
450 | user_id = message.from_user.id
451 | if not await db.is_user_exist(user_id):
452 | try:
453 | invite_link = f"https://t.me/{client.me.username}?start=start"
454 | await message.reply_text(
455 | MSG_ERROR_START_BOT.format(invite_link=invite_link),
456 | link_preview_options=LinkPreviewOptions(is_disabled=True),
457 | parse_mode=enums.ParseMode.MARKDOWN,
458 | reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]),
459 | quote=True
460 | )
461 | except Exception:
462 | pass
463 | return
464 | if await rate_limiter.is_rate_limited(user_id):
465 | reset_time = await rate_limiter.get_reset_time(user_id)
466 | await message.reply_text(
467 | MSG_ERROR_RATE_LIMIT.format(seconds=f"{reset_time:.0f}"),
468 | quote=True
469 | )
470 | return
471 | if message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
472 | is_admin = await check_admin_privileges(client, message.chat.id)
473 | if not is_admin:
474 | await message.reply_text(
475 | MSG_ERROR_NOT_ADMIN,
476 | quote=True
477 | )
478 | return
479 | if not message.reply_to_message:
480 | await message.reply_text(
481 | MSG_ERROR_REPLY_FILE,
482 | quote=True
483 | )
484 | return
485 | reply_msg = message.reply_to_message
486 | if not reply_msg.media:
487 | await message.reply_text(
488 | MSG_ERROR_NO_FILE,
489 | quote=True
490 | )
491 | return
492 | command_parts = message.text.strip().split()
493 | num_files = 1
494 | if len(command_parts) > 1:
495 | try:
496 | num_files = int(command_parts[1])
497 | if num_files < 1 or num_files > 100:
498 | await message.reply_text(
499 | MSG_ERROR_NUMBER_RANGE,
500 | quote=True
501 | )
502 | return
503 | except ValueError:
504 | await message.reply_text(
505 | MSG_ERROR_INVALID_NUMBER,
506 | quote=True
507 | )
508 | return
509 | processing_msg = await message.reply_text(
510 | MSG_PROCESSING_REQUEST,
511 | quote=True
512 | )
513 | try:
514 | if num_files == 1:
515 | result = await process_media_message(client, message, reply_msg, shortener=shortener)
516 | if result:
517 | if message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
518 | try:
519 | cached_data = await CACHE.get(get_file_unique_id(reply_msg))
520 | if cached_data:
521 | msg_text = (
522 | MSG_LINKS.format(
523 | file_name=cached_data['media_name'],
524 | file_size=cached_data['media_size'],
525 | download_link=cached_data['online_link'],
526 | stream_link=cached_data['stream_link']
527 | )
528 | )
529 | await client.send_message(
530 | chat_id=message.from_user.id,
531 | text=MSG_LINK_FROM_GROUP.format(
532 | chat_title=message.chat.title,
533 | links_message=msg_text
534 | ),
535 | link_preview_options=LinkPreviewOptions(is_disabled=True),
536 | parse_mode=enums.ParseMode.MARKDOWN,
537 | reply_markup=InlineKeyboardMarkup([
538 | [
539 | InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=cached_data['stream_link']),
540 | InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=cached_data['online_link'])
541 | ]
542 | ]),
543 | )
544 | except Exception as e:
545 | logger.debug(f"Error sending DM to user {message.from_user.id} from group: {e}")
546 | await message.reply_text(
547 | MSG_ERROR_DM_FAILED,
548 | quote=True
549 | )
550 | await processing_msg.delete()
551 | else:
552 | try:
553 | await processing_msg.edit(MSG_ERROR_PROCESSING_MEDIA)
554 | except MessageNotModified:
555 | pass
556 | else:
557 | await process_multiple_messages(client, message, reply_msg, num_files, processing_msg, shortener)
558 | except Exception as e:
559 | logger.error(f"Error handling link command: {e}")
560 | try:
561 | await processing_msg.edit(MSG_ERROR_PROCESSING_MEDIA)
562 | except MessageNotModified:
563 | pass
564 |
565 | @StreamBot.on_message(
566 | filters.private & filters.incoming & (
567 | filters.document | filters.video | filters.photo | filters.audio |
568 | filters.voice | filters.animation | filters.video_note
569 | ),
570 | group=4
571 | )
572 | @check_banned
573 | @require_token
574 | @shorten_link
575 | @force_channel_check
576 | async def private_receive_handler(client, message, shortener=True):
577 | if not message.from_user:
578 | return
579 | if await rate_limiter.is_rate_limited(message.from_user.id):
580 | reset_time = await rate_limiter.get_reset_time(message.from_user.id)
581 | await message.reply_text(
582 | MSG_ERROR_RATE_LIMIT.format(seconds=f"{reset_time:.0f}"),
583 | quote=True
584 | )
585 | return
586 | await log_new_user(
587 | bot=client,
588 | user_id=message.from_user.id,
589 | first_name=message.from_user.first_name
590 | )
591 | processing_msg = await message.reply_text(
592 | MSG_PROCESSING_FILE,
593 | quote=True
594 | )
595 | try:
596 | result = await process_media_message(client, message, message, shortener=shortener)
597 | if result:
598 | await processing_msg.delete()
599 | else:
600 | try:
601 | await processing_msg.edit(MSG_ERROR_PROCESSING_MEDIA)
602 | except MessageNotModified:
603 | pass
604 | except Exception as e:
605 | logger.error(f"Error in private handler: {e}")
606 | try:
607 | await processing_msg.edit(MSG_ERROR_PROCESSING_MEDIA)
608 | except MessageNotModified:
609 | pass
610 |
611 | @StreamBot.on_message(
612 | filters.channel & filters.incoming & (
613 | filters.document | filters.video | filters.photo | filters.audio |
614 | filters.voice | filters.animation | filters.video_note
615 | ) & ~filters.chat(Var.BIN_CHANNEL),
616 | group=-1
617 | )
618 | @shorten_link
619 | async def channel_receive_handler(client, broadcast, shortener=True):
620 | if hasattr(Var, 'BANNED_CHANNELS') and int(broadcast.chat.id) in Var.BANNED_CHANNELS:
621 | await client.leave_chat(broadcast.chat.id)
622 | return
623 | can_edit = False
624 | try:
625 | member = await client.get_chat_member(broadcast.chat.id, client.me.id)
626 | can_edit = member.status in [
627 | enums.ChatMemberStatus.ADMINISTRATOR,
628 | enums.ChatMemberStatus.OWNER
629 | ]
630 | except Exception:
631 | can_edit = False
632 | retries = 0
633 | max_retries = 3
634 | while retries < max_retries:
635 | try:
636 | log_msg = await forward_media(broadcast)
637 | stream_link, online_link, media_name, media_size = await generate_media_links(log_msg, shortener=shortener)
638 | await log_request(log_msg, broadcast.chat, stream_link, online_link)
639 | if can_edit:
640 | try:
641 | await client.edit_message_reply_markup(
642 | chat_id=broadcast.chat.id,
643 | message_id=broadcast.id,
644 | reply_markup=InlineKeyboardMarkup([
645 | [
646 | InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
647 | InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=online_link)
648 | ]
649 | ])
650 | )
651 | except Exception:
652 | pass
653 | break
654 | except FloodWait as e:
655 | await handle_flood_wait(e)
656 | retries += 1
657 | continue
658 | except (FileReferenceExpired, FileReferenceInvalid):
659 | retries += 1
660 | await asyncio.sleep(0.5)
661 | continue
662 | except Exception as e:
663 | logger.error(f"Error handling channel message: {e}")
664 | if retries < max_retries - 1:
665 | retries += 1
666 | await asyncio.sleep(0.5)
667 | continue
668 | await notify_owner(
669 | client,
670 | MSG_CRITICAL_ERROR.format(error=e, error_id=uuid.uuid4().hex[:8])
671 | )
672 | break
673 |
674 | async def clean_cache_task():
675 | while True:
676 | try:
677 | await asyncio.sleep(3600)
678 | await CACHE.clean_expired()
679 | except Exception as e:
680 | logger.error(f"Error in cache cleaning task: {e}")
681 |
682 | StreamBot.loop.create_task(clean_cache_task())
683 |
--------------------------------------------------------------------------------
/Thunder/server/__init__.py:
--------------------------------------------------------------------------------
1 | # Thunder/server/__init__.py
2 |
3 | from aiohttp import web
4 | from .stream_routes import routes
5 |
6 | async def web_server():
7 | web_app = web.Application(client_max_size=30000000)
8 | web_app.add_routes(routes)
9 | return web_app
10 |
--------------------------------------------------------------------------------
/Thunder/server/exceptions.py:
--------------------------------------------------------------------------------
1 | # Thunder/server/exceptions.py
2 |
3 | class InvalidHash(Exception):
4 | """Exception raised for an invalid hash."""
5 | message = "Invalid hash"
6 |
7 |
8 | class FileNotFound(Exception):
9 | """Exception raised when a file is not found."""
10 | message = "File not found"
11 |
--------------------------------------------------------------------------------
/Thunder/server/stream_routes.py:
--------------------------------------------------------------------------------
1 | # Thunder/server/stream_routes.py
2 |
3 | import re
4 | import secrets
5 | import time
6 | from aiohttp import web
7 | from urllib.parse import unquote, quote
8 | from Thunder import StartTime, __version__
9 | from Thunder.bot import multi_clients, StreamBot, work_loads
10 | from Thunder.server.exceptions import FileNotFound, InvalidHash
11 | from Thunder.utils.custom_dl import ByteStreamer
12 | from Thunder.utils.logger import logger
13 | from Thunder.utils.render_template import render_page
14 | from Thunder.utils.time_format import get_readable_time
15 |
16 | routes = web.RouteTableDef()
17 |
18 | SECURE_HASH_LENGTH = 6
19 | CHUNK_SIZE = 1024 * 1024
20 | MAX_CONCURRENT_PER_CLIENT = 8
21 | RANGE_REGEX = re.compile(r"bytes=(?P\d*)-(?P\d*)")
22 | PATTERN_HASH_FIRST = re.compile(rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$")
23 | PATTERN_ID_FIRST = re.compile(r"^(\d+)(?:/.*)?$")
24 | VALID_HASH_REGEX = re.compile(r'^[a-zA-Z0-9_-]+$')
25 |
26 | streamers = {}
27 |
28 | def get_streamer(client_id: int) -> ByteStreamer:
29 | if client_id not in streamers:
30 | streamers[client_id] = ByteStreamer(multi_clients[client_id])
31 | return streamers[client_id]
32 |
33 | def parse_media_request(path: str, query: dict) -> tuple[int, str]:
34 | clean_path = unquote(path).strip('/')
35 |
36 | match = PATTERN_HASH_FIRST.match(clean_path)
37 | if match:
38 | try:
39 | message_id = int(match.group(2))
40 | secure_hash = match.group(1)
41 | if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash):
42 | return message_id, secure_hash
43 | except ValueError as e:
44 | raise InvalidHash("Invalid message ID format") from e
45 |
46 | match = PATTERN_ID_FIRST.match(clean_path)
47 | if match:
48 | try:
49 | message_id = int(match.group(1))
50 | secure_hash = query.get("hash", "").strip()
51 | if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash):
52 | return message_id, secure_hash
53 | except ValueError as e:
54 | raise InvalidHash("Invalid message ID format") from e
55 |
56 | raise InvalidHash("Invalid URL structure")
57 |
58 | def select_optimal_client() -> tuple[int, ByteStreamer]:
59 | if not work_loads:
60 | raise web.HTTPInternalServerError(text="No available clients")
61 |
62 | available_clients = [(cid, load) for cid, load in work_loads.items() if load < MAX_CONCURRENT_PER_CLIENT]
63 |
64 | if available_clients:
65 | client_id = min(available_clients, key=lambda x: x[1])[0]
66 | else:
67 | client_id = min(work_loads, key=work_loads.get)
68 |
69 | return client_id, get_streamer(client_id)
70 |
71 | def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
72 | if not range_header:
73 | return 0, file_size - 1
74 |
75 | match = RANGE_REGEX.match(range_header)
76 | if not match:
77 | raise web.HTTPBadRequest(text="Invalid range")
78 |
79 | start = int(match.group("start")) if match.group("start") else 0
80 | end = int(match.group("end")) if match.group("end") else file_size - 1
81 |
82 | if start < 0 or end >= file_size or start > end:
83 | raise web.HTTPRequestRangeNotSatisfiable(
84 | headers={"Content-Range": f"bytes */{file_size}"}
85 | )
86 |
87 | return start, end
88 |
89 | @routes.get("/", allow_head=True)
90 | async def root_redirect(request):
91 | raise web.HTTPFound("https://github.com/fyaz05/FileToLink")
92 |
93 | @routes.get("/status", allow_head=True)
94 | async def status_endpoint(request):
95 | uptime = time.time() - StartTime
96 | total_load = sum(work_loads.values())
97 |
98 | workload_distribution = {str(k): v for k, v in sorted(work_loads.items())}
99 |
100 | return web.json_response({
101 | "server": {
102 | "status": "operational",
103 | "version": __version__,
104 | "uptime": get_readable_time(uptime)
105 | },
106 | "telegram_bot": {
107 | "username": f"@{StreamBot.username}",
108 | "active_clients": len(multi_clients)
109 | },
110 | "resources": {
111 | "total_workload": total_load,
112 | "workload_distribution": workload_distribution
113 | }
114 | })
115 |
116 | @routes.get(r"/watch/{path:.+}", allow_head=True)
117 | async def media_preview(request: web.Request):
118 | try:
119 | path = request.match_info["path"]
120 | message_id, secure_hash = parse_media_request(path, request.query)
121 |
122 | rendered_page = await render_page(message_id, secure_hash, requested_action='stream')
123 | return web.Response(text=rendered_page, content_type='text/html')
124 |
125 | except (InvalidHash, FileNotFound) as e:
126 | logger.debug(f"Client error in preview: {type(e).__name__}")
127 | raise web.HTTPNotFound(text="Resource not found")
128 | except Exception as e:
129 | error_id = secrets.token_hex(6)
130 | logger.error(f"Preview error {error_id}: {e}")
131 | raise web.HTTPInternalServerError(text="Server error") from e
132 |
133 | @routes.get(r"/{path:.+}", allow_head=True)
134 | async def media_delivery(request: web.Request):
135 | try:
136 | path = request.match_info["path"]
137 | message_id, secure_hash = parse_media_request(path, request.query)
138 |
139 | client_id, streamer = select_optimal_client()
140 |
141 | work_loads[client_id] += 1
142 |
143 | try:
144 | file_info = await streamer.get_file_info(message_id)
145 | if not file_info.get('unique_id'):
146 | raise FileNotFound("File not found")
147 |
148 | if file_info['unique_id'][:SECURE_HASH_LENGTH] != secure_hash:
149 | raise InvalidHash("Invalid hash")
150 |
151 | file_size = file_info.get('file_size', 0)
152 | if file_size == 0:
153 | raise FileNotFound("File size unavailable")
154 |
155 | range_header = request.headers.get("Range", "")
156 | start, end = parse_range_header(range_header, file_size)
157 | content_length = end - start + 1
158 |
159 | if start == 0 and end == file_size - 1:
160 | range_header = ""
161 |
162 | mime_type = file_info.get('mime_type') or 'application/octet-stream'
163 | filename = file_info.get('file_name') or f"file_{secrets.token_hex(4)}"
164 |
165 | headers = {
166 | "Content-Type": mime_type,
167 | "Content-Length": str(content_length),
168 | "Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
169 | "Accept-Ranges": "bytes",
170 | "Cache-Control": "public, max-age=31536000",
171 | "Connection": "keep-alive"
172 | }
173 |
174 | if range_header:
175 | headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
176 |
177 | async def stream_generator():
178 | try:
179 | bytes_sent = 0
180 | bytes_to_skip = start % CHUNK_SIZE
181 |
182 | async for chunk in streamer.stream_file(message_id, offset=start, limit=content_length):
183 | if bytes_to_skip > 0:
184 | if len(chunk) <= bytes_to_skip:
185 | bytes_to_skip -= len(chunk)
186 | continue
187 | chunk = chunk[bytes_to_skip:]
188 | bytes_to_skip = 0
189 |
190 | remaining = content_length - bytes_sent
191 | if len(chunk) > remaining:
192 | chunk = chunk[:remaining]
193 |
194 | if chunk:
195 | yield chunk
196 | bytes_sent += len(chunk)
197 |
198 | if bytes_sent >= content_length:
199 | break
200 | finally:
201 | work_loads[client_id] -= 1
202 | return web.Response(
203 | status=206 if range_header else 200,
204 | body=stream_generator(),
205 | headers=headers
206 | )
207 |
208 | except (FileNotFound, InvalidHash):
209 | work_loads[client_id] -= 1
210 | raise
211 | except Exception as e:
212 | work_loads[client_id] -= 1
213 | error_id = secrets.token_hex(6)
214 | logger.error(f"Stream error {error_id}: {e}")
215 | raise web.HTTPInternalServerError(text="Server error") from e
216 |
217 | except (InvalidHash, FileNotFound) as e:
218 | logger.debug(f"Client error: {type(e).__name__}")
219 | raise web.HTTPNotFound(text="Resource not found")
220 | except Exception as e:
221 | error_id = secrets.token_hex(6)
222 | logger.error(f"Server error {error_id}: {e}")
223 | raise web.HTTPInternalServerError(text="Server error") from e
224 |
--------------------------------------------------------------------------------
/Thunder/template/dl.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | Downloading: {{ file_name }}
9 |
10 |
18 |
19 |
20 |
21 |
22 |
Your download for {{ file_name }} should start automatically.
23 |
If it doesn't, please click here to download .
24 |
25 |
26 |
27 |
--------------------------------------------------------------------------------
/Thunder/template/req.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ heading }}
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
47 |
48 | <{{ tag }} id="player" playsinline controls preload="metadata" controlsList="nodownload">
49 |
50 | Your browser does not support the {{ tag }} tag.
51 | {{ tag }}>
52 |
53 |
54 |
55 |
56 |
57 | Stream
58 |
59 |
145 |
146 | Download
147 | Copy Link
148 |
149 |
150 |
151 |
152 |
155 |
156 |
157 |
158 |
Action completed.
159 |
160 |
161 |
162 |
163 |
164 |
165 |
--------------------------------------------------------------------------------
/Thunder/utils/bot_utils.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/bot_utils.py
2 |
3 | import asyncio
4 | from typing import Optional, Tuple
5 | from urllib.parse import quote
6 |
7 | from pyrogram import Client, enums
8 | from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, User, CallbackQuery, LinkPreviewOptions, ReplyParameters
9 | from pyrogram.enums import ChatMemberStatus
10 |
11 | from Thunder.vars import Var
12 | from Thunder.utils.database import db
13 | from Thunder.utils.human_readable import humanbytes
14 | from Thunder.utils.retry_api import retry_send_message
15 | from Thunder.utils.logger import logger
16 | from Thunder.utils.error_handling import log_errors
17 | from Thunder.utils.messages import (
18 | MSG_BUTTON_GET_HELP,
19 | MSG_NEW_USER,
20 | MSG_DC_UNKNOWN,
21 | MSG_DC_USER_INFO,
22 | MSG_LINKS,
23 | MSG_BUTTON_STREAM_NOW,
24 | MSG_BUTTON_DOWNLOAD
25 | )
26 | from Thunder.utils.file_properties import get_name, get_media_file_size, get_hash
27 | from Thunder.utils.shortener import shorten
28 |
29 | @log_errors
30 | async def notify_channel(bot: Client, text: str):
31 | if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
32 | await bot.send_message(chat_id=Var.BIN_CHANNEL, text=text)
33 |
34 | @log_errors
35 | async def notify_owner(client: Client, text: str):
36 | owner_ids = Var.OWNER_ID
37 | tasks = [retry_send_message(client, oid, text) for oid in (owner_ids if isinstance(owner_ids, (list, tuple, set)) else [owner_ids])]
38 | if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
39 | tasks.append(retry_send_message(client, Var.BIN_CHANNEL, text))
40 | await asyncio.gather(*tasks, return_exceptions=True)
41 |
42 | @log_errors
43 | async def handle_user_error(message: Message, error_msg: str):
44 | await retry_send_message(
45 | message._client,
46 | message.chat.id,
47 | error_msg,
48 | reply_parameters=ReplyParameters(message_id=message.id),
49 | link_preview_options=LinkPreviewOptions(is_disabled=True),
50 | reply_markup=InlineKeyboardMarkup([
51 | [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]
52 | ])
53 | )
54 |
55 | @log_errors
56 | async def log_new_user(bot: Client, user_id: int, first_name: str):
57 | if not await db.is_user_exist(user_id):
58 | await db.add_user(user_id)
59 | if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
60 | await retry_send_message(
61 | bot,
62 | Var.BIN_CHANNEL,
63 | MSG_NEW_USER.format(first_name=first_name, user_id=user_id)
64 | )
65 |
66 | @log_errors
67 | async def generate_media_links(log_msg: Message, shortener: bool = True) -> Tuple[str, str, str, str]:
68 | base_url = Var.URL.rstrip("/")
69 | file_id = log_msg.id
70 | media_name = get_name(log_msg)
71 | if isinstance(media_name, bytes):
72 | media_name = media_name.decode('utf-8', errors='replace')
73 | else:
74 | media_name = str(media_name)
75 | media_size = humanbytes(get_media_file_size(log_msg))
76 | file_name_encoded = quote(media_name)
77 | hash_value = get_hash(log_msg)
78 | stream_link = f"{base_url}/watch/{hash_value}{file_id}/{file_name_encoded}"
79 | online_link = f"{base_url}/{hash_value}{file_id}/{file_name_encoded}"
80 | if shortener and getattr(Var, "SHORTEN_MEDIA_LINKS", False):
81 | shortened_results = await asyncio.gather(
82 | shorten(stream_link),
83 | shorten(online_link),
84 | return_exceptions=True
85 | )
86 | if not isinstance(shortened_results[0], Exception):
87 | stream_link = shortened_results[0]
88 | if not isinstance(shortened_results[1], Exception):
89 | online_link = shortened_results[1]
90 | return stream_link, online_link, media_name, media_size
91 |
92 | @log_errors
93 | async def send_links_to_user(client: Client, command_message: Message, media_name: str, media_size: str, stream_link: str, online_link: str):
94 | msg_text = MSG_LINKS.format(
95 | file_name=media_name,
96 | file_size=media_size,
97 | download_link=online_link,
98 | stream_link=stream_link
99 | )
100 | await retry_send_message(
101 | client,
102 | command_message.chat.id,
103 | msg_text,
104 | reply_parameters=ReplyParameters(message_id=command_message.id),
105 | link_preview_options=LinkPreviewOptions(is_disabled=True),
106 | parse_mode=enums.ParseMode.MARKDOWN,
107 | reply_markup=InlineKeyboardMarkup([
108 | [
109 | InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
110 | InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=online_link)
111 | ]
112 | ])
113 | )
114 |
115 | @log_errors
116 | async def generate_dc_text(user: User) -> str:
117 | dc_id = user.dc_id if user.dc_id is not None else MSG_DC_UNKNOWN
118 | return MSG_DC_USER_INFO.format(
119 | user_name=user.first_name or 'User',
120 | user_id=user.id,
121 | dc_id=dc_id
122 | )
123 |
124 | @log_errors
125 | async def get_user_safely(bot: Client, query) -> Optional[User]:
126 | if isinstance(query, str):
127 | if query.startswith('@'):
128 | return await bot.get_users(query)
129 | elif query.isdigit():
130 | return await bot.get_users(int(query))
131 | elif isinstance(query, int):
132 | return await bot.get_users(query)
133 | return None
134 |
135 | @log_errors
136 | async def check_admin_privileges(client: Client, chat_id: int) -> bool:
137 | member = await client.get_chat_member(chat_id, client.me.id)
138 | return member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]
139 |
140 | @log_errors
141 | async def _execute_command_from_callback(client: Client, callback_query: CallbackQuery, command_name: str, command_func):
142 | await callback_query.answer()
143 | mock_message = callback_query.message
144 | mock_message.from_user = callback_query.from_user
145 | mock_message.chat = callback_query.message.chat
146 | mock_message.text = f"/{command_name}"
147 | mock_message.command = [command_name]
148 | await command_func(client, mock_message)
149 |
--------------------------------------------------------------------------------
/Thunder/utils/broadcast_helper.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/broadcast_helper.py
2 |
3 | import asyncio
4 | from typing import Tuple
5 | from pyrogram.errors import FloodWait, InputUserDeactivated, UserIsBlocked, PeerIdInvalid
6 | from pyrogram.types import Message
7 | from Thunder.utils.error_handling import log_errors
8 |
9 | @log_errors
10 | async def send_msg(user_id: int, message: Message) -> Tuple[int, str]:
11 | try:
12 | await message.forward(chat_id=user_id)
13 | return 200, None
14 | except FloodWait as e:
15 | await asyncio.sleep(e.value + 1)
16 | return await send_msg(user_id, message)
17 | except InputUserDeactivated:
18 | return 400, f"{user_id} : deactivated"
19 | except UserIsBlocked:
20 | return 400, f"{user_id} : blocked the bot"
21 | except PeerIdInvalid:
22 | return 400, f"{user_id} : user ID invalid"
23 | except Exception as e:
24 | return 500, f"{user_id} : {str(e)}"
25 |
--------------------------------------------------------------------------------
/Thunder/utils/config_parser.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/config_parser.py
2 |
3 | import os
4 | from typing import Dict, Optional
5 | from Thunder.utils.error_handling import log_errors
6 |
7 | class TokenParser:
8 | def __init__(self, config_file: Optional[str] = None):
9 | self.tokens: Dict[int, str] = {}
10 | self.config_file = config_file
11 | self._env_cache = None
12 |
13 | @log_errors
14 | def parse_from_env(self) -> Dict[int, str]:
15 | if self._env_cache is None:
16 | self._env_cache = dict(os.environ)
17 |
18 | multi_tokens = {
19 | key: value.strip()
20 | for key, value in self._env_cache.items()
21 | if key.startswith("MULTI_TOKEN") and value.strip()
22 | }
23 |
24 | if not multi_tokens:
25 | return {}
26 |
27 | sorted_tokens = sorted(
28 | multi_tokens.items(),
29 | key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0)
30 | )
31 |
32 | self.tokens = {
33 | index + 1: token
34 | for index, (_, token) in enumerate(sorted_tokens)
35 | }
36 |
37 | return self.tokens
38 |
--------------------------------------------------------------------------------
/Thunder/utils/custom_dl.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/custom_dl.py
2 |
3 | from typing import Dict, Any, AsyncGenerator
4 | from pyrogram import Client
5 | from pyrogram.types import Message
6 | from Thunder.vars import Var
7 | from Thunder.server.exceptions import FileNotFound
8 | from Thunder.utils.logger import logger
9 |
10 | class ByteStreamer:
11 | __slots__ = ('client', 'chat_id')
12 |
13 | def __init__(self, client: Client):
14 | self.client = client
15 | self.chat_id = int(Var.BIN_CHANNEL)
16 |
17 | async def get_message(self, message_id: int) -> Message:
18 | try:
19 | message = await self.client.get_messages(self.chat_id, message_id)
20 | if not message or not message.media:
21 | raise FileNotFound(f"Message {message_id} not found")
22 | return message
23 | except Exception as e:
24 | logger.debug(f"Error fetching message {message_id}: {e}")
25 | raise FileNotFound(f"Message {message_id} not found") from e
26 |
27 | async def stream_file(self, message_id: int, offset: int = 0, limit: int = 0) -> AsyncGenerator[bytes, None]:
28 | message = await self.get_message(message_id)
29 |
30 | if limit > 0:
31 | chunk_offset = offset // (1024 * 1024)
32 | chunk_limit = (limit + 1024 * 1024 - 1) // (1024 * 1024)
33 |
34 | async for chunk in self.client.stream_media(message, offset=chunk_offset, limit=chunk_limit):
35 | yield chunk
36 | else:
37 | async for chunk in self.client.stream_media(message):
38 | yield chunk
39 |
40 | def get_file_info_sync(self, message: Message) -> Dict[str, Any]:
41 | media = message.document or message.video or message.audio or message.photo
42 | if not media:
43 | return {"message_id": message.id, "error": "No media"}
44 |
45 | return {
46 | "message_id": message.id,
47 | "file_size": getattr(media, 'file_size', 0) or 0,
48 | "file_name": getattr(media, 'file_name', None),
49 | "mime_type": getattr(media, 'mime_type', None),
50 | "unique_id": getattr(media, 'file_unique_id', None),
51 | "media_type": type(media).__name__.lower()
52 | }
53 |
54 | async def get_file_info(self, message_id: int) -> Dict[str, Any]:
55 | try:
56 | message = await self.get_message(message_id)
57 | return self.get_file_info_sync(message)
58 | except Exception as e:
59 | logger.debug(f"Error getting file info for {message_id}: {e}")
60 | return {"message_id": message_id, "error": str(e)}
61 |
--------------------------------------------------------------------------------
/Thunder/utils/database.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/database.py
2 |
3 | import datetime
4 | from typing import Optional, Dict, Any
5 | from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
6 | from Thunder.vars import Var
7 | from Thunder.utils.logger import logger
8 | from Thunder.utils.error_handling import log_errors
9 |
10 | class Database:
11 | def __init__(self, uri: str, database_name: str, *args, **kwargs):
12 | self._client = AsyncIOMotorClient(uri, *args, **kwargs)
13 | self.db = self._client[database_name]
14 | self.col: AsyncIOMotorCollection = self.db.users
15 | self.banned_users_col: AsyncIOMotorCollection = self.db.banned_users
16 | self.token_col: AsyncIOMotorCollection = self.db.tokens
17 | self.authorized_users_col: AsyncIOMotorCollection = self.db.authorized_users
18 | self.restart_message_col: AsyncIOMotorCollection = self.db.restart_message
19 |
20 | @log_errors
21 | async def ensure_indexes(self):
22 | await self.banned_users_col.create_index("user_id", unique=True)
23 | await self.token_col.create_index("token", unique=True)
24 | await self.authorized_users_col.create_index("user_id", unique=True)
25 | await self.col.create_index("id", unique=True)
26 | await self.token_col.create_index("expires_at", expireAfterSeconds=0)
27 | await self.token_col.create_index("activated")
28 | await self.restart_message_col.create_index("message_id", unique=True)
29 | await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600)
30 |
31 | logger.info("Database indexes ensured.")
32 |
33 | @log_errors
34 | def new_user(self, user_id: int) -> dict:
35 | return {
36 | 'id': user_id,
37 | 'join_date': datetime.datetime.utcnow()
38 | }
39 |
40 | @log_errors
41 | async def add_user(self, user_id: int):
42 | if not await self.is_user_exist(user_id):
43 | await self.col.insert_one(self.new_user(user_id))
44 | logger.debug(f"Added new user {user_id} to database.")
45 |
46 | @log_errors
47 | async def add_user_pass(self, user_id: int, ag_pass: str):
48 | await self.add_user(user_id)
49 | await self.col.update_one({'id': user_id}, {'$set': {'ag_p': ag_pass}})
50 | logger.debug(f"Updated password for user {user_id}.")
51 |
52 | @log_errors
53 | async def get_user_pass(self, user_id: int) -> Optional[str]:
54 | user_data = await self.col.find_one({'id': user_id}, {'ag_p': 1})
55 | return user_data.get('ag_p') if user_data else None
56 |
57 | @log_errors
58 | async def is_user_exist(self, user_id: int) -> bool:
59 | user = await self.col.find_one({'id': user_id}, {'_id': 1})
60 | return bool(user)
61 |
62 | @log_errors
63 | async def total_users_count(self) -> int:
64 | return await self.col.count_documents({})
65 |
66 | @log_errors
67 | async def get_all_users(self):
68 | return self.col.find({})
69 |
70 | @log_errors
71 | async def delete_user(self, user_id: int):
72 | await self.col.delete_one({'id': user_id})
73 | logger.info(f"Deleted user {user_id}.")
74 |
75 | @log_errors
76 | async def create_index(self):
77 | await self.col.create_index("id", unique=True)
78 | logger.info("Created index for 'id' on users collection.")
79 |
80 | @log_errors
81 | async def get_active_users(self, days: int = 7):
82 | cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=days)
83 | return self.col.find({'join_date': {'$gte': cutoff}})
84 |
85 | @log_errors
86 | async def add_banned_user(
87 | self, user_id: int, banned_by: Optional[int] = None,
88 | reason: Optional[str] = None, ban_time: Optional[str] = None
89 | ):
90 | ban_data = {
91 | "user_id": user_id,
92 | "banned_at": datetime.datetime.utcnow(),
93 | "banned_by": banned_by,
94 | "reason": reason
95 | }
96 | await self.banned_users_col.update_one(
97 | {"user_id": user_id},
98 | {"$set": ban_data},
99 | upsert=True
100 | )
101 | logger.info(f"Added/Updated banned user {user_id}. Reason: {reason}")
102 |
103 | @log_errors
104 | async def remove_banned_user(self, user_id: int) -> bool:
105 | result = await self.banned_users_col.delete_one({"user_id": user_id})
106 | if result.deleted_count > 0:
107 | logger.info(f"Removed banned user {user_id}.")
108 | return True
109 | return False
110 |
111 | @log_errors
112 | async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
113 | return await self.banned_users_col.find_one({"user_id": user_id})
114 |
115 | @log_errors
116 | async def save_main_token(self, user_id: int, token_value: str, expires_at: datetime.datetime, created_at: datetime.datetime, activated: bool) -> None:
117 | try:
118 | await self.token_col.update_one(
119 | {"user_id": user_id, "token": token_value},
120 | {"$set": {
121 | "expires_at": expires_at,
122 | "created_at": created_at,
123 | "activated": activated
124 | }
125 | },
126 | upsert=True
127 | )
128 | logger.info(f"Saved main token {token_value} for user {user_id} with activated status {activated}.")
129 | except Exception as e:
130 | logger.error(f"Error saving main token for user {user_id}: {e}")
131 | raise
132 |
133 | @log_errors
134 | async def save_broadcast_state(self, broadcast_id, state_data):
135 | await self.db.broadcasts.update_one(
136 | {"_id": broadcast_id},
137 | {"$set": state_data},
138 | upsert=True
139 | )
140 | logger.debug(f"Saved broadcast state for ID {broadcast_id}.")
141 |
142 | @log_errors
143 | async def get_broadcast_state(self, broadcast_id):
144 | return await self.db.broadcasts.find_one({"_id": broadcast_id})
145 |
146 | @log_errors
147 | async def list_active_broadcasts(self):
148 | cursor = self.db.broadcasts.find({"is_cancelled": False})
149 | return await cursor.to_list(length=None)
150 |
151 | @log_errors
152 | async def add_restart_message(self, message_id: int, chat_id: int) -> None:
153 | try:
154 | await self.restart_message_col.insert_one({
155 | "message_id": message_id,
156 | "chat_id": chat_id,
157 | "timestamp": datetime.datetime.utcnow()
158 | })
159 | logger.info(f"Added restart message {message_id} for chat {chat_id}.")
160 | except Exception as e:
161 | logger.error(f"Error adding restart message {message_id}: {e}")
162 |
163 | @log_errors
164 | async def get_restart_message(self) -> Optional[Dict[str, Any]]:
165 | try:
166 | return await self.restart_message_col.find_one(sort=[("timestamp", -1)])
167 | except Exception as e:
168 | logger.error(f"Error getting restart message: {e}")
169 | return None
170 |
171 | @log_errors
172 | async def delete_restart_message(self, message_id: int) -> None:
173 | try:
174 | await self.restart_message_col.delete_one({"message_id": message_id})
175 | logger.info(f"Deleted restart message {message_id}.")
176 | except Exception as e:
177 | logger.error(f"Error deleting restart message {message_id}: {e}")
178 |
179 | async def close(self):
180 | if self._client:
181 | self._client.close()
182 |
183 | db = Database(Var.DATABASE_URL, Var.NAME)
184 |
--------------------------------------------------------------------------------
/Thunder/utils/decorators.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/decorators.py
2 |
3 | from functools import wraps
4 | from pyrogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
5 | from Thunder.vars import Var
6 | from Thunder.utils.logger import logger
7 | from Thunder.utils.messages import (
8 | MSG_DECORATOR_BANNED,
9 | MSG_TOKEN_INVALID,
10 | MSG_ERROR_UNAUTHORIZED
11 | )
12 | from Thunder.utils.database import db
13 | from Thunder.utils.tokens import check, allowed, generate
14 | from Thunder.utils.shortener import shorten
15 |
16 |
17 | def check_banned(func):
18 | @wraps(func)
19 | async def wrapper(client, message: Message, *args, **kwargs):
20 | try:
21 | if not message.from_user:
22 | return await func(client, message, *args, **kwargs)
23 | user_id = message.from_user.id
24 | if isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID:
25 | return await func(client, message, *args, **kwargs)
26 | elif isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID:
27 | return await func(client, message, *args, **kwargs)
28 |
29 | ban_details = await db.is_user_banned(user_id)
30 | if ban_details:
31 | banned_at = ban_details.get('banned_at')
32 | ban_time = (
33 | banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
34 | if banned_at and hasattr(banned_at, 'strftime')
35 | else str(banned_at) if banned_at else 'N/A'
36 | )
37 | await message.reply_text(
38 | MSG_DECORATOR_BANNED.format(
39 | reason=ban_details.get('reason', 'Not specified'),
40 | ban_time=ban_time
41 | ),
42 | quote=True
43 | )
44 | logger.info(f"Blocked banned user {user_id} from accessing {func.__name__}.")
45 | return
46 | return await func(client, message, *args, **kwargs)
47 | except Exception as e:
48 | logger.error(f"Error in check_banned decorator for {func.__name__}: {e}")
49 | return await func(client, message, *args, **kwargs)
50 | return wrapper
51 |
52 | def require_token(func):
53 | @wraps(func)
54 | async def wrapper(client, message: Message, *args, **kwargs):
55 | try:
56 | if not message.from_user:
57 | return await func(client, message, *args, **kwargs)
58 |
59 | if not getattr(Var, "TOKEN_ENABLED", False):
60 | return await func(client, message, *args, **kwargs)
61 |
62 | user_id = message.from_user.id
63 | is_owner = (isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID) or \
64 | (isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID)
65 |
66 | if is_owner or await allowed(user_id) or await check(user_id):
67 | return await func(client, message, *args, **kwargs)
68 |
69 | temp_token_string = None
70 | try:
71 | temp_token_string = await generate(user_id)
72 | except Exception as e:
73 | logger.error(f"Failed to generate temporary token for user {user_id} in require_token: {e}")
74 | await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
75 | return
76 |
77 | if not temp_token_string:
78 | logger.error(f"Temporary token generation returned empty for user {user_id} in require_token.")
79 | await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
80 | return
81 |
82 | me = await client.get_me()
83 | deep_link = f"https://t.me/{me.username}?start={temp_token_string}"
84 | short_url = deep_link
85 |
86 | try:
87 | short_url_result = await shorten(deep_link)
88 | if short_url_result:
89 | short_url = short_url_result
90 | except Exception as e:
91 | logger.warning(f"Failed to shorten token link for user {user_id}: {e}. Using full link.")
92 |
93 | await message.reply_text(
94 | MSG_TOKEN_INVALID,
95 | reply_markup=InlineKeyboardMarkup([
96 | [InlineKeyboardButton("Activate Access", url=short_url)]
97 | ]),
98 | quote=True
99 | )
100 | logger.debug(f"Sent temporary token activation link to user {user_id} for {func.__name__}.")
101 | return
102 | except Exception as e:
103 | logger.error(f"Error in require_token decorator for {func.__name__}: {e}")
104 | try:
105 | await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
106 | except Exception as inner_e:
107 | logger.error(f"Failed to send error message to user in require_token: {inner_e}")
108 | return
109 | return wrapper
110 |
111 | def shorten_link(func):
112 | @wraps(func)
113 | async def wrapper(client, message: Message, *args, **kwargs):
114 | try:
115 | user_id = message.from_user.id if message.from_user else None
116 | use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
117 | if user_id:
118 | try:
119 | is_owner = (isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID) or \
120 | (isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID)
121 | if is_owner or await allowed(user_id):
122 | use_shortener = False
123 | except Exception as e:
124 | logger.warning(f"Error checking allowed status for user {user_id} in shorten_link: {e}. Defaulting shortener behavior.")
125 |
126 | kwargs['shortener'] = use_shortener
127 | return await func(client, message, *args, **kwargs)
128 | except Exception as e:
129 | logger.error(f"Error in shorten_link decorator for {func.__name__}: {e}")
130 | kwargs['shortener'] = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
131 | return await func(client, message, *args, **kwargs)
132 | return wrapper
133 |
134 | def owner_only(func):
135 | _cached_owner_ids = None
136 | @wraps(func)
137 | async def wrapper(client, callback_query: Message):
138 | nonlocal _cached_owner_ids
139 | try:
140 | if _cached_owner_ids is None:
141 | owner_ids_config = getattr(Var, 'OWNER_ID', [])
142 | if isinstance(owner_ids_config, int):
143 | _cached_owner_ids = {owner_ids_config}
144 | elif isinstance(owner_ids_config, (list, tuple, set)):
145 | _cached_owner_ids = set(owner_ids_config)
146 | else:
147 | _cached_owner_ids = set()
148 |
149 | if callback_query.from_user.id not in _cached_owner_ids:
150 | await callback_query.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True)
151 | logger.warning(f"Unauthorized access attempt by {callback_query.from_user.id} to owner_only function {func.__name__}.")
152 | return
153 |
154 | return await func(client, callback_query)
155 | except Exception as e:
156 | logger.error(f"Error in owner_only decorator for {func.__name__}: {e}")
157 | try:
158 | await callback_query.answer("An error occurred. Please try again.", show_alert=True)
159 | except Exception as inner_e:
160 | logger.error(f"Failed to send error answer in owner_only: {inner_e}")
161 | return
162 | return wrapper
163 |
--------------------------------------------------------------------------------
/Thunder/utils/error_handling.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/error_handling.py
2 |
3 | from functools import wraps
4 | from Thunder.utils.logger import logger
5 |
6 | def log_errors(func):
7 | @wraps(func)
8 | def wrapper(*args, **kwargs):
9 | try:
10 | return func(*args, **kwargs)
11 | except Exception as e:
12 | logger.error(f"Error in {func.__name__}: {e}", exc_info=True)
13 | raise
14 | return wrapper
--------------------------------------------------------------------------------
/Thunder/utils/file_properties.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/file_properties.py
2 |
3 | from pyrogram import Client
4 | from pyrogram.types import Message
5 | from pyrogram.file_id import FileId
6 | from Thunder.server.exceptions import FileNotFound
7 | from Thunder.utils.error_handling import log_errors
8 | from typing import Any, Optional
9 |
10 | def get_media_from_message(message: Message) -> Optional[Any]:
11 | for attr in ("audio", "document", "photo", "sticker", "animation", "video", "voice", "video_note"):
12 | if media := getattr(message, attr, None):
13 | return media
14 | return None
15 |
16 | @log_errors
17 | async def get_file_ids(client: Client, chat_id: int, message_id: int) -> Optional[FileId]: # Return Optional
18 | message = await client.get_messages(chat_id, message_id)
19 | if not message or message.empty:
20 | raise FileNotFound("Message not found/empty")
21 |
22 | media = get_media_from_message(message)
23 | if not media:
24 | raise FileNotFound("No media in message")
25 |
26 | if not hasattr(media, 'file_id') or not hasattr(media, 'file_unique_id'):
27 | raise FileNotFound("Media metadata incomplete")
28 |
29 | file_id_obj = FileId.decode(media.file_id)
30 | file_id_obj.file_size = getattr(media, "file_size", 0)
31 | file_id_obj.mime_type = getattr(media, "mime_type", "")
32 | file_id_obj.file_name = getattr(media, "file_name", "")
33 | file_id_obj.unique_id = media.file_unique_id
34 | return file_id_obj
35 |
36 | @log_errors
37 | def parse_file_id(message: Message) -> Optional[FileId]:
38 | media = get_media_from_message(message)
39 | return FileId.decode(media.file_id) if media and hasattr(media, 'file_id') else None
40 |
41 | @log_errors
42 | def parse_file_unique_id(message: Message) -> Optional[str]:
43 | media = get_media_from_message(message)
44 | return media.file_unique_id if media and hasattr(media, 'file_unique_id') else None
45 |
46 | @log_errors
47 | def get_hash(media_msg: Message) -> str:
48 | media = get_media_from_message(media_msg)
49 | return media.file_unique_id[:6] if media and hasattr(media, 'file_unique_id') and media.file_unique_id else ''
50 |
51 | @log_errors
52 | def get_name(media_msg: Message) -> str:
53 | media = get_media_from_message(media_msg)
54 | return getattr(media, 'file_name', '') if media and hasattr(media, 'file_name') else ''
55 |
56 | @log_errors
57 | def get_media_file_size(message: Message) -> int:
58 | media = get_media_from_message(message)
59 | return getattr(media, 'file_size', 0) if media and hasattr(media, 'file_size') else 0
60 |
--------------------------------------------------------------------------------
/Thunder/utils/force_channel.py:
--------------------------------------------------------------------------------
1 | from typing import Any, Callable, Coroutine
2 | from pyrogram import Client
3 | from pyrogram.enums import ChatMemberStatus
4 | from pyrogram.errors import UserNotParticipant, ChatAdminRequired, PeerIdInvalid
5 | from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
6 | from Thunder.vars import Var
7 | from Thunder.utils.error_handling import log_errors
8 |
9 | def force_channel_check(
10 | func: Callable[[Client, Message], Coroutine[Any, Any, Any]]
11 | ) -> Callable[[Client, Message], Coroutine[Any, Any, Any]]:
12 |
13 | @log_errors
14 | async def wrapper(client: Client, message: Message, *args, **kwargs) -> None:
15 | if not Var.FORCE_CHANNEL_ID or message.chat.id == Var.FORCE_CHANNEL_ID:
16 | return await func(client, message, *args, **kwargs)
17 | try:
18 | member = await client.get_chat_member(Var.FORCE_CHANNEL_ID, message.from_user.id)
19 | if member.status in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]:
20 | return await func(client, message, *args, **kwargs)
21 | except UserNotParticipant:
22 | pass
23 | except (ChatAdminRequired, PeerIdInvalid):
24 | return await func(client, message, *args, **kwargs)
25 | chat = await client.get_chat(Var.FORCE_CHANNEL_ID)
26 | invite_link = f"https://t.me/{chat.username}" if chat.username else chat.invite_link
27 | if invite_link:
28 | await message.reply(
29 | f"Please join {chat.title} to use this bot.",
30 | reply_markup=InlineKeyboardMarkup([[
31 | InlineKeyboardButton("Join Channel", url=invite_link)
32 | ]])
33 | )
34 | return wrapper
35 |
--------------------------------------------------------------------------------
/Thunder/utils/human_readable.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/human_readable.py
2 | from Thunder.utils.error_handling import log_errors
3 |
4 | _UNITS = ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
5 |
6 | @log_errors
7 | def humanbytes(size: int, decimal_places: int = 2) -> str:
8 | if not size:
9 | return "0 B"
10 | n = 0
11 | while size >= 1024 and n < len(_UNITS) - 1:
12 | size /= 1024
13 | n += 1
14 | return f"{round(size, decimal_places)} {_UNITS[n]}B"
15 |
--------------------------------------------------------------------------------
/Thunder/utils/keepalive.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/keepalive.py
2 |
3 | import asyncio
4 | import aiohttp
5 | from Thunder.vars import Var
6 | from Thunder.utils.logger import logger
7 | from Thunder.utils.error_handling import log_errors
8 |
9 | @log_errors
10 | async def ping_server():
11 | async with aiohttp.ClientSession(
12 | timeout=aiohttp.ClientTimeout(total=10)
13 | ) as session:
14 | while True:
15 | await asyncio.sleep(Var.PING_INTERVAL)
16 | async with session.get(Var.URL) as resp:
17 | if resp.status != 200:
18 | logger.warning(f"Ping to {Var.URL} returned status {resp.status}.")
19 |
--------------------------------------------------------------------------------
/Thunder/utils/logger.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/logger.py
2 |
3 | import logging
4 | from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener
5 | import os
6 | import queue
7 | import atexit
8 |
9 | # Setup paths
10 | LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
11 | os.makedirs(LOG_DIR, exist_ok=True)
12 | LOG_FILE = os.path.join(LOG_DIR, 'bot.txt')
13 |
14 | logging._srcfile = None
15 | logging.logThreads = 0
16 | logging.logProcesses = 0
17 |
18 | log_queue = queue.Queue(maxsize=10000)
19 |
20 | formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
21 |
22 | file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10*1024*1024, backupCount=5)
23 | file_handler.setFormatter(formatter)
24 |
25 | console_handler = logging.StreamHandler()
26 | console_handler.setFormatter(formatter)
27 |
28 | listener = QueueListener(log_queue, file_handler, console_handler, respect_handler_level=True)
29 | listener.start()
30 |
31 | logger = logging.getLogger('ThunderBot')
32 | logger.setLevel(logging.INFO)
33 | logger.propagate = False
34 | logger.addHandler(QueueHandler(log_queue))
35 |
36 | atexit.register(listener.stop)
37 |
38 | __all__ = ['logger', 'LOG_FILE']
39 |
--------------------------------------------------------------------------------
/Thunder/utils/messages.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/messages.py
2 |
3 | # =====================================================================================
4 | # ====== ERROR MESSAGES ======
5 | # =====================================================================================
6 |
7 | # ------ General Errors ------
8 | MSG_ERROR_GENERIC = "⚠️ **Oops!** Something went wrong. Please try again. If the issue persists, contact support."
9 | MSG_ERROR_USER_INFO = "❗ **User Not Found:** Couldn't find user. Please check the ID or Username."
10 | MSG_ERROR_INVALID_ARG = "❗ Please provide a valid Telegram user ID or username."
11 | MSG_ERROR_RATE_LIMIT = "⏳ **Slow Down!** Too many requests. Please wait `{seconds}` seconds."
12 | MSG_ERROR_PRIVATE_CHAT_ONLY = "⚠️ **Private Chat Only:** This command works only in a private chat with me."
13 |
14 | # ------ User Input & Validation Errors ------
15 | MSG_INVALID_USER_ID = "❌ **Invalid User ID:** Please provide a numeric user ID."
16 | MSG_ERROR_START_BOT = "⚠️ You need to start the bot in private first to use this command.\n👉 [Click here]({invite_link}) to start a private chat."
17 | MSG_ERROR_REPLY_FILE = "⚠️ Please use the /link command in reply to a file."
18 | MSG_ERROR_NO_FILE = "⚠️ The message you're replying to does not contain any file."
19 | MSG_ERROR_INVALID_NUMBER = "⚠️ **Invalid number specified.**"
20 | MSG_ERROR_NUMBER_RANGE = "⚠️ **Please specify a number between 1 and 100.**"
21 | MSG_ERROR_DM_FAILED = "⚠️ I couldn't send you a Direct Message. Please start the bot first."
22 |
23 | # ------ File & Media Errors ------
24 | MSG_ERROR_FILE_INVALID = "🚫 **File Error:** Invalid file. It might be deleted or inaccessible."
25 | MSG_ERROR_FILE_INVALID_ID = "🚫 **File Error:** Invalid file. It might be deleted or inaccessible. Please provide a valid message ID from the bot's storage channel."
26 | MSG_ERROR_LINK_GENERATION = "⚠️ **Link Generation Failed!** 🔗 Unable to create links for this file. It might be inaccessible or corrupted."
27 | MSG_ERROR_FILE_ID_EXTRACT = "⚠️ **File Error:** Could not extract file identifier from the media. Please try sending the file again."
28 | MSG_MEDIA_ERROR = "⚠️ **Media Error:** The file appears to be empty or corrupted. Please try sending a valid file."
29 | MSG_ERROR_PROCESSING_MEDIA = "⚠️ **Oops!** Something went wrong while processing your media. Please try again. If the issue persists, contact support."
30 | MSG_ERROR_CHANNEL_BANNED = "🚫 **Channel Banned:** Files from this channel are blocked."
31 | MSG_ERROR_NO_MEDIA_FOUND = "⚠️ **No Media Found:** Please send or reply to a valid media file."
32 | MSG_FILE_ACCESS_ERROR = "⚙️ **Error Retrieving File!** Could not fetch details. File might be unavailable, ID incorrect, or deleted from storage."
33 |
34 | # ------ Admin Action Errors (Ban, Auth, etc.) ------
35 | MSG_BAN_ERROR = "🚨 **Ban error:** {error}"
36 | MSG_UNBAN_ERROR = "🚨 **Unban error:** {error}"
37 | MSG_AUTHORIZE_FAILED = (
38 | "❌ **Authorization Failed:** "
39 | "Could not authorize user `{user_id}`."
40 | )
41 | MSG_DEAUTHORIZE_FAILED = (
42 | "❌ **Deauthorization Failed:** "
43 | "User `{user_id}` was not authorized or an error occurred."
44 | )
45 | MSG_TOKEN_FAILED = (
46 | "⚠️ **Token Activation Failed!**\n\n"
47 | "> ❗ Reason: {reason}\n\n"
48 | "🔑 Please check your token or contact support."
49 | )
50 | MSG_TOKEN_ERROR = "⚙️ **Token Activation Error:** Something went wrong. Please try again."
51 | MSG_START_INVALID_PAYLOAD = "Invalid command format or expired/invalid link. Please use a valid command or activation link. Error ID: {error_id}"
52 | MSG_SHELL_ERROR = """**❌ Shell Command Error ❌**
53 | {error} """
54 | MSG_SHELL_LARGE_OUTPUT = """Output is too large, sending as a file.
55 | Error: {error}"""
56 |
57 | # ------ System & Bot Errors ------
58 | MSG_ERROR_NOT_ADMIN = "⚠️ **Admin Required:** I need admin privileges to work here."
59 | MSG_DC_INVALID_USAGE = "🤔 **Invalid Usage:** Please reply to a user's message or a media file to get DC info."
60 | MSG_DC_ANON_ERROR = "😥 **Cannot Get Your DC Info:** Unable to identify you. This command might not work for anonymous users."
61 | MSG_DC_FILE_ERROR = "⚙️ **Error Getting File DC Info:** Could not fetch details. File might be inaccessible."
62 | MSG_STATS_ERROR = "❌ **Stats Error:** Could not retrieve system statistics."
63 | MSG_STATUS_ERROR = "❌ **Status Error:** Could not retrieve system status."
64 | MSG_DB_ERROR = "❌ **Database Error:** Could not retrieve user count."
65 | MSG_LOG_ERROR = "❌ **Log Retrieval Error:** Could not get logs\n\n> ❗ Error: `{error}`"
66 | MSG_RESTART_FAILED = "❌ **Restart Failed:** Could not reboot the bot."
67 | MSG_CRITICAL_ERROR = (
68 | "🚨 **Critical Media Processing Error** 🚨\n\n"
69 | "> ⚠️ Details:\n```\n{error}\n```\n\n"
70 | "Please investigate immediately! (ID: {error_id})"
71 | )
72 |
73 | # =====================================================================================
74 | # ====== ADMIN MESSAGES ======
75 | # =====================================================================================
76 |
77 | # ------ Ban/Unban ------
78 | MSG_DECORATOR_BANNED = "You are currently banned and cannot use this bot.\nReason: {reason}\nBanned on: {ban_time}"
79 | MSG_BAN_USAGE = "⚠️ **Usage:** /ban [user_id] [reason]"
80 | MSG_CANNOT_BAN_OWNER = "❌ **Cannot ban an owner.**"
81 | MSG_ADMIN_USER_BANNED = "✅ **User {user_id} has been banned."
82 | MSG_BAN_REASON_SUFFIX = "\n📝 **Reason:** {reason}"
83 | MSG_ADMIN_NO_BAN_REASON = "No reason provided"
84 | MSG_USER_BANNED_NOTIFICATION = "🚫 **You have been banned from using this bot.**"
85 | MSG_COULD_NOT_NOTIFY_USER = "⚠️ Could not notify user {user_id}: {error}"
86 | MSG_UNBAN_USAGE = "⚠️ **Usage:** /unban "
87 | MSG_ADMIN_USER_UNBANNED = "✅ **User {user_id} has been unbanned."
88 | MSG_USER_UNBANNED_NOTIFICATION = "🎉 **You have been unbanned from using this bot.**"
89 | MSG_USER_NOT_IN_BAN_LIST = "ℹ️ **User {user_id} was not found in the ban list."
90 |
91 | # ------ Token & Authorization ------
92 | MSG_TOKEN_DISABLED = "🚫 **Token System Disabled:** This feature is not currently enabled."
93 | MSG_AUTHORIZE_USAGE = "🔑 **Usage:** `/authorize `"
94 | MSG_DEAUTHORIZE_USAGE = "🔒 **Usage:** `/deauthorize `"
95 | MSG_AUTHORIZE_SUCCESS = (
96 | "✅ **User Authorized!**\n\n"
97 | "> 👤 User ID: `{user_id}`\n"
98 | "> 🔑 Access: Permanent"
99 | )
100 | MSG_DEAUTHORIZE_SUCCESS = (
101 | "✅ **User Deauthorized!**\n\n"
102 | "> 👤 User ID: `{user_id}`\n"
103 | "> 🔒 Access: Revoked"
104 | )
105 | MSG_TOKEN_ACTIVATED = "✅ Token successfully activated!\n\n⏳ This token is valid for {duration_hours} hours."
106 | MSG_TOKEN_VERIFIED = "🎉 **Token Verified!** You're all set to use the bot's features."
107 | MSG_TOKEN_INVALID = "Access to this feature requires an active token. Please click the button below to activate your access token."
108 | MSG_NO_AUTH_USERS = "ℹ️ **No Authorized Users Found:** The list is currently empty."
109 | MSG_AUTH_USER_INFO = """{i}. 👤 User ID: `{user_id}`
110 | • Authorized by: `{authorized_by}`
111 | • Date: `{auth_time}`\n\n"""
112 | MSG_ADMIN_AUTH_LIST_HEADER = "🔐 **Authorized Users List**\n\n"
113 |
114 | # ------ Shell Commands ------
115 | MSG_SHELL_USAGE = (
116 | "Usage: \n"
117 | "/shell \n\n"
118 | "Example: \n"
119 | "/shell ls -l"
120 | )
121 | MSG_SHELL_EXECUTING = "Executing Command... ⚙️\n{command} "
122 | MSG_SHELL_OUTPUT = """**Shell Command Output:**
123 | {output} """
124 | MSG_SHELL_OUTPUT_STDOUT = "[stdout]: \n{output} "
125 | MSG_SHELL_OUTPUT_STDERR = "[stderr]: \n{error} "
126 | MSG_SHELL_NO_OUTPUT = "✅ Command Executed: No output."
127 | MSG_ADMIN_SHELL_STDOUT_PLAIN = "[stdout]:\n{output}\n"
128 | MSG_ADMIN_SHELL_STDERR_PLAIN = "[stderr]:\n{error}\n"
129 | MSG_SHELL_OUTPUT_FILENAME = "shell_output.txt"
130 |
131 | # ------ Admin View & Control ------
132 | MSG_ADMIN_RESTART_BROADCAST = "🔄 Restart Broadcast"
133 | MSG_ADMIN_CANCEL_BROADCAST = "🛑 Cancel Broadcast"
134 | MSG_ADMIN_CANCEL_BROADCAST_BUTTON_TEXT = "ID: {broadcast_id} | Progress: {progress} | Time: {elapsed}"
135 | MSG_ADMIN_BOT_WORKLOAD_HEADER = "🤖 **Bot Workload Distribution:**\n\n"
136 | MSG_ADMIN_BOT_WORKLOAD_ITEM = " {bot_name}: {load}\n"
137 | MSG_ADMIN_BROADCAST_PROGRESS_ITEM = "ID: {broadcast_id} | Progress: {progress} | Time: {elapsed}"
138 | MSG_ADMIN_RESTART_DONE = "✅ **Restart Successful!**"
139 |
140 | # =====================================================================================
141 | # ====== BUTTON TEXTS (User-facing) ======
142 | # =====================================================================================
143 | MSG_BUTTON_STREAM_NOW = "🖥️ Stream"
144 | MSG_BUTTON_DOWNLOAD = "📥 Download"
145 | MSG_BUTTON_GET_HELP = "📖 Get Help"
146 | MSG_BUTTON_QUICK_START = "🚀 Quick Start"
147 | MSG_BUTTON_CANCEL_BROADCAST = "🛑 Cancel Broadcast"
148 | MSG_BUTTON_VIEW_PROFILE = "👤 View User Profile"
149 | MSG_BUTTON_ABOUT = "ℹ️ About Bot"
150 | MSG_BUTTON_STATUS = "📡 Status"
151 | MSG_BUTTON_JOIN_CHANNEL = "📢 Join {channel_title}"
152 | MSG_BUTTON_GITHUB = "🛠️ GitHub"
153 | MSG_BUTTON_START_CHAT = "📩 Start Chat"
154 | MSG_BUTTON_JOIN_CHAT = "📢 Join {chat_title}"
155 | MSG_BUTTON_CLOSE = "✖ Close"
156 |
157 | # ------ Quick Start Guide ------
158 | MSG_QUICK_START_GUIDE = (
159 | "🚀 **Quick Start Guide** 🚀\n\n"
160 | "Welcome to the Thunder File to Link Bot! Here's how to get started:\n\n"
161 | "1. **Private Chat:** Send any file directly to me, and I'll reply with download and stream links.\n"
162 | "2. **Groups:** Reply to a file with the `/link` command. For multiple files, reply to the first file with `/link ` (e.g., `/link 5`).\n"
163 | "3. **Explore:** Use `/help` to see all available commands and features.\n\n"
164 | "Enjoy fast and easy file sharing! ⚡"
165 | )
166 |
167 | # =====================================================================================
168 | # ====== COMMAND RESPONSES (User-facing) ======
169 | # =====================================================================================
170 |
171 | # ------ Welcome, Help, About ------
172 | MSG_WELCOME = (
173 | "🌟 **Welcome, {user_name}!** 🌟\n\n"
174 | "I'm **Thunder File to Link Bot** ⚡\n"
175 | "I generate direct download and streaming links for your files.\n\n"
176 | "**How to use:**\n"
177 | "1. Send any file to me for private links.\n"
178 | "2. In groups, reply to a file with `/link`.\n\n"
179 | "» Use `/help` for all commands and detailed information.\n\n"
180 | "🚀 Send a file to begin!"
181 | )
182 |
183 | MSG_HELP = (
184 | "📘 **Thunder Bot - Help Guide** 📖\n\n"
185 | "How to get direct download & streaming links:\n\n"
186 | "**🚀 Private Chat (with me):**\n"
187 | "> 1. Send me **any file** (document, video, audio, photo, etc.).\n"
188 | "> 2. I'll instantly reply with your links! ⚡\n\n"
189 | "**👥 Using in Groups:**\n"
190 | "> • Reply to any file with `/link`.\n"
191 | "> • **Batch Mode:** Reply to the *first* file with `/link ` (e.g., `/link 5` for 5 files, up to 100).\n"
192 | "> • Bot needs administrator rights in the group to function.\n"
193 | "> • Links are posted in the group & sent to you privately (if you have started a chat with me).\n"
194 | "> • *Optional:* If the bot is an admin with delete rights, it can be configured to auto-link new files.\n\n"
195 | "**📢 Using in Channels:**\n"
196 | "> • Add me as an administrator with necessary permissions.\n"
197 | "> • I can be configured to auto-detect new media files.\n"
198 | "> • Inline stream/download buttons can be added to files automatically.\n"
199 | "> • Files from banned channels (owner configuration) are rejected.\n"
200 | "> • Auto-posting links for new files is a configurable option.\n\n"
201 | "**⚙️ Available Commands:**\n"
202 | "> `/start` 👋 - Welcome message & quick start information.\n"
203 | "> `/help` 📖 - Shows this help message.\n"
204 | "> `/link ` 🔗 - (Groups) Generate links. For batch processing: `/link ` (1-100 files).\n"
205 | "> `/about` ℹ️ - Learn more about me and my features.\n"
206 | "> `/ping` 📡 - Check my responsiveness and online status.\n"
207 | "> `/dc` 🌍 - View DC information (for yourself, another user, or a file).\n\n"
208 | "**💡 Pro Tips:**\n"
209 | "> • You can forward files from other chats directly to me.\n"
210 | "> • If you encounter a rate limit message, please wait the specified time. ⏳\n"
211 | "> • For `/link` in groups to work reliably (and for private link delivery), ensure you've started a private chat with me first.\n"
212 | "> • Processing batch files might take a bit longer. Please be patient. 🐌\n\n"
213 | "❓ Questions? Please ask in our support group!"
214 | )
215 |
216 | MSG_ABOUT = (
217 | "🌟 **About Thunder File to Link Bot** ℹ️\n\n"
218 | "I'm your go-to bot for **instant download & streaming!** ⚡\n\n"
219 | "**🚀 Key Features:**\n"
220 | "> **Instant Links:** Get your links within seconds.\n"
221 | "> **Online Streaming:** Watch videos or listen to audio directly (for supported formats).\n"
222 | "> **Universal File Support:** Handles documents, videos, audio, photos, and more.\n"
223 | "> **High-Speed Access:** Optimized for fast link generation and file access.\n"
224 | "> **Secure & Reliable:** Your files are handled with care during processing.\n"
225 | "> **User-Friendly Interface:** Designed for ease of use on any device.\n"
226 | "> **Efficient Processing:** Built for speed and reliability.\n"
227 | "> **Batch Mode:** Process multiple files at once in groups using `/link `.\n"
228 | "> **Versatile Usage:** Works in private chats, groups, and channels (with admin setup).\n\n"
229 | "💖 If you find me useful, please consider sharing me with your friends!"
230 | )
231 |
232 | # ------ Ping ------
233 | MSG_PING_START = "🛰️ **Pinging...** Please wait."
234 | MSG_PING_RESPONSE = (
235 | "🚀 **PONG! Bot is Online!** ⚡\n"
236 | "> ⏱️ **Response Time:** {time_taken_ms:.2f} ms\n"
237 | "> 🤖 **Bot Status:** `Active & Ready`"
238 | )
239 |
240 | # ------ DC Info ------
241 | MSG_DC_USER_INFO = (
242 | "📍 **Information**\n"
243 | "> 👤 **User:** [{user_name}](tg://user?id={user_id})\n"
244 | "> 🆔 **User ID:** `{user_id}`\n"
245 | "> 🌍 **DC ID:** `{dc_id}`"
246 | )
247 |
248 | MSG_DC_FILE_INFO = (
249 | "🗂️ **File Information**\n"
250 | ">`{file_name}`\n"
251 | "💾 **File Size:** `{file_size}`\n"
252 | "📁 **File Type:** `{file_type}`\n"
253 | "🌍 **DC ID:** `{dc_id}`"
254 | )
255 |
256 | MSG_DC_UNKNOWN = "Unknown"
257 |
258 | # ------ File Link Generation ------
259 | MSG_LINKS = (
260 | "✨ **Your Links are Ready!** ✨\n\n"
261 | "> `{file_name}`\n\n"
262 | "📂 **File Size:** `{file_size}`\n\n"
263 | "🔗 **Download Link:**\n`{download_link}`\n\n"
264 | "🖥️ **Stream Link:**\n`{stream_link}`\n\n"
265 | "⌛️ *Note: Links remain active while the bot is running and the file is accessible.*"
266 | )
267 |
268 | # =====================================================================================
269 | # ====== USER NOTIFICATIONS ======
270 | # =====================================================================================
271 |
272 | MSG_NEW_USER = (
273 | "✨ **New User Alert!** ✨\n"
274 | "> 👤 **Name:** [{first_name}](tg://user?id={user_id})\n"
275 | "> 🆔 **User ID:** `{user_id}`\n\n"
276 | )
277 |
278 | MSG_PRIVATE_CHAT_WELCOME = (
279 | "👋 **Welcome!** Send me any file to get started.\n"
280 | "> 📤 I'll generate instant download & streaming links for you.\n"
281 | "> ⚡ Fast and reliable service.\n"
282 | "> 🔒 Your files are handled securely."
283 | )
284 | MSG_DEFAULT_WELCOME = "📢 Don't forget our channel for the latest news & features!"
285 | MSG_COMMUNITY_CHANNEL = "📢 **{channel_title}:** 🔒 Join this channel to use the bot."
286 |
287 | # =====================================================================================
288 | # ====== PROCESSING MESSAGES ======
289 | # =====================================================================================
290 |
291 | # ------ General File Processing ------
292 | MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**"
293 | MSG_PROCESSING_FILE = "⏳ **Processing your file...**"
294 | MSG_DEFAULT_FILENAME = "Untitled File"
295 | MSG_NEW_FILE_REQUEST = (
296 | "> 👤 **Source:** [{source_info}](tg://user?id={id_})\n"
297 | "> 🆔 **ID:** `{id_}`\n\n"
298 | "🔗 **Download:** `{online_link}`\n\n"
299 | "🖥️ **Stream:** `{stream_link}`"
300 | )
301 |
302 | # ------ Batch Processing ------
303 | MSG_PROCESSING_BATCH = "🔄 **Processing Batch {batch_number}/{total_batches}** ({file_count} files)"
304 | MSG_PROCESSING_STATUS = "📊 **Processing Files:** {processed}/{total} complete, {failed} failed"
305 | MSG_PROCESSING_WARNING = "⚠️ **Warning:** Too many files failed processing. Please try again with fewer files or contact support."
306 | MSG_BATCH_LINKS_READY = "🔗 Here are your {count} download links:"
307 | MSG_DM_BATCH_PREFIX = "📬 **Batch Links from {chat_title}**\n"
308 | MSG_LINK_FROM_GROUP = "📬 **Links from {chat_title}**\n\n{links_message}"
309 | MSG_PROCESSING_RESULT = "✅ **Process Complete:** {processed}/{total} files processed successfully, {failed} failed"
310 | MSG_PROCESSING_ERROR = "❌ **Error Processing Files:** {error}\n\n{processed}/{total} files were processed (ID: {error_id})"
311 | MSG_RETRYING_FILES = "🔄 **Retrying {count} Failed Files...**"
312 |
313 | # =====================================================================================
314 | # ====== BROADCAST MESSAGES ======
315 | # =====================================================================================
316 | MSG_BROADCAST_START = "📣 **Starting Broadcast...**\n\n> ⏳ Please wait for completion."
317 | MSG_BROADCAST_PROGRESS = (
318 | "📊 **Broadcast Progress**\n\n"
319 | "> 👥 **Total Users:** `{total_users}`\n"
320 | "> ✅ **Processed:** `{processed}/{total_users}`\n"
321 | "> ⏱️ **Elapsed:** `{elapsed_time}`\n\n"
322 | "> ✓ **Sent:** `{successes}`\n"
323 | "> ✗ **Failed:** `{failures}`"
324 | )
325 |
326 | # =====================================================================================
327 | # ====== PERMISSION MESSAGES ======
328 | # =====================================================================================
329 | MSG_ERROR_UNAUTHORIZED = "You are not authorized to view this information."
330 | MSG_ERROR_BROADCAST_RESTART = "Please use the /broadcast command to start a new broadcast."
331 | MSG_ERROR_BROADCAST_INSTRUCTION = "To start a new broadcast, use the /broadcast command and reply to the message you want to broadcast."
332 | MSG_ERROR_CALLBACK_UNSUPPORTED = "This button is not active or no longer supported."
333 | MSG_ERROR_GENERIC_CALLBACK = "An error occurred. Please try again later. (ID: {error_id})"
334 | MSG_BROADCAST_COMPLETE = (
335 | "📢 **Broadcast Completed Successfully!** 📢\n\n"
336 | "⏱️ **Duration:** `{elapsed_time}`\n"
337 | "👥 **Total Users:** `{total_users}`\n"
338 | "✅ **Successful Deliveries:** `{successes}`\n"
339 | "❌ **Failed Deliveries:** `{failures}`\n\n"
340 | "🗑️ **Accounts Removed (Blocked/Deactivated):** `{deleted_accounts}`\n"
341 | )
342 | MSG_BROADCAST_CANCEL = "🛑 **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations..."
343 | MSG_BROADCAST_FAILED = (
344 | "❌ **Broadcast Failed!** 😞\n\n"
345 | "> ❗ **Error Details:**\n```\n{error}\n``` (ID: {error_id})"
346 | )
347 | MSG_INVALID_BROADCAST_CMD = "Please reply to the message you want to broadcast."
348 | MSG_NO_ACTIVE_BROADCASTS = "ℹ️ **No Active Broadcasts:** Nothing to cancel at the moment."
349 | MSG_BROADCAST_NOT_FOUND = "⚠️ **Broadcast Not Found:** This broadcast is no longer active or has finished."
350 | MSG_MULTIPLE_BROADCASTS = "🔄 **Multiple Broadcasts Active:** Select one to cancel:"
351 | MSG_CANCELLING_BROADCAST = "🛑 **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations... Please wait."
352 |
353 | # =====================================================================================
354 | # ====== FORCE CHANNEL MESSAGES ======
355 | # =====================================================================================
356 |
357 | MSG_FORCE_CHANNEL_ERROR = "Sorry, there was an issue verifying access. Please try again later. (ID: {error_id})"
358 | MSG_FORCE_CHANNEL_RPC_ERROR = "An unexpected error occurred while checking channel membership. Please try again. (ID: {error_id})"
359 | MSG_FORCE_CHANNEL_GENERIC_ERROR = "An error occurred. Please try again. (ID: {error_id})"
360 | MSG_FORCE_CHANNEL_NO_LINK = "To use this bot, you must join our main channel. Please contact an admin for assistance."
361 | MSG_FORCE_CHANNEL_ACCESS_REQUIRED = (
362 | "🚫 **Access Required**\n\n"
363 | "Please join our channel to use this bot:\n{invite_link}\n\n"
364 | "After joining, try your command again."
365 | )
366 | MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION = "⚠️ Temporary service interruption. Please try again later. (ID: {error_id})"
367 | MSG_FORCE_CHANNEL_MEMBERSHIP_REQUIRED = "🔒 This command requires channel membership. Please contact support if you need assistance. (ID: {error_id})"
368 |
369 | # =====================================================================================
370 | # ====== FILE TYPE DESCRIPTIONS ======
371 | # =====================================================================================
372 | MSG_FILE_TYPE_DOCUMENT = "📄 Document"
373 | MSG_FILE_TYPE_PHOTO = "🖼️ Photo"
374 | MSG_FILE_TYPE_VIDEO = "🎬 Video"
375 | MSG_FILE_TYPE_AUDIO = "🎵 Audio"
376 | MSG_FILE_TYPE_VOICE = "🎤 Voice Message"
377 | MSG_FILE_TYPE_STICKER = "🎨 Sticker"
378 | MSG_FILE_TYPE_ANIMATION = "🎞️ Animation (GIF)"
379 | MSG_FILE_TYPE_VIDEO_NOTE = "📹 Video Note"
380 | MSG_FILE_TYPE_UNKNOWN = "❓ Unknown File Type"
381 |
382 | # =====================================================================================
383 | # ====== SYSTEM & STATUS MESSAGES (Bot Health, Logs, Stats) ======
384 | # =====================================================================================
385 |
386 | MSG_RESTARTING = "🔄 **Restarting Bot...**\n\n> ⏳ Please wait a moment."
387 | MSG_LOG_FILE_CAPTION = "📄 **System Logs**\n\n> ℹ️ Latest log file"
388 | MSG_LOG_FILE_EMPTY = "ℹ️ **Log File Empty:** No data found in the log file."
389 | MSG_LOG_FILE_MISSING = "⚠️ **Log File Missing:** Could not find the log file."
390 | MSG_SYSTEM_STATUS = (
391 | "✅ **System Status:** Operational\n\n"
392 | "> 🕒 **Uptime:** `{uptime}`\n"
393 | "> 🤖 **Active Bot Instances:** `{active_bots}`\n\n"
394 | "{workloads}\n"
395 | "> ♻️ **Bot Version:** `{version}`"
396 | )
397 | MSG_SYSTEM_STATS = (
398 | "📊 **System Statistics**\n\n"
399 | "> 🕒 **Uptime:** `{uptime}`\n\n"
400 | "💾 **Storage (Server):**\n"
401 | "> 📀 Total: `{total}`\n"
402 | "> 📝 Used: `{used}`\n"
403 | "> 📭 Free: `{free}`\n\n"
404 | "📶 **Network (Server):**\n"
405 | "> 🔺 Upload: `{upload}`\n"
406 | "> 🔻 Download: `{download}`\n\n"
407 | )
408 | MSG_PERFORMANCE_STATS = (
409 | "⚙️ **Performance (Server):**\n"
410 | "> 🖥️ CPU: `{cpu_percent}%`\n"
411 | "> 🧠 RAM: `{ram_percent}%`\n"
412 | "> 📦 Disk: `{disk_percent}%`"
413 | )
414 | MSG_DB_STATS = "📊 **Database Statistics**\n\n> 👥 **Total Users:** `{total_users}`"
415 | MSG_BOT_WORKLOAD_ITEM = "🔹 Bot {num}: {load}"
416 | MSG_BOT_WORKLOAD_TEXT = " {bot_name}: {load}\n"
--------------------------------------------------------------------------------
/Thunder/utils/render_template.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/render_template.py
2 |
3 | import urllib.parse
4 | import html as html_module
5 | from jinja2 import Environment, FileSystemLoader
6 | from Thunder.vars import Var
7 | from Thunder.bot import StreamBot
8 | from Thunder.utils.file_properties import get_file_ids
9 | from Thunder.server.exceptions import InvalidHash
10 | from Thunder.utils.error_handling import log_errors
11 |
12 | template_env = Environment(
13 | loader=FileSystemLoader('Thunder/template'),
14 | enable_async=True,
15 | cache_size=200,
16 | auto_reload=False,
17 | optimized=True
18 | )
19 |
20 | @log_errors
21 | async def render_page(id: int, secure_hash: str, requested_action: str | None = None) -> str:
22 | file_data = await get_file_ids(StreamBot, int(Var.BIN_CHANNEL), id)
23 | if file_data.unique_id[:6] != secure_hash:
24 | raise InvalidHash
25 | quoted_filename = urllib.parse.quote(file_data.file_name.replace('/', '_'))
26 | src = urllib.parse.urljoin(Var.URL, f'{secure_hash}{id}/{quoted_filename}')
27 | safe_filename = html_module.escape(file_data.file_name)
28 | if requested_action == 'stream':
29 | template = template_env.get_template('req.html')
30 | context = {
31 | 'tag': "video",
32 | 'heading': f"View {safe_filename}",
33 | 'file_name': safe_filename,
34 | 'src': src
35 | }
36 | else:
37 | template = template_env.get_template('dl.html')
38 | context = {
39 | 'file_name': safe_filename,
40 | 'src': src
41 | }
42 | return await template.render_async(**context)
43 |
--------------------------------------------------------------------------------
/Thunder/utils/retry_api.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/retry_api.py
2 |
3 | import asyncio
4 | import random
5 | from typing import Callable, TypeVar, Any, Awaitable
6 | from pyrogram.errors import FloodWait, RPCError
7 | from Thunder.utils.logger import logger
8 |
9 | T = TypeVar('T')
10 | DEFAULT_RETRY_COUNT = 3
11 | BASE_RETRY_DELAY = 1.0
12 | MAX_FLOOD_WAIT = 30
13 | MAX_JITTER_FACTOR = 0.1
14 |
15 | async def retry_api_call(
16 | func: Callable[[], Awaitable[T]],
17 | max_retries: int = DEFAULT_RETRY_COUNT,
18 | base_delay: float = BASE_RETRY_DELAY,
19 | operation_name: str = "API call"
20 | ) -> T:
21 | for attempt in range(1, max_retries + 1):
22 | try:
23 | if attempt > 1:
24 | logger.debug(f"Retry attempt {attempt} for {operation_name}.")
25 | return await func()
26 | except FloodWait as e:
27 | if attempt == max_retries:
28 | logger.error(f"Max retries reached for {operation_name} due to FloodWait: {e}")
29 | raise
30 | wait_time = min(e.value, MAX_FLOOD_WAIT)
31 | logger.warning(f"FloodWait ({e.value}s) for {operation_name}, sleeping {wait_time}s before retry.")
32 | await asyncio.sleep(wait_time)
33 | except (asyncio.TimeoutError, RPCError) as e:
34 | if attempt == max_retries:
35 | logger.error(f"Max retries reached for {operation_name} due to {e.__class__.__name__}: {e}")
36 | raise
37 | delay = base_delay * (2 ** (attempt - 1))
38 | jitter = random.uniform(0, delay * MAX_JITTER_FACTOR)
39 | logger.warning(f"{e.__class__.__name__} during {operation_name}: {e}. Retrying in {delay + jitter:.2f}s.")
40 | await asyncio.sleep(delay + jitter)
41 | except Exception as e:
42 | if attempt == max_retries:
43 | logger.error(f"Max retries reached for {operation_name} due to unexpected error: {e}")
44 | raise
45 | delay = base_delay * (2 ** (attempt - 1))
46 | logger.warning(f"Unexpected error {e.__class__.__name__} during {operation_name}: {e}. Retrying in {delay}s.")
47 | await asyncio.sleep(delay)
48 |
49 | async def retry_api_operation(
50 | client: Any,
51 | operation: str,
52 | *args: Any,
53 | max_retries: int = DEFAULT_RETRY_COUNT,
54 | base_delay: float = BASE_RETRY_DELAY,
55 | **kwargs: Any
56 | ) -> Any:
57 | kwargs.pop("operation_name", None)
58 | return await retry_api_call(
59 | lambda: getattr(client, operation)(*args, **kwargs),
60 | max_retries=max_retries,
61 | base_delay=base_delay
62 | )
63 |
64 | async def retry_get_chat_member(client: Any, chat_id: int, user_id: int, **kwargs) -> Any:
65 | kwargs.pop("operation_name", None)
66 | return await retry_api_operation(
67 | client,
68 | "get_chat_member",
69 | chat_id,
70 | user_id,
71 | **kwargs
72 | )
73 |
74 | async def retry_get_chat(client: Any, chat_id: int, **kwargs) -> Any:
75 | kwargs.pop("operation_name", None)
76 | return await retry_api_operation(
77 | client,
78 | "get_chat",
79 | chat_id,
80 | **kwargs
81 | )
82 |
83 | async def retry_send_message(client: Any, chat_id: int, text: str, **kwargs) -> Any:
84 | kwargs.pop("operation_name", None)
85 | current_max_retries = kwargs.pop("max_retries", DEFAULT_RETRY_COUNT)
86 | current_base_delay = kwargs.pop("base_delay", BASE_RETRY_DELAY)
87 | return await retry_api_call(
88 | lambda: client.send_message(chat_id=chat_id, text=text, **kwargs),
89 | max_retries=current_max_retries,
90 | base_delay=current_base_delay
91 | )
92 |
--------------------------------------------------------------------------------
/Thunder/utils/shortener.py:
--------------------------------------------------------------------------------
1 | # Thunder/utils/shortener.py
2 |
3 | from abc import ABC, abstractmethod
4 | from base64 import b64encode
5 | from random import random, choice
6 | from urllib.parse import quote
7 | import cloudscraper
8 | from Thunder.vars import Var
9 | from Thunder.utils.logger import logger
10 |
11 | class ShortenerPlugin(ABC):
12 | @classmethod
13 | @abstractmethod
14 | def matches(cls, domain: str) -> bool:
15 | pass
16 |
17 | @abstractmethod
18 | async def shorten(self, url: str, api_key: str) -> str:
19 | pass
20 |
21 | class LinkvertisePlugin(ShortenerPlugin):
22 | @classmethod
23 | def matches(cls, domain: str) -> bool:
24 | return "linkvertise" in domain
25 |
26 | async def shorten(self, url: str, api_key: str) -> str:
27 | encoded_url = quote(b64encode(url.encode("utf-8")))
28 | return choice([
29 | f"https://link-to.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
30 | f"https://up-to-down.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
31 | f"https://direct-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
32 | f"https://file-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
33 | ])
34 |
35 | class BitlyPlugin(ShortenerPlugin):
36 | @classmethod
37 | def matches(cls, domain: str) -> bool:
38 | return "bitly.com" in domain
39 |
40 | async def shorten(self, url: str, api_key: str) -> str:
41 | response = self.session.post(
42 | "https://api-ssl.bit.ly/v4/shorten",
43 | json={"long_url": url},
44 | headers={"Authorization": f"Bearer {api_key}"}
45 | )
46 | if response.status_code == 200:
47 | return response.json()["link"]
48 | return url
49 |
50 | class OuoIoPlugin(ShortenerPlugin):
51 | @classmethod
52 | def matches(cls, domain: str) -> bool:
53 | return "ouo.io" in domain
54 |
55 | async def shorten(self, url: str, api_key: str) -> str:
56 | response = self.session.get(f"http://ouo.io/api/{api_key}?s={url}")
57 | if response.status_code == 200 and response.text:
58 | return response.text
59 | return url
60 |
61 | class CuttLyPlugin(ShortenerPlugin):
62 | @classmethod
63 | def matches(cls, domain: str) -> bool:
64 | return "cutt.ly" in domain
65 |
66 | async def shorten(self, url: str, api_key: str) -> str:
67 | response = self.session.get(f"http://cutt.ly/api/api.php?key={api_key}&short={url}")
68 | if response.status_code == 200:
69 | return response.json()["url"]["shortLink"]
70 | return url
71 |
72 | class GenericShortenerPlugin(ShortenerPlugin):
73 | @classmethod
74 | def matches(cls, domain: str) -> bool:
75 | return True
76 |
77 | async def shorten(self, url: str, api_key: str) -> str:
78 | response = self.session.get(f"https://{self.domain}/api?api={api_key}&url={quote(url)}")
79 | if response.status_code == 200:
80 | return response.json().get("shortenedUrl", url)
81 | return url
82 |
83 | class ShortenerSystem:
84 | def __init__(self):
85 | self.session = None
86 | self.plugin = None
87 | self.ready = False
88 |
89 | def _get_plugin_class(self, domain: str):
90 | for plugin_class in ShortenerPlugin.__subclasses__():
91 | if plugin_class.matches(domain):
92 | return plugin_class
93 |
94 | return GenericShortenerPlugin
95 |
96 | async def initialize(self) -> bool:
97 | if self.ready:
98 | return True
99 |
100 | if not (getattr(Var, "SHORTEN_ENABLED", False) or
101 | getattr(Var, "SHORTEN_MEDIA_LINKS", False)):
102 | return False
103 |
104 | site = getattr(Var, "URL_SHORTENER_SITE", "")
105 | api_key = getattr(Var, "URL_SHORTENER_API_KEY", "")
106 |
107 | if not (site and api_key):
108 | return False
109 |
110 | try:
111 | self.session = cloudscraper.create_scraper(
112 | browser={
113 | 'browser': 'chrome',
114 | 'platform': 'windows',
115 | 'desktop': True,
116 | 'mobile': False
117 | },
118 | delay=1
119 | )
120 |
121 | plugin_class = self._get_plugin_class(site)
122 | self.plugin = plugin_class()
123 | self.plugin.session = self.session
124 | self.plugin.domain = site
125 | self.ready = True
126 | return True
127 | except Exception as e:
128 | logger.error(f"Failed to initialize ShortenerSystem: {e}")
129 | return False
130 |
131 | async def short_url(self, url: str) -> str:
132 | if not self.ready:
133 | return url
134 |
135 | try:
136 | return await self.plugin.shorten(url, Var.URL_SHORTENER_API_KEY)
137 | except Exception as e:
138 | logger.error(f"Error shortening URL {url}: {e}")
139 | return url
140 |
141 | _system = ShortenerSystem()
142 |
143 | async def shorten(url: str) -> str:
144 | if not _system.ready:
145 | await _system.initialize()
146 | return await _system.short_url(url)
147 |
--------------------------------------------------------------------------------
/Thunder/utils/time_format.py:
--------------------------------------------------------------------------------
1 | from Thunder.utils.error_handling import log_errors
2 |
3 | _TIME_PERIODS = (('d', 86400), ('h', 3600), ('m', 60), ('s', 1))
4 |
5 | @log_errors
6 | def get_readable_time(seconds: int) -> str:
7 | result = []
8 | for suffix, period in _TIME_PERIODS:
9 | if seconds >= period:
10 | value, seconds = divmod(seconds, period)
11 | result.append(f"{value}{suffix}")
12 | return ' '.join(result) if result else '0s'
13 |
--------------------------------------------------------------------------------
/Thunder/utils/tokens.py:
--------------------------------------------------------------------------------
1 | import secrets
2 | from datetime import datetime, timedelta
3 | from typing import Optional, Dict, Any, List
4 | from Thunder.utils.database import db
5 | from Thunder.vars import Var
6 | from Thunder.utils.error_handling import log_errors
7 | from Thunder.utils.logger import logger
8 |
9 | _OWNER_IDS_CACHE = None
10 |
11 | def _get_owner_ids():
12 | global _OWNER_IDS_CACHE
13 | if _OWNER_IDS_CACHE is None:
14 | owner_id = Var.OWNER_ID
15 | _OWNER_IDS_CACHE = set(owner_id if isinstance(owner_id, (list, tuple, set)) else [owner_id])
16 | return _OWNER_IDS_CACHE
17 |
18 | @log_errors
19 | async def check(user_id: int) -> bool:
20 | if not getattr(Var, "TOKEN_ENABLED", False):
21 | return True
22 | if user_id in _get_owner_ids():
23 | return True
24 | current_time = datetime.utcnow()
25 | auth_result = await db.authorized_users_col.find_one(
26 | {"user_id": user_id},
27 | {"_id": 1}
28 | )
29 | if auth_result:
30 | return True
31 | token_result = await db.token_col.find_one(
32 | {"user_id": user_id, "expires_at": {"$gt": current_time}, "activated": True},
33 | {"_id": 1}
34 | )
35 | return bool(token_result)
36 |
37 | @log_errors
38 | async def generate(user_id: int) -> str:
39 | token_str = secrets.token_urlsafe(32)
40 | ttl_hours = getattr(Var, "TOKEN_TTL_HOURS", 24)
41 | created_at = datetime.utcnow()
42 | expires_at = created_at + timedelta(hours=ttl_hours)
43 |
44 | try:
45 | await db.save_main_token(
46 | user_id=user_id,
47 | token_value=token_str,
48 | created_at=created_at,
49 | expires_at=expires_at,
50 | activated=False
51 | )
52 | return token_str
53 | except Exception as e:
54 | logger.error(f"Failed to generate and save unactivated token for user {user_id}: {e}")
55 | raise
56 |
57 | @log_errors
58 | async def allowed(user_id: int) -> bool:
59 | result = await db.authorized_users_col.find_one(
60 | {"user_id": user_id},
61 | {"_id": 1}
62 | )
63 | return bool(result)
64 |
65 | @log_errors
66 | async def authorize(user_id: int, authorized_by: int) -> bool:
67 | auth_data = {
68 | "user_id": user_id,
69 | "authorized_by": authorized_by,
70 | "authorized_at": datetime.utcnow()
71 | }
72 | await db.authorized_users_col.update_one(
73 | {"user_id": user_id},
74 | {"$set": auth_data},
75 | upsert=True
76 | )
77 | return True
78 |
79 | @log_errors
80 | async def deauthorize(user_id: int) -> bool:
81 | result = await db.authorized_users_col.delete_one({"user_id": user_id})
82 | return result.deleted_count > 0
83 |
84 | @log_errors
85 | async def get_user(user_id: int) -> Optional[Dict[str, Any]]:
86 | return await db.token_col.find_one({"user_id": user_id})
87 |
88 | @log_errors
89 | async def get(token: str) -> Optional[Dict[str, Any]]:
90 | return await db.token_col.find_one({"token": token})
91 |
92 | @log_errors
93 | async def list_allowed() -> List[Dict[str, Any]]:
94 | cursor = db.authorized_users_col.find(
95 | {},
96 | {"user_id": 1, "authorized_by": 1, "authorized_at": 1}
97 | )
98 | return await cursor.to_list(length=None)
99 |
100 | @log_errors
101 | async def list_tokens() -> List[Dict[str, Any]]:
102 | current_time = datetime.utcnow()
103 | cursor = db.token_col.find(
104 | {"expires_at": {"$gt": current_time}},
105 | {"user_id": 1, "expires_at": 1, "created_at": 1, "activated": 1}
106 | )
107 | return await cursor.to_list(length=None)
108 |
109 | @log_errors
110 | async def cleanup_expired_tokens() -> int:
111 | result = await db.token_col.delete_many({"expires_at": {"$lte": datetime.utcnow()}})
112 | return result.deleted_count
113 |
--------------------------------------------------------------------------------
/Thunder/vars.py:
--------------------------------------------------------------------------------
1 | from typing import Set, Optional, List, Dict
2 | import os
3 | from dotenv import load_dotenv
4 | from Thunder.utils.logger import logger
5 |
6 | # Load environment variables from config.env
7 | load_dotenv("config.env")
8 |
9 | def str_to_bool(val: str) -> bool:
10 | return val.lower() in ("true", "1", "t", "y", "yes")
11 |
12 | def str_to_int_list(val: str) -> List[int]:
13 | return [int(x) for x in val.split() if x.isdigit()] if val else []
14 |
15 | def str_to_int_set(val: str) -> Set[int]:
16 | return {int(x) for x in val.split() if x.isdigit()} if val else set()
17 |
18 | class Var:
19 |
20 | # Telegram API credentials
21 | API_ID: int = int(os.getenv("API_ID", ""))
22 | API_HASH: str = os.getenv("API_HASH", "")
23 | BOT_TOKEN: str = os.getenv("BOT_TOKEN", "")
24 |
25 | if not all([API_ID, API_HASH, BOT_TOKEN]):
26 | logger.critical("Missing required Telegram API configuration")
27 | raise ValueError("Missing required Telegram API configuration")
28 |
29 | # Bot identity and performance
30 | NAME: str = os.getenv("NAME", "ThunderF2L")
31 | SLEEP_THRESHOLD: int = int(os.getenv("SLEEP_THRESHOLD", "30"))
32 | WORKERS: int = int(os.getenv("WORKERS", "100"))
33 | TIMEOUT: int = int(os.getenv("TIMEOUT", "90"))
34 |
35 | # Channel for file storage
36 | BIN_CHANNEL: int = int(os.getenv("BIN_CHANNEL", "0"))
37 | if not BIN_CHANNEL:
38 | logger.critical("BIN_CHANNEL is required")
39 | raise ValueError("BIN_CHANNEL is required")
40 |
41 | # Web server configuration
42 | PORT: int = int(os.getenv("PORT", "8080"))
43 | BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0")
44 | PING_INTERVAL: int = int(os.getenv("PING_INTERVAL", "840"))
45 | NO_PORT: bool = str_to_bool(os.getenv("NO_PORT", "True"))
46 | CACHE_SIZE: int = int(os.getenv("CACHE_SIZE", "100"))
47 |
48 | # Owner details
49 | OWNER_ID: List[int] = str_to_int_list(os.getenv("OWNER_ID", ""))
50 | if not OWNER_ID:
51 | logger.warning("WARNING: OWNER_ID is empty. No user will have admin access.")
52 | OWNER_USERNAME: str = os.getenv("OWNER_USERNAME", "")
53 |
54 | # Domain and URL configuration
55 | FQDN: str = os.getenv("FQDN", "") or BIND_ADDRESS
56 | HAS_SSL: bool = str_to_bool(os.getenv("HAS_SSL", "False"))
57 | PROTOCOL: str = "https" if HAS_SSL else "http"
58 | PORT_SEGMENT: str = "" if NO_PORT else f":{PORT}"
59 | URL: str = f"{PROTOCOL}://{FQDN}{PORT_SEGMENT}/"
60 |
61 | # Database configuration
62 | DATABASE_URL: str = os.getenv("DATABASE_URL", "")
63 | if not DATABASE_URL:
64 | logger.critical("DATABASE_URL is required")
65 | raise ValueError("DATABASE_URL is required")
66 |
67 | # Channel configurations
68 | BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", ""))
69 |
70 | # Multi-client support flag
71 | MULTI_CLIENT: bool = False
72 |
73 | # Force channel configuration
74 | FORCE_CHANNEL_ID: Optional[int] = None
75 | force_channel_env = os.getenv("FORCE_CHANNEL_ID", "").strip()
76 | if force_channel_env:
77 | try:
78 | FORCE_CHANNEL_ID = int(force_channel_env)
79 | except ValueError:
80 | logger.warning(f"Invalid FORCE_CHANNEL_ID '{force_channel_env}' in environment; must be an integer.")
81 |
82 | # Token System
83 | TOKEN_ENABLED: bool = str_to_bool(os.getenv("TOKEN_ENABLED", "False"))
84 | TOKEN_TTL_HOURS: int = int(os.getenv("TOKEN_TTL_HOURS", "24"))
85 |
86 | # URL Shortener
87 | SHORTEN_ENABLED: bool = str_to_bool(os.getenv("SHORTEN_ENABLED", "False"))
88 | SHORTEN_MEDIA_LINKS: bool = str_to_bool(os.getenv("SHORTEN_MEDIA_LINKS", "False"))
89 | URL_SHORTENER_API_KEY: str = os.getenv("URL_SHORTENER_API_KEY", "")
90 | URL_SHORTENER_SITE: str = os.getenv("URL_SHORTENER_SITE", "")
91 |
--------------------------------------------------------------------------------
/config_sample.env:
--------------------------------------------------------------------------------
1 | # =============================================================
2 | # Rename this file to config.env before using
3 | # =============================================================
4 |
5 | ####################
6 | ## REQUIRED SETTINGS
7 | ####################
8 |
9 | # Telegram API credentials (from https://my.telegram.org/apps)
10 | API_ID=0 # Example: 1234567
11 | API_HASH="" # Example: "abc123def456"
12 |
13 | # Bot token (from @BotFather)
14 | BOT_TOKEN="" # Example: "123456789:ABCdef..."
15 |
16 | # Storage channel ID (create a channel and add bot as admin)
17 | BIN_CHANNEL=0 # Example: -1001234567890
18 |
19 | # Owner information (get ID from @userinfobot)
20 | OWNER_ID="" # Your Telegram user ID(s) as a space-separated string. Example: "123456789 098765432"
21 | OWNER_USERNAME="" # Example: "@YourUsername"
22 |
23 | # Database connection string
24 | DATABASE_URL="" # Example: "mongodb+srv://user:pass@host/db"
25 |
26 | # Deployment configuration
27 | FQDN="" # Your domain name (leave empty to use IP)
28 | HAS_SSL="False" # Set to "True" if using HTTPS
29 | PORT=8080 # Web server port
30 | NO_PORT="True" # Hide port in URLs ("True" or "False")
31 |
32 | ####################
33 | ## OPTIONAL SETTINGS
34 | ####################
35 |
36 | # Force users to join a specific channel before using the bot
37 | FORCE_CHANNEL_ID="" # Example: -1001234567890 (Leave empty if not needed)
38 |
39 | # Banned channels (files from these channels will be rejected)
40 | BANNED_CHANNELS="" # Example: "-1001234567890 -100987654321" (Space-separated IDs, leave empty if none)
41 |
42 | # Multiple bot tokens (can add up to MULTI_TOKEN49) # Example: MULTI_TOKEN49="123456789:ABCdef..."
43 | MULTI_TOKEN1=""
44 |
45 | ####################
46 | ## TOKEN SYSTEM SETTINGS
47 | ####################
48 |
49 | # Enable token-based access (True/False)
50 | TOKEN_ENABLED="False"
51 |
52 | # Default token validity in hours
53 | TOKEN_TTL_HOURS="24"
54 |
55 | ####################
56 | ## URL SHORTENER SETTINGS
57 | ####################
58 |
59 | # Enable URL shortening for tokens (True/False)
60 | SHORTEN_ENABLED="False"
61 |
62 | # Enable URL shortening for media links (True/False)
63 | SHORTEN_MEDIA_LINKS="False"
64 |
65 | # URL Shortener
66 | URL_SHORTENER_API_KEY="" # Example: "abc123def456"
67 | URL_SHORTENER_SITE="" # Example: "example.com"
68 |
69 | ####################
70 | ## ADVANCED SETTINGS (modify with caution)
71 | ####################
72 |
73 | # Application name
74 | NAME="ThunderF2L" # Bot application name
75 |
76 | # Performance settings
77 | SLEEP_THRESHOLD=60 # Sleep time in seconds
78 | WORKERS=100 # Number of worker processes
79 |
80 | # Web server configuration
81 | BIND_ADDRESS="0.0.0.0" # Listen on all network interfaces
82 | PING_INTERVAL=840 # Ping interval in seconds
83 | CACHE_SIZE=100 # Cache size in MB
84 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | aiofiles
2 | aiohttp
3 | aiocache
4 | apscheduler
5 | cachetools
6 | cloudscraper
7 | dnspython
8 | Jinja2
9 | kurigram
10 | motor
11 | psutil
12 | pyromod
13 | python-dotenv
14 | requests
15 | tgcrypto
16 |
--------------------------------------------------------------------------------