Fyaz Mohammed commited on
Commit
1a1e2eb
·
0 Parent(s):
.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1 @@
 
 
1
+ 3.13
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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"]
LICENSE ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 <https://unlicense.org>
Procfile ADDED
@@ -0,0 +1 @@
 
 
1
+ web: python -m Thunder
README.md ADDED
@@ -0,0 +1,471 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <p align="center">
2
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/Thunder.jpg?text=Thunder" alt="Thunder Logo" width="120">
3
+ <h1 align="center">⚡ Thunder</h1>
4
+ </p>
5
+
6
+ <p align="center">
7
+ <b>High-performance Telegram File to Link Bot</b>
8
+ </p>
9
+
10
+ <p align="center">
11
+ <a href="https://github.com/fyaz05/FileToLink">
12
+ <img src="https://img.shields.io/badge/version-1.8.0-blue" alt="Version">
13
+ </a>
14
+ <a href="https://www.python.org/">
15
+ <img src="https://img.shields.io/badge/python-3.13%2B-brightgreen" alt="Python">
16
+ </a>
17
+ <a href="https://github.com/KurimuzonAkuma/pyrogram/">
18
+ <img src="https://img.shields.io/badge/kurigram-blue" alt="Kurigram">
19
+ </a>
20
+ <a href="LICENSE">
21
+ <img src="https://img.shields.io/github/license/fyaz05/FileToLink.svg?color=brightgreen" alt="License">
22
+ </a>
23
+ </p>
24
+
25
+ <hr>
26
+
27
+ <p align="center">
28
+ <a href="#about-the-project">ℹ️ About</a> •
29
+ <a href="#-features">✨ Features</a> •
30
+ <a href="#-how-it-works">🔍 How It Works</a> •
31
+ <a href="#-prerequisites">📋 Prerequisites</a> •
32
+ <a href="#-configuration">⚙️ Configuration</a> •
33
+ <a href="#-deployment">📦 Deployment</a> •
34
+ <a href="#-usage">📱 Usage</a> •
35
+ <a href="#-commands">⌨️ Commands</a>
36
+ </p>
37
+
38
+ <hr>
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 <user_id>` | Grant a user permanent access to the bot |
263
+ | `/deauthorize <user_id>` | Remove a user's permanent access |
264
+ | `/listauth` | List all permanently authorized users |
265
+
266
+ ---
267
+
268
+ <details>
269
+ <summary><b>Reverse Proxy with Cloudflare SSL</b></summary>
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/<your_file_id>
421
+ ```
422
+
423
+ ---
424
+
425
+ ## 🎉 Done!
426
+
427
+ Your reverse proxy is now securely streaming files behind Cloudflare, powered by NGINX!
428
+
429
+ </details>
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
+ <p align="center">
469
+ ⭐ <b>Like this project? Give it a star!</b> ⭐<br>
470
+ 🐛 <b>Found a bug or have a feature request?</b> <a href="https://github.com/fyaz05/FileToLink/issues/new">Open an issue</a>
471
+ </p>
Thunder/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()
Thunder/bot/__init__.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 = {}
Thunder/bot/clients.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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("╚════════════════════════════════════════════════════════╝")
Thunder/bot/plugins/admin.py ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thunder/bot/plugins/admin.py - Administrative commands and functionality
3
+ """
4
+
5
+ import os
6
+ import sys
7
+ import time
8
+ import asyncio
9
+ import shutil
10
+ import psutil
11
+ import random
12
+ import string
13
+ import html
14
+ import uuid
15
+
16
+ from datetime import datetime
17
+ from typing import List, Dict
18
+
19
+ from pyrogram.client import Client
20
+ from pyrogram import filters, StopPropagation
21
+ from pyrogram.enums import ParseMode
22
+ from pyrogram.types import (
23
+ InlineKeyboardButton,
24
+ InlineKeyboardMarkup,
25
+ Message,
26
+ LinkPreviewOptions
27
+ )
28
+ from pyrogram.errors import (
29
+ FloodWait,
30
+ UserDeactivated,
31
+ ChatWriteForbidden,
32
+ UserIsBlocked,
33
+ PeerIdInvalid,
34
+ FileReferenceExpired,
35
+ FileReferenceInvalid
36
+ )
37
+
38
+ from Thunder.bot import StreamBot, multi_clients, work_loads
39
+ from Thunder.vars import Var
40
+ from Thunder import StartTime, __version__
41
+ from Thunder.utils.human_readable import humanbytes
42
+ from Thunder.utils.time_format import get_readable_time
43
+ from Thunder.utils.logger import logger, LOG_FILE
44
+ from Thunder.utils.tokens import authorize, deauthorize, list_allowed
45
+ from Thunder.utils.database import db
46
+ from Thunder.utils.messages import *
47
+
48
+ broadcast_ids = {}
49
+ MAX_CONCURRENT_TASKS = 10
50
+
51
+ def generate_unique_id(length=6):
52
+ while True:
53
+ random_id = ''.join(random.choices(string.ascii_letters + string.digits, k=length))
54
+ if random_id not in broadcast_ids:
55
+ return random_id
56
+
57
+ async def get_users_in_batches(batch_size=100):
58
+ users_cursor = await db.get_all_users()
59
+ current_batch = []
60
+ async for user in users_cursor:
61
+ current_batch.append(user)
62
+ if len(current_batch) >= batch_size:
63
+ yield current_batch
64
+ current_batch = []
65
+ if current_batch:
66
+ yield current_batch
67
+
68
+ async def handle_broadcast_completion(message, output, failures, successes, total_users, start_time, broadcast_id):
69
+ elapsed_time = get_readable_time(time.time() - start_time)
70
+ try:
71
+ await output.delete()
72
+ except:
73
+ pass
74
+ message_text = MSG_BROADCAST_COMPLETE.format(
75
+ elapsed_time=elapsed_time,
76
+ total_users=total_users,
77
+ successes=successes,
78
+ failures=failures,
79
+ deleted_accounts=broadcast_ids[broadcast_id]['deleted']
80
+ )
81
+ reply_markup = InlineKeyboardMarkup([
82
+ [InlineKeyboardButton(MSG_ADMIN_RESTART_BROADCAST, callback_data="restart_broadcast")]
83
+ ])
84
+ await message.reply_text(
85
+ message_text,
86
+ parse_mode=ParseMode.MARKDOWN,
87
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
88
+ reply_markup=reply_markup
89
+ )
90
+
91
+ @StreamBot.on_message(filters.command("users") & filters.private & filters.user(Var.OWNER_ID))
92
+ async def get_total_users(client, message):
93
+ try:
94
+ total_users = await db.total_users_count()
95
+ reply_markup = InlineKeyboardMarkup([
96
+ [InlineKeyboardButton("🗙 Close", callback_data="close_panel")]
97
+ ])
98
+ await message.reply_text(
99
+ MSG_DB_STATS.format(total_users=total_users),
100
+ quote=True,
101
+ parse_mode=ParseMode.MARKDOWN,
102
+ reply_markup=reply_markup
103
+ )
104
+ except:
105
+ await message.reply_text(MSG_DB_ERROR)
106
+
107
+ @StreamBot.on_message(filters.command("broadcast") & filters.private & filters.user(Var.OWNER_ID))
108
+ async def broadcast_message(client, message):
109
+ if not message.reply_to_message:
110
+ await message.reply_text(MSG_INVALID_BROADCAST_CMD, quote=True)
111
+ return
112
+ try:
113
+ broadcast_id = generate_unique_id()
114
+ broadcast_ids[broadcast_id] = {
115
+ "total": 0,
116
+ "current": 0,
117
+ "success": 0,
118
+ "failed": 0,
119
+ "deleted": 0,
120
+ "start_time": time.time(),
121
+ "is_cancelled": False
122
+ }
123
+ reply_markup = InlineKeyboardMarkup([
124
+ [InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_broadcast_{broadcast_id}")]
125
+ ])
126
+ output = await message.reply_text(MSG_BROADCAST_START, reply_markup=reply_markup)
127
+ self_id = client.me.id
128
+ start_time = time.time()
129
+ total_users = await db.total_users_count()
130
+ processed = 0
131
+ successes = 0
132
+ failures = 0
133
+ broadcast_ids[broadcast_id]["total"] = total_users
134
+ semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS)
135
+ broadcast_lock = asyncio.Lock()
136
+ processed_lock = asyncio.Lock()
137
+
138
+ async def update_progress():
139
+ while processed < total_users and not broadcast_ids[broadcast_id]["is_cancelled"]:
140
+ try:
141
+ await output.edit_text(
142
+ MSG_BROADCAST_PROGRESS.format(
143
+ total_users=total_users,
144
+ processed=processed,
145
+ elapsed_time=get_readable_time(int(time.time() - start_time)),
146
+ successes=successes,
147
+ failures=failures
148
+ )
149
+ )
150
+ except:
151
+ pass
152
+ await asyncio.sleep(3)
153
+
154
+ progress_task = asyncio.create_task(update_progress())
155
+
156
+ async def send_message_to_user(user_id):
157
+ nonlocal successes, failures, processed
158
+ if not isinstance(user_id, int) or user_id == self_id:
159
+ return
160
+ async with semaphore:
161
+ retries = 0
162
+ for attempt in range(3):
163
+ if broadcast_ids[broadcast_id]["is_cancelled"]:
164
+ return
165
+ try:
166
+ if message.reply_to_message.text or message.reply_to_message.caption:
167
+ await client.send_message(
168
+ chat_id=user_id,
169
+ text=message.reply_to_message.text or message.reply_to_message.caption,
170
+ parse_mode=ParseMode.MARKDOWN,
171
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
172
+ )
173
+ elif message.reply_to_message.media:
174
+ await message.reply_to_message.copy(chat_id=user_id)
175
+ async with broadcast_lock:
176
+ successes += 1
177
+ async with processed_lock:
178
+ processed += 1
179
+ broadcast_ids[broadcast_id]["current"] = processed
180
+ break
181
+ except FloodWait as e:
182
+ await asyncio.sleep(e.value + 1)
183
+ continue
184
+ except (FileReferenceExpired, FileReferenceInvalid):
185
+ retries += 1
186
+ if retries >= 3:
187
+ break
188
+ except (UserDeactivated, ChatWriteForbidden, UserIsBlocked, PeerIdInvalid):
189
+ try:
190
+ await db.delete_user(user_id)
191
+ async with broadcast_lock:
192
+ failures += 1
193
+ broadcast_ids[broadcast_id]["deleted"] += 1
194
+ async with processed_lock:
195
+ processed += 1
196
+ broadcast_ids[broadcast_id]["current"] = processed
197
+ except:
198
+ pass
199
+ break
200
+ except Exception:
201
+ async with broadcast_lock:
202
+ failures += 1
203
+ async with processed_lock:
204
+ processed += 1
205
+ broadcast_ids[broadcast_id]["current"] = processed
206
+ if attempt == 2:
207
+ break
208
+ await asyncio.sleep(1)
209
+
210
+ async for user_batch in get_users_in_batches():
211
+ if broadcast_ids[broadcast_id]["is_cancelled"]:
212
+ break
213
+ batch_tasks = [send_message_to_user(int(user['id'])) for user in user_batch]
214
+ await asyncio.gather(*batch_tasks)
215
+
216
+ progress_task.cancel()
217
+ try:
218
+ await progress_task
219
+ except asyncio.CancelledError:
220
+ pass
221
+
222
+ await handle_broadcast_completion(
223
+ message, output, failures, successes, total_users, start_time, broadcast_id
224
+ )
225
+ broadcast_ids.pop(broadcast_id, None)
226
+
227
+ except Exception as e:
228
+ await message.reply_text(
229
+ MSG_BROADCAST_FAILED.format(error=str(e), error_id=uuid.uuid4().hex[:8])
230
+ )
231
+
232
+ @StreamBot.on_message(filters.command("cancel_broadcast") & filters.private & filters.user(Var.OWNER_ID))
233
+ async def cancel_broadcast(client, message):
234
+ if not broadcast_ids:
235
+ await message.reply_text(MSG_NO_ACTIVE_BROADCASTS)
236
+ return
237
+ if len(broadcast_ids) == 1:
238
+ broadcast_id = list(broadcast_ids.keys())[0]
239
+ broadcast_ids[broadcast_id]["is_cancelled"] = True
240
+ await message.reply_text(MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id))
241
+ return
242
+ keyboard = []
243
+ for broadcast_id, info in broadcast_ids.items():
244
+ progress = f"{info['current']}/{info['total']}" if info['total'] else "Unknown"
245
+ elapsed = get_readable_time(time.time() - info['start_time'])
246
+ keyboard.append([
247
+ InlineKeyboardButton(
248
+ MSG_ADMIN_BROADCAST_PROGRESS_ITEM.format(
249
+ broadcast_id=broadcast_id,
250
+ progress=progress,
251
+ elapsed=elapsed
252
+ ),
253
+ callback_data=f"cancel_broadcast_{broadcast_id}"
254
+ )
255
+ ])
256
+ await message.reply_text(
257
+ MSG_MULTIPLE_BROADCASTS,
258
+ reply_markup=InlineKeyboardMarkup(keyboard)
259
+ )
260
+
261
+ @StreamBot.on_callback_query(filters.regex(r"^cancel_broadcast_(.+)$"))
262
+ async def handle_cancel_broadcast(client, callback_query):
263
+ broadcast_id = callback_query.data.split("_")[-1]
264
+ if broadcast_id in broadcast_ids:
265
+ broadcast_ids[broadcast_id]["is_cancelled"] = True
266
+ await callback_query.edit_message_text(
267
+ MSG_CANCELLING_BROADCAST.format(broadcast_id=broadcast_id)
268
+ )
269
+ else:
270
+ await callback_query.edit_message_text(MSG_BROADCAST_NOT_FOUND)
271
+
272
+ @StreamBot.on_message(filters.command("status") & filters.private & filters.user(Var.OWNER_ID))
273
+ async def show_status(client, message):
274
+ try:
275
+ uptime = get_readable_time(int(time.time() - StartTime))
276
+ workloads_text = MSG_ADMIN_BOT_WORKLOAD_HEADER
277
+ workloads = {
278
+ f"🔹 Bot {c + 1}": load
279
+ for c, (bot, load) in enumerate(
280
+ sorted(work_loads.items(), key=lambda x: x[1], reverse=True)
281
+ )
282
+ }
283
+ for bot_name, load in workloads.items():
284
+ workloads_text += MSG_ADMIN_BOT_WORKLOAD_ITEM.format(bot_name=bot_name, load=load)
285
+ stats_text = MSG_SYSTEM_STATUS.format(
286
+ uptime=uptime,
287
+ active_bots=len(multi_clients),
288
+ workloads=workloads_text,
289
+ version=__version__
290
+ )
291
+ reply_markup = InlineKeyboardMarkup([
292
+ [InlineKeyboardButton("🗙 Close", callback_data="close_panel")]
293
+ ])
294
+ await message.reply_text(stats_text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup)
295
+ except:
296
+ await message.reply_text(MSG_STATUS_ERROR)
297
+
298
+ @StreamBot.on_message(filters.command("stats") & filters.private & filters.user(Var.OWNER_ID))
299
+ async def show_stats(client, message):
300
+ try:
301
+ current_time = get_readable_time(int(time.time() - StartTime))
302
+ total, used, free = shutil.disk_usage('.')
303
+ stats_text = MSG_SYSTEM_STATS.format(
304
+ uptime=current_time,
305
+ total=humanbytes(total),
306
+ used=humanbytes(used),
307
+ free=humanbytes(free),
308
+ upload=humanbytes(psutil.net_io_counters().bytes_sent),
309
+ download=humanbytes(psutil.net_io_counters().bytes_recv)
310
+ )
311
+ stats_text += MSG_PERFORMANCE_STATS.format(
312
+ cpu_percent=psutil.cpu_percent(interval=0.5),
313
+ ram_percent=psutil.virtual_memory().percent,
314
+ disk_percent=psutil.disk_usage('.').percent
315
+ )
316
+ reply_markup = InlineKeyboardMarkup([
317
+ [InlineKeyboardButton("🗙 Close", callback_data="close_panel")]
318
+ ])
319
+ await message.reply_text(stats_text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup)
320
+ except:
321
+ await message.reply_text(MSG_STATUS_ERROR)
322
+
323
+ @StreamBot.on_message(filters.command("restart") & filters.private & filters.user(Var.OWNER_ID))
324
+ async def restart_bot(client, message):
325
+ try:
326
+ sent_message = await message.reply_text(MSG_RESTARTING)
327
+ await db.add_restart_message(sent_message.id, message.chat.id)
328
+ os.execv(sys.executable, [sys.executable, "-m", "Thunder"])
329
+ except Exception as e:
330
+ logger.error(f"Error during restart: {e}")
331
+ await message.reply_text(MSG_RESTART_FAILED)
332
+
333
+ @StreamBot.on_message(filters.command("log") & filters.private & filters.user(Var.OWNER_ID))
334
+ async def send_logs(client, message):
335
+ try:
336
+ if not os.path.exists(LOG_FILE):
337
+ await message.reply_text(MSG_LOG_FILE_MISSING)
338
+ return
339
+ file_size = os.path.getsize(LOG_FILE)
340
+ if file_size == 0:
341
+ await message.reply_text(MSG_LOG_FILE_EMPTY)
342
+ return
343
+ await client.send_document(
344
+ chat_id=message.chat.id,
345
+ document=LOG_FILE,
346
+ caption=MSG_LOG_FILE_CAPTION,
347
+ parse_mode=ParseMode.MARKDOWN
348
+ )
349
+ except Exception as e:
350
+ await message.reply_text(MSG_LOG_ERROR.format(error=str(e)))
351
+
352
+ @StreamBot.on_message(filters.command("authorize") & filters.private & filters.user(Var.OWNER_ID))
353
+ async def authorize_command(client, message):
354
+ if len(message.command) < 2:
355
+ await message.reply_text(MSG_AUTHORIZE_USAGE)
356
+ return
357
+ try:
358
+ user_id_to_authorize = int(message.command[1])
359
+ authorized = await authorize(user_id_to_authorize, authorized_by=message.from_user.id)
360
+ if authorized:
361
+ await message.reply_text(MSG_AUTHORIZE_SUCCESS.format(user_id=user_id_to_authorize))
362
+ else:
363
+ await message.reply_text(MSG_AUTHORIZE_FAILED.format(user_id=user_id_to_authorize))
364
+ except ValueError:
365
+ await message.reply_text(MSG_INVALID_USER_ID)
366
+ except:
367
+ await message.reply_text(MSG_ERROR_GENERIC)
368
+
369
+ @StreamBot.on_message(filters.command("deauthorize") & filters.private & filters.user(Var.OWNER_ID))
370
+ async def deauthorize_command(client, message):
371
+ if len(message.command) < 2:
372
+ await message.reply_text(MSG_DEAUTHORIZE_USAGE)
373
+ return
374
+ try:
375
+ user_id_to_deauthorize = int(message.command[1])
376
+ deauthorized = await deauthorize(user_id_to_deauthorize)
377
+ if deauthorized:
378
+ await message.reply_text(MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id_to_deauthorize))
379
+ else:
380
+ await message.reply_text(MSG_DEAUTHORIZE_FAILED.format(user_id=user_id_to_deauthorize))
381
+ except ValueError:
382
+ await message.reply_text(MSG_INVALID_USER_ID)
383
+ except:
384
+ await message.reply_text(MSG_ERROR_GENERIC)
385
+
386
+ @StreamBot.on_message(filters.command("listauth") & filters.private & filters.user(Var.OWNER_ID))
387
+ async def list_authorized_command(client, message):
388
+ try:
389
+ authorized_users = await list_allowed()
390
+ if not authorized_users:
391
+ await message.reply_text(MSG_NO_AUTH_USERS)
392
+ return
393
+ message_text = MSG_ADMIN_AUTH_LIST_HEADER
394
+ for i, user in enumerate(authorized_users, 1):
395
+ message_text += MSG_AUTH_USER_INFO.format(
396
+ i=i,
397
+ user_id=user['user_id'],
398
+ authorized_by=user['authorized_by'],
399
+ auth_time=user['authorized_at']
400
+ )
401
+ reply_markup = InlineKeyboardMarkup([
402
+ [InlineKeyboardButton("🗙 Close", callback_data="close_panel")]
403
+ ])
404
+ await message.reply_text(
405
+ message_text,
406
+ parse_mode=ParseMode.MARKDOWN,
407
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
408
+ reply_markup=reply_markup
409
+ )
410
+ except:
411
+ await message.reply_text(MSG_ERROR_GENERIC)
412
+
413
+ @StreamBot.on_message(filters.command("ban") & filters.private & filters.user(Var.OWNER_ID))
414
+ async def ban_user_command(client, message):
415
+ try:
416
+ if len(message.command) < 2:
417
+ await message.reply_text(MSG_BAN_USAGE)
418
+ return
419
+ try:
420
+ user_id_to_ban = int(message.command[1])
421
+ except ValueError:
422
+ await message.reply_text(MSG_INVALID_USER_ID)
423
+ return
424
+ if user_id_to_ban in (Var.OWNER_ID if isinstance(Var.OWNER_ID, list) else [Var.OWNER_ID]):
425
+ await message.reply_text(MSG_CANNOT_BAN_OWNER)
426
+ return
427
+ reason = " ".join(message.command[2:]) if len(message.command) > 2 else MSG_ADMIN_NO_BAN_REASON
428
+ ban_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
429
+ await db.add_banned_user(
430
+ user_id=user_id_to_ban,
431
+ reason=reason,
432
+ ban_time=ban_time,
433
+ banned_by=message.from_user.id
434
+ )
435
+ reply_text = MSG_ADMIN_USER_BANNED.format(user_id=user_id_to_ban)
436
+ if reason != MSG_ADMIN_NO_BAN_REASON:
437
+ reply_text += MSG_BAN_REASON_SUFFIX.format(reason=reason)
438
+ await message.reply_text(reply_text)
439
+ try:
440
+ await client.send_message(
441
+ chat_id=user_id_to_ban,
442
+ text=MSG_USER_BANNED_NOTIFICATION
443
+ )
444
+ except:
445
+ pass
446
+ except Exception as e:
447
+ await message.reply_text(MSG_BAN_ERROR.format(error=str(e)))
448
+
449
+ @StreamBot.on_message(filters.command("unban") & filters.private & filters.user(Var.OWNER_ID))
450
+ async def unban_user_command(client, message):
451
+ try:
452
+ if len(message.command) < 2:
453
+ await message.reply_text(MSG_UNBAN_USAGE)
454
+ return
455
+ try:
456
+ user_id_to_unban = int(message.command[1])
457
+ except ValueError:
458
+ await message.reply_text(MSG_INVALID_USER_ID)
459
+ return
460
+ removed = await db.remove_banned_user(user_id=user_id_to_unban)
461
+ if removed:
462
+ await message.reply_text(MSG_ADMIN_USER_UNBANNED.format(user_id=user_id_to_unban))
463
+ try:
464
+ await client.send_message(
465
+ chat_id=user_id_to_unban,
466
+ text=MSG_USER_UNBANNED_NOTIFICATION
467
+ )
468
+ except:
469
+ pass
470
+ else:
471
+ await message.reply_text(MSG_USER_NOT_IN_BAN_LIST.format(user_id=user_id_to_unban))
472
+ except Exception as e:
473
+ await message.reply_text(MSG_UNBAN_ERROR.format(error=str(e)))
474
+
475
+ @StreamBot.on_message(filters.command("shell") & filters.private & filters.user(Var.OWNER_ID))
476
+ async def run_shell_command(client: Client, message: Message):
477
+ if len(message.command) < 2:
478
+ await message.reply_text(MSG_SHELL_USAGE, parse_mode=ParseMode.HTML)
479
+ return
480
+ command_to_run = " ".join(message.command[1:])
481
+ reply_msg = await message.reply_text(
482
+ MSG_SHELL_EXECUTING.format(command=html.escape(command_to_run)),
483
+ parse_mode=ParseMode.HTML,
484
+ quote=True
485
+ )
486
+ try:
487
+ process = await asyncio.create_subprocess_shell(
488
+ command_to_run,
489
+ stdout=asyncio.subprocess.PIPE,
490
+ stderr=asyncio.subprocess.PIPE
491
+ )
492
+ stdout, stderr = await process.communicate()
493
+ output = ""
494
+ if stdout:
495
+ output += MSG_SHELL_OUTPUT_STDOUT.format(output=html.escape(stdout.decode().strip()))
496
+ if stderr:
497
+ output += MSG_SHELL_OUTPUT_STDERR.format(error=html.escape(stderr.decode().strip()))
498
+ if not output:
499
+ output = MSG_SHELL_NO_OUTPUT
500
+ except Exception as e:
501
+ output = MSG_SHELL_ERROR.format(error=html.escape(str(e)))
502
+ if len(output) > 4096:
503
+ try:
504
+ filename = f"shell_output_{int(time.time())}.txt"
505
+ with open(filename, "w", encoding="utf-8") as file:
506
+ if stdout:
507
+ file.write(f"STDOUT:\n{stdout.decode()}\n\n")
508
+ if stderr:
509
+ file.write(f"STDERR:\n{stderr.decode()}")
510
+ await client.send_document(
511
+ chat_id=message.chat.id,
512
+ document=filename,
513
+ caption=MSG_SHELL_OUTPUT.format(command=html.escape(command_to_run)),
514
+ parse_mode=ParseMode.HTML
515
+ )
516
+ os.remove(filename)
517
+ except Exception as e:
518
+ await message.reply_text(
519
+ MSG_SHELL_LARGE_OUTPUT.format(error=html.escape(str(e))),
520
+ parse_mode=ParseMode.HTML
521
+ )
522
+ else:
523
+ await message.reply_text(output, parse_mode=ParseMode.HTML)
524
+ try:
525
+ await reply_msg.delete()
526
+ except:
527
+ pass
528
+
529
+ @StreamBot.on_callback_query(filters.regex("close_panel"))
530
+ async def close_panel(client, callback_query):
531
+ """Handler for Close button - deletes current panel and command message."""
532
+ try:
533
+ await callback_query.answer()
534
+ await callback_query.message.delete()
535
+ logger.debug(f"User {callback_query.from_user.id} closed panel")
536
+ if callback_query.message.reply_to_message:
537
+ try:
538
+ ctx = callback_query.message.reply_to_message
539
+ await ctx.delete()
540
+ if ctx.reply_to_message:
541
+ await ctx.reply_to_message.delete()
542
+ except Exception as e:
543
+ logger.warning(f"Error deleting command messages: {e}")
544
+ except Exception as e:
545
+ logger.error(f"Error in close_panel: {e}")
546
+ finally:
547
+ raise StopPropagation
548
+
549
+ @StreamBot.on_callback_query(filters.regex("restart_broadcast"))
550
+ async def restart_broadcast(client, callback_query):
551
+ try:
552
+ await callback_query.edit_message_text(
553
+ "Please use `/broadcast` command with a reply to the message you want to broadcast.",
554
+ parse_mode=ParseMode.MARKDOWN
555
+ )
556
+ except:
557
+ pass
Thunder/bot/plugins/callbacks.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thunder/bot/plugins/callbacks.py - Callback query handlers for Thunder bot.
3
+ """
4
+
5
+ import uuid
6
+ from pyrogram import Client, filters, StopPropagation
7
+ from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, LinkPreviewOptions
8
+ from pyrogram.errors import MessageNotModified
9
+ from Thunder.bot import StreamBot
10
+ from Thunder.vars import Var
11
+ from Thunder.utils.logger import logger
12
+ from Thunder.utils.messages import *
13
+ from Thunder.utils.decorators import owner_only
14
+
15
+ # Helper Functions
16
+ async def handle_callback_error(callback_query, error, operation="callback"):
17
+ """Standardized error handling for callback queries."""
18
+ error_id = uuid.uuid4().hex[:8]
19
+ logger.error(f"Error in {operation}: {error}")
20
+ await callback_query.answer(
21
+ MSG_ERROR_GENERIC_CALLBACK.format(error_id=error_id),
22
+ show_alert=True
23
+ )
24
+
25
+ async def get_force_channel_button(client):
26
+ """Get force channel button if configured."""
27
+ if not Var.FORCE_CHANNEL_ID:
28
+ return None
29
+
30
+ try:
31
+ chat = await client.get_chat(Var.FORCE_CHANNEL_ID)
32
+ invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
33
+
34
+ if invite_link:
35
+ return [InlineKeyboardButton(
36
+ MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title),
37
+ url=invite_link
38
+ )]
39
+ else:
40
+ logger.warning(f"Could not construct invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID}")
41
+ return None
42
+ except Exception as e:
43
+ logger.error(f"Error creating force channel button: {e}")
44
+ return None
45
+
46
+ # Callback Handlers
47
+ @StreamBot.on_callback_query(filters.regex(r"^help_command$"))
48
+ async def help_callback(client: Client, callback_query: CallbackQuery):
49
+ """Handler for Help button."""
50
+ try:
51
+ await callback_query.answer()
52
+
53
+ # Build buttons list - About button first
54
+ buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]]
55
+
56
+ # Add force channel button if configured
57
+ force_button = await get_force_channel_button(client)
58
+ if force_button:
59
+ buttons.append(force_button)
60
+
61
+ # Add close button at the end
62
+ buttons.append([InlineKeyboardButton("🗙 Close", callback_data="close_panel")])
63
+
64
+ await callback_query.message.edit_text(
65
+ text=MSG_HELP,
66
+ reply_markup=InlineKeyboardMarkup(buttons),
67
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
68
+ )
69
+
70
+ logger.debug(f"User {callback_query.from_user.id} accessed help panel.")
71
+
72
+ except MessageNotModified:
73
+ logger.debug(f"Help panel already displayed for user {callback_query.from_user.id}")
74
+ except Exception as e:
75
+ await handle_callback_error(callback_query, e, "help_callback")
76
+ finally:
77
+ raise StopPropagation
78
+
79
+ @StreamBot.on_callback_query(filters.regex(r"^about_command$"))
80
+ async def about_callback(client: Client, callback_query: CallbackQuery):
81
+ """Handler for About button."""
82
+ try:
83
+ await callback_query.answer()
84
+
85
+ buttons = [
86
+ [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")],
87
+ [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"),
88
+ InlineKeyboardButton("🗙 Close", callback_data="close_panel")]
89
+ ]
90
+
91
+ await callback_query.message.edit_text(
92
+ text=MSG_ABOUT,
93
+ reply_markup=InlineKeyboardMarkup(buttons),
94
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
95
+ )
96
+
97
+ logger.debug(f"User {callback_query.from_user.id} accessed about panel.")
98
+
99
+ except MessageNotModified:
100
+ logger.debug(f"About panel already displayed for user {callback_query.from_user.id}")
101
+ except Exception as e:
102
+ await handle_callback_error(callback_query, e, "about_callback")
103
+ finally:
104
+ raise StopPropagation
105
+
106
+ @StreamBot.on_callback_query(filters.regex(r"^restart_broadcast$"))
107
+ @owner_only
108
+ async def restart_broadcast_callback(client: Client, callback_query: CallbackQuery):
109
+ """Handler for Restart Broadcast button."""
110
+ try:
111
+ await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True)
112
+
113
+ # Create back and close buttons
114
+ buttons = [
115
+ [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
116
+ InlineKeyboardButton("🗙 Close", callback_data="close_panel")]
117
+ ]
118
+
119
+ await callback_query.message.edit_text(
120
+ MSG_ERROR_BROADCAST_INSTRUCTION,
121
+ reply_markup=InlineKeyboardMarkup(buttons),
122
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
123
+ )
124
+
125
+ logger.debug(f"User {callback_query.from_user.id} viewed broadcast restart instruction.")
126
+
127
+ except Exception as e:
128
+ await handle_callback_error(callback_query, e, "restart_broadcast_callback")
129
+ finally:
130
+ raise StopPropagation
131
+
132
+ @StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
133
+ async def close_panel_callback(client: Client, callback_query: CallbackQuery):
134
+ """Handler for Close button - deletes current panel and command message."""
135
+ try:
136
+ await callback_query.answer()
137
+
138
+ # Delete the current panel message
139
+ await callback_query.message.delete()
140
+ logger.debug(f"User {callback_query.from_user.id} closed panel")
141
+
142
+ # Clean up original command message and replies
143
+ if callback_query.message.reply_to_message:
144
+ try:
145
+ ctx = callback_query.message.reply_to_message
146
+ await ctx.delete()
147
+ if ctx.reply_to_message:
148
+ await ctx.reply_to_message.delete()
149
+ except Exception as e:
150
+ logger.warning(f"Error deleting command messages: {e}")
151
+
152
+ except Exception as e:
153
+ await handle_callback_error(callback_query, e, "close_panel_callback")
154
+ finally:
155
+ raise StopPropagation
156
+
157
+ # Fallback handler for unmatched callback queries
158
+ @StreamBot.on_callback_query(group=999)
159
+ async def fallback_callback(client: Client, callback_query: CallbackQuery):
160
+ """Fallback handler for any unmatched callback query."""
161
+ try:
162
+ logger.debug(f"Unhandled callback query: {callback_query.data} from user {callback_query.from_user.id}")
163
+ await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True)
164
+ except Exception as e:
165
+ logger.error(f"Error in fallback_callback: {e}")
166
+ raise StopPropagation
Thunder/bot/plugins/common.py ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thunder/bot/plugins/common.py - Common plugin handlers and helpers for Thunder bot.
3
+ """
4
+
5
+ import time
6
+ import asyncio
7
+ import uuid
8
+ from typing import Tuple, Optional
9
+ from urllib.parse import quote_plus
10
+
11
+ from pyrogram import filters, StopPropagation
12
+ from pyrogram.client import Client
13
+ from pyrogram.errors import RPCError, MessageNotModified
14
+ from pyrogram.types import (
15
+ InlineKeyboardButton,
16
+ InlineKeyboardMarkup,
17
+ Message,
18
+ User,
19
+ CallbackQuery,
20
+ LinkPreviewOptions
21
+ )
22
+
23
+ from Thunder.bot import StreamBot
24
+ from Thunder.vars import Var
25
+ from Thunder.utils.database import db
26
+ from Thunder.utils.messages import *
27
+ from Thunder.utils.human_readable import humanbytes
28
+ from Thunder.utils.file_properties import get_media_file_size, get_name
29
+ from Thunder.utils.force_channel import force_channel_check
30
+ from Thunder.utils.logger import logger
31
+ from Thunder.utils.tokens import get, check, validate_activation_token
32
+ from Thunder.utils.decorators import check_banned
33
+ from Thunder.utils.bot_utils import (
34
+ notify_channel,
35
+ log_new_user,
36
+ generate_media_links,
37
+ handle_user_error,
38
+ generate_dc_text,
39
+ get_user_safely
40
+ )
41
+
42
+ def has_media(message):
43
+ """Check if message contains media"""
44
+ return any(
45
+ hasattr(message, attr) and getattr(message, attr)
46
+ for attr in ["document", "photo", "video", "audio", "voice",
47
+ "sticker", "animation", "video_note"]
48
+ )
49
+
50
+ # ====== COMMAND HANDLERS ======
51
+
52
+ @check_banned
53
+ @StreamBot.on_message(filters.command("start") & filters.private)
54
+ async def start_command(bot: Client, message: Message):
55
+ try:
56
+ user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
57
+
58
+ if message.from_user:
59
+ await log_new_user(bot, message.from_user.id, message.from_user.first_name)
60
+
61
+ if len(message.command) == 2:
62
+ token = message.command[1]
63
+ try:
64
+ if token.startswith("token"):
65
+ validation_result = await validate_activation_token(token)
66
+ if validation_result["valid"]:
67
+ await db.update_user_token(message.from_user.id, token)
68
+ await message.reply_text(
69
+ MSG_TOKEN_ACTIVATED.format(
70
+ expiry_date=validation_result['expiry_date'],
71
+ description=validation_result['description']
72
+ ),
73
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
74
+ quote=True
75
+ )
76
+ return
77
+ else:
78
+ await message.reply_text(
79
+ MSG_TOKEN_FAILED.format(
80
+ reason=validation_result['reason'],
81
+ error_id=uuid.uuid4().hex[:8]
82
+ ),
83
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
84
+ quote=True
85
+ )
86
+ return
87
+ else:
88
+ record = await get(token)
89
+ if record and record["user_id"] == message.from_user.id and await check(message.from_user.id):
90
+ await message.reply_text(MSG_TOKEN_VERIFIED, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
91
+ return
92
+ else:
93
+ await message.reply_text(MSG_TOKEN_INVALID, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
94
+ return
95
+ except Exception as e:
96
+ logger.error(f"Token activation error: {e}")
97
+ await message.reply_text(MSG_TOKEN_ERROR.format(error_id=uuid.uuid4().hex[:8]), quote=True)
98
+ return
99
+
100
+ parts = message.text.strip().split(maxsplit=1)
101
+ if len(parts) == 1 or (len(parts) > 1 and parts[1].lower() == "start"):
102
+ welcome_text = MSG_WELCOME.format(user_name=message.from_user.first_name)
103
+
104
+ if Var.FORCE_CHANNEL_ID:
105
+ error_id_context = uuid.uuid4().hex[:8]
106
+ try:
107
+ chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
108
+ invite_link = getattr(chat, 'invite_link', None)
109
+ chat_username = getattr(chat, 'username', None)
110
+ chat_title = getattr(chat, 'title', 'Channel')
111
+
112
+ if not invite_link and chat_username:
113
+ invite_link = f"https://t.me/{chat_username}"
114
+
115
+ if invite_link:
116
+ welcome_text += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=chat_title)}"
117
+ else:
118
+ 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}).")
119
+ welcome_text += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=chat_title)}"
120
+
121
+ except asyncio.TimeoutError as e_timeout:
122
+ logger.error(f"(ID: {error_id_context}) Timeout fetching Force Channel details for /start text (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_timeout}")
123
+ welcome_text += f"\n\n{MSG_DEFAULT_WELCOME}"
124
+ except RPCError as e_rpc:
125
+ logger.error(f"(ID: {error_id_context}) RPCError fetching Force Channel details for /start text (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_rpc}")
126
+ welcome_text += f"\n\n{MSG_DEFAULT_WELCOME}"
127
+ except Exception as e:
128
+ 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}")
129
+ welcome_text += f"\n\n{MSG_DEFAULT_WELCOME}"
130
+
131
+ reply_markup = InlineKeyboardMarkup([
132
+ [
133
+ InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
134
+ InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command"),
135
+ ],
136
+ [
137
+ InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"),
138
+ InlineKeyboardButton("🗙 Close", callback_data="close_panel")
139
+ ]
140
+ ])
141
+
142
+ if Var.FORCE_CHANNEL_ID:
143
+ error_id_context_button = uuid.uuid4().hex[:8]
144
+ try:
145
+ chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
146
+ invite_link = getattr(chat, 'invite_link', None)
147
+ chat_username = getattr(chat, 'username', None)
148
+ chat_title = getattr(chat, 'title', 'Channel')
149
+
150
+ if not invite_link and chat_username:
151
+ invite_link = f"https://t.me/{chat_username}"
152
+
153
+ if invite_link:
154
+ reply_markup.inline_keyboard.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat_title), url=invite_link)])
155
+ else:
156
+ logger.warning(f"(ID: {error_id_context_button}) Could not retrieve invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /start message button (Channel: {chat_title}, User: {user_id_str}).")
157
+
158
+ except asyncio.TimeoutError as e_timeout_btn:
159
+ logger.error(f"(ID: {error_id_context_button}) Timeout fetching Force Channel details for /start button (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_timeout_btn}")
160
+ except RPCError as e_rpc_btn:
161
+ logger.error(f"(ID: {error_id_context_button}) RPCError fetching Force Channel details for /start button (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_rpc_btn}")
162
+ except Exception as e_btn:
163
+ logger.error(f"(ID: {error_id_context_button}) Error adding force channel button to /start message (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_btn}")
164
+
165
+ await message.reply_text(text=welcome_text, reply_markup=reply_markup, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
166
+ return
167
+
168
+ payload = parts[1]
169
+ try:
170
+ msg_id = int(payload)
171
+
172
+ # Ensure file_msg is a single Message object
173
+ retrieved_messages = await bot.get_messages(chat_id=Var.BIN_CHANNEL, message_ids=msg_id)
174
+ if not retrieved_messages:
175
+ await handle_user_error(message, MSG_ERROR_FILE_INVALID)
176
+ return
177
+
178
+ # If get_messages returns a list, take the first one.
179
+ file_msg = retrieved_messages[0] if isinstance(retrieved_messages, list) else retrieved_messages
180
+
181
+ if not file_msg:
182
+ await handle_user_error(message, MSG_ERROR_FILE_INVALID)
183
+ return
184
+
185
+ stream_link, download_link, file_name, file_size = await generate_media_links(file_msg)
186
+
187
+ reply_text = MSG_LINKS.format(
188
+ file_name=file_name,
189
+ file_size=file_size,
190
+ download_link=download_link,
191
+ stream_link=stream_link
192
+ )
193
+
194
+ await message.reply_text(
195
+ text=reply_text,
196
+ reply_markup=InlineKeyboardMarkup([
197
+ [
198
+ InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
199
+ InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=download_link)
200
+ ]
201
+ ]),
202
+ quote=True,
203
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
204
+ )
205
+
206
+ except ValueError:
207
+ await handle_user_error(message, MSG_ERROR_FILE_INVALID_ID)
208
+ except Exception as e:
209
+ logger.error(f"Error processing /start with payload: {e}")
210
+ await handle_user_error(message, MSG_FILE_ACCESS_ERROR)
211
+
212
+ except Exception as e:
213
+ logger.error(f"Error in start_command: {e}")
214
+ await handle_user_error(message, MSG_ERROR_GENERIC)
215
+
216
+ @check_banned
217
+ @StreamBot.on_message(filters.command("help") & filters.private)
218
+ async def help_command(bot: Client, message: Message):
219
+ try:
220
+ user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
221
+
222
+ if message.from_user:
223
+ await log_new_user(bot, message.from_user.id, message.from_user.first_name)
224
+
225
+ help_text = MSG_HELP
226
+ buttons = [
227
+ [InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]
228
+ ]
229
+
230
+ if Var.FORCE_CHANNEL_ID:
231
+ error_id_context_help = uuid.uuid4().hex[:8]
232
+ try:
233
+ chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
234
+ invite_link = getattr(chat, 'invite_link', None)
235
+ chat_username = getattr(chat, 'username', None)
236
+ chat_title = getattr(chat, 'title', 'Channel')
237
+
238
+ if not invite_link and chat_username:
239
+ invite_link = f"https://t.me/{chat_username}"
240
+
241
+ if invite_link:
242
+ buttons.append([
243
+ InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat_title), url=invite_link)
244
+ ])
245
+ else:
246
+ 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}).")
247
+
248
+ except Exception as e_help:
249
+ 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}")
250
+
251
+ buttons.append([InlineKeyboardButton("🗙 Close", callback_data="close_panel")])
252
+ reply_markup = InlineKeyboardMarkup(buttons)
253
+
254
+ await message.reply_text(
255
+ text=help_text,
256
+ reply_markup=reply_markup,
257
+ quote=True,
258
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
259
+ )
260
+
261
+ except Exception as e:
262
+ logger.error(f"Error in help_command: {e}")
263
+ await handle_user_error(message, MSG_ERROR_GENERIC)
264
+
265
+ @check_banned
266
+ @StreamBot.on_message(filters.command("about") & filters.private)
267
+ async def about_command(bot: Client, message: Message):
268
+ try:
269
+ if message.from_user:
270
+ await log_new_user(bot, message.from_user.id, message.from_user.first_name)
271
+
272
+ about_text = MSG_ABOUT
273
+ buttons = [
274
+ [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")],
275
+ [InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"),
276
+ InlineKeyboardButton("🗙 Close", callback_data="close_panel")]
277
+ ]
278
+
279
+ reply_markup = InlineKeyboardMarkup(buttons)
280
+
281
+ await message.reply_text(
282
+ text=about_text,
283
+ reply_markup=reply_markup,
284
+ quote=True,
285
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
286
+ )
287
+
288
+ except Exception as e:
289
+ logger.error(f"Error in about_command: {e}")
290
+ await handle_user_error(message, MSG_ERROR_GENERIC)
291
+
292
+ @check_banned
293
+ @force_channel_check
294
+ @StreamBot.on_message(filters.command("dc"))
295
+ async def dc_command(bot: Client, message: Message):
296
+ try:
297
+ # Early exit conditions
298
+ if not message.from_user and not message.reply_to_message:
299
+ await handle_user_error(message, MSG_DC_ANON_ERROR)
300
+ return
301
+
302
+ async def process_dc_info(user: User):
303
+ dc_text = await generate_dc_text(user)
304
+ buttons = []
305
+ if user.username:
306
+ profile_url = f"https://t.me/{user.username}"
307
+ else:
308
+ profile_url = f"tg://user?id={user.id}"
309
+ buttons.append([
310
+ InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=profile_url)
311
+ ])
312
+ buttons.append([
313
+ InlineKeyboardButton("🗙 Close", callback_data="close_panel")
314
+ ])
315
+ dc_keyboard = InlineKeyboardMarkup(buttons)
316
+
317
+ await message.reply_text(
318
+ dc_text,
319
+ reply_markup=dc_keyboard,
320
+ quote=True,
321
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
322
+ )
323
+
324
+ async def process_file_dc_info(file_msg: Message):
325
+ try:
326
+ file_name = get_name(file_msg) or "Untitled File"
327
+ file_size = humanbytes(get_media_file_size(file_msg))
328
+ file_type_map = {
329
+ "document": MSG_FILE_TYPE_DOCUMENT,
330
+ "photo": MSG_FILE_TYPE_PHOTO,
331
+ "video": MSG_FILE_TYPE_VIDEO,
332
+ "audio": MSG_FILE_TYPE_AUDIO,
333
+ "voice": MSG_FILE_TYPE_VOICE,
334
+ "sticker": MSG_FILE_TYPE_STICKER,
335
+ "animation": MSG_FILE_TYPE_ANIMATION,
336
+ "video_note": MSG_FILE_TYPE_VIDEO_NOTE
337
+ }
338
+
339
+ file_type_attr = next((attr for attr in file_type_map if getattr(file_msg, attr, None)), "unknown")
340
+ file_type_display = file_type_map.get(file_type_attr, MSG_FILE_TYPE_UNKNOWN)
341
+
342
+ actual_media = None
343
+ if file_type_attr == "photo" and file_msg.photo and file_msg.photo.sizes:
344
+ actual_media = file_msg.photo.sizes[-1]
345
+ elif file_type_attr != "unknown":
346
+ potential = getattr(file_msg, file_type_attr, None)
347
+ if potential:
348
+ actual_media = potential
349
+
350
+ dc_id = MSG_DC_UNKNOWN
351
+ if hasattr(file_msg, 'raw') and hasattr(file_msg.raw, 'media'):
352
+ if hasattr(file_msg.raw.media, 'document') and hasattr(file_msg.raw.media.document, 'dc_id'):
353
+ dc_id = file_msg.raw.media.document.dc_id
354
+
355
+ dc_text = MSG_DC_FILE_INFO.format(
356
+ file_name=file_name,
357
+ file_size=file_size,
358
+ file_type=file_type_display,
359
+ dc_id=dc_id
360
+ )
361
+ await message.reply_text(
362
+ dc_text,
363
+ quote=True,
364
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
365
+ )
366
+ except Exception as e:
367
+ logger.error(f"Error processing file info for DC command: {e}")
368
+ await handle_user_error(message, MSG_DC_FILE_ERROR)
369
+
370
+ # If DC command has an argument, treat it as user lookup
371
+ args = message.text.strip().split(maxsplit=1)
372
+ if len(args) > 1:
373
+ query = args[1].strip()
374
+ user = await get_user_safely(bot, query)
375
+ if user:
376
+ await process_dc_info(user)
377
+ else:
378
+ await handle_user_error(message, MSG_ERROR_USER_INFO)
379
+ return
380
+
381
+ # Handle replied message cases
382
+ if message.reply_to_message:
383
+ if has_media(message.reply_to_message):
384
+ await process_file_dc_info(message.reply_to_message)
385
+ return
386
+ elif message.reply_to_message.from_user:
387
+ await process_dc_info(message.reply_to_message.from_user)
388
+ return
389
+ else:
390
+ await handle_user_error(message, MSG_DC_INVALID_USAGE)
391
+ return
392
+
393
+ # Otherwise, show info for the command sender
394
+ if message.from_user:
395
+ await process_dc_info(message.from_user)
396
+ else:
397
+ await handle_user_error(message, MSG_DC_ANON_ERROR)
398
+
399
+ except Exception as e:
400
+ logger.error(f"Error in dc_command: {e}")
401
+ await handle_user_error(message, MSG_ERROR_GENERIC)
402
+
403
+ @check_banned
404
+ @force_channel_check
405
+ @StreamBot.on_message(filters.command("ping") & filters.private)
406
+ async def ping_command(bot: Client, message: Message):
407
+ try:
408
+ start_time = time.time()
409
+ sent_msg = await message.reply_text(MSG_PING_START, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
410
+ end_time = time.time()
411
+ time_taken_ms = (end_time - start_time) * 1000
412
+ buttons = [
413
+ [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
414
+ InlineKeyboardButton("🗙 Close", callback_data="close_panel")]
415
+ ]
416
+
417
+ await sent_msg.edit_text(
418
+ MSG_PING_RESPONSE.format(time_taken_ms=time_taken_ms),
419
+ reply_markup=InlineKeyboardMarkup(buttons),
420
+ link_preview_options=LinkPreviewOptions(is_disabled=True)
421
+ )
422
+ except Exception as e:
423
+ logger.error(f"Error in ping_command: {e}")
424
+ await handle_user_error(message, MSG_ERROR_GENERIC)
425
+
426
+ # ====== CALLBACK HANDLERS ======
427
+
428
+ async def handle_callback_error(callback_query, error, operation="callback"):
429
+ """Standardized error handling for callback queries."""
430
+ error_id = uuid.uuid4().hex[:8]
431
+ logger.error(f"Error in {operation}: {error}")
432
+ try:
433
+ await callback_query.answer(
434
+ MSG_ERROR_GENERIC_CALLBACK.format(error_id=error_id),
435
+ show_alert=True
436
+ )
437
+ except Exception as e:
438
+ logger.error(f"Failed to send error callback: {e}")
439
+
440
+ @StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
441
+ async def close_panel_callback(client: Client, callback_query: CallbackQuery):
442
+ """Handler for Close button - deletes current panel and command message."""
443
+ try:
444
+ await callback_query.answer()
445
+ await callback_query.message.delete()
446
+ if callback_query.message.reply_to_message:
447
+ try:
448
+ ctx = callback_query.message.reply_to_message
449
+ await ctx.delete()
450
+ if ctx.reply_to_message:
451
+ await ctx.reply_to_message.delete()
452
+ except Exception as e:
453
+ logger.warning(f"Error deleting command messages: {e}")
454
+
455
+ except Exception as e:
456
+ await handle_callback_error(callback_query, e, "close_panel_callback")
457
+ finally:
458
+ raise StopPropagation
Thunder/bot/plugins/stream.py ADDED
@@ -0,0 +1,802 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thunder/bot/plugins/stream.py - Streaming and file link plugin handlers for Thunder bot.
3
+ """
4
+
5
+ import time
6
+ import asyncio
7
+ import random
8
+ import uuid
9
+ from urllib.parse import quote
10
+ from typing import Optional, Tuple, Dict, Union, List, Set
11
+ from datetime import datetime, timedelta
12
+
13
+ from pyrogram.client import Client
14
+ from pyrogram import filters, enums
15
+ from pyrogram.errors import (
16
+ FloodWait,
17
+ RPCError,
18
+ MediaEmpty,
19
+ FileReferenceExpired,
20
+ FileReferenceInvalid,
21
+ MessageNotModified
22
+ )
23
+
24
+ from pyrogram.types import (
25
+ InlineKeyboardMarkup,
26
+ InlineKeyboardButton,
27
+ Message,
28
+ CallbackQuery,
29
+ LinkPreviewOptions
30
+ )
31
+
32
+ from pyrogram.enums import ChatMemberStatus
33
+ from Thunder.bot import StreamBot
34
+ from Thunder.utils.database import db
35
+ from Thunder.utils.messages import *
36
+ from Thunder.utils.file_properties import get_hash, get_media_file_size, get_name
37
+ from Thunder.utils.human_readable import humanbytes
38
+ from Thunder.utils.logger import logger
39
+ from Thunder.vars import Var
40
+ from Thunder.utils.decorators import check_banned, require_token, shorten_link
41
+ from Thunder.utils.force_channel import force_channel_check
42
+ from Thunder.utils.shortener import shorten
43
+ from Thunder.utils.bot_utils import (
44
+ notify_owner,
45
+ handle_user_error,
46
+ log_new_user,
47
+ generate_media_links,
48
+ send_links_to_user,
49
+ check_admin_privileges
50
+ )
51
+
52
+
53
+ class LRUCache:
54
+ def __init__(self, max_size=1000, ttl=86400):
55
+ self.cache = {}
56
+ self.max_size = max_size
57
+ self.ttl = ttl
58
+ self.access_order = []
59
+ self._lock = asyncio.Lock()
60
+
61
+ async def get(self, key):
62
+ if key is None:
63
+ return None
64
+
65
+ async with self._lock:
66
+ if key not in self.cache:
67
+ return None
68
+
69
+ item = self.cache[key]
70
+ if time.time() - item['timestamp'] > self.ttl:
71
+ self._remove_item(key)
72
+ return None
73
+
74
+ self._update_access_order(key)
75
+ return item
76
+
77
+ def _update_access_order(self, key):
78
+ try:
79
+ self.access_order.remove(key)
80
+ except ValueError:
81
+ pass
82
+ self.access_order.append(key)
83
+
84
+ def _remove_item(self, key):
85
+ try:
86
+ del self.cache[key]
87
+ self.access_order.remove(key)
88
+ except (KeyError, ValueError):
89
+ pass
90
+
91
+ async def set(self, key, value):
92
+ if key is None or not isinstance(value, dict):
93
+ return
94
+
95
+ async with self._lock:
96
+ if key in self.cache:
97
+ self._update_access_order(key)
98
+ elif len(self.cache) >= self.max_size:
99
+ lru_key = self.access_order[0]
100
+ self._remove_item(lru_key)
101
+
102
+ self.cache[key] = value
103
+ self._update_access_order(key)
104
+
105
+ async def clean_expired(self):
106
+ count = 0
107
+ current_time = time.time()
108
+
109
+ async with self._lock:
110
+ keys_to_remove = []
111
+ for key in self.cache:
112
+ if current_time - self.cache[key]['timestamp'] > self.ttl:
113
+ keys_to_remove.append(key)
114
+
115
+ for key in keys_to_remove:
116
+ self._remove_item(key)
117
+ count += 1
118
+
119
+ return count
120
+
121
+
122
+ # Initialize cache
123
+ CACHE = LRUCache(max_size=getattr(Var, "CACHE_SIZE", 1000), ttl=86400)
124
+
125
+
126
+ async def clean_cache_task():
127
+ while True:
128
+ try:
129
+ cleaned_count = await CACHE.clean_expired()
130
+ if cleaned_count > 0:
131
+ logger.info(f"Cache cleanup: Removed {cleaned_count} expired items")
132
+ await asyncio.sleep(3600)
133
+ except Exception as e:
134
+ logger.error(f"Error in cache cleaning task: {e}")
135
+ await asyncio.sleep(60)
136
+
137
+
138
+ class RateLimiter:
139
+ def __init__(self, max_calls, time_period):
140
+ self.max_calls = max_calls
141
+ self.time_period = time_period
142
+ self.calls = {}
143
+ self._lock = asyncio.Lock()
144
+ self._cleanup_task = None
145
+ self._stopping = False
146
+
147
+ def start_cleanup_task(self):
148
+ if self._cleanup_task is None or self._cleanup_task.done():
149
+ self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
150
+ self._cleanup_task.add_done_callback(self._cleanup_task_done)
151
+
152
+ def stop_cleanup_task(self):
153
+ self._stopping = True
154
+ if self._cleanup_task and not self._cleanup_task.done():
155
+ self._cleanup_task.cancel()
156
+
157
+ def _cleanup_task_done(self, task):
158
+ try:
159
+ task.result()
160
+ except asyncio.CancelledError:
161
+ pass
162
+ except Exception as e:
163
+ logger.error(f"Error in rate limiter cleanup task: {e}")
164
+ if not self._stopping:
165
+ asyncio.create_task(self._delayed_restart())
166
+
167
+ async def _delayed_restart(self):
168
+ await asyncio.sleep(5)
169
+ if not self._stopping:
170
+ self.start_cleanup_task()
171
+
172
+ async def _periodic_cleanup(self):
173
+ while not self._stopping:
174
+ try:
175
+ await asyncio.sleep(60)
176
+ await self._cleanup_old_entries()
177
+ except asyncio.CancelledError:
178
+ break
179
+ except Exception as e:
180
+ logger.error(f"Error in periodic cleanup: {e}")
181
+ await asyncio.sleep(30)
182
+
183
+ async def _cleanup_old_entries(self):
184
+ now = time.time()
185
+ async with self._lock:
186
+ for user_id in list(self.calls.keys()):
187
+ self.calls[user_id] = [ts for ts in self.calls[user_id]
188
+ if now - ts <= self.time_period]
189
+ if not self.calls[user_id]:
190
+ del self.calls[user_id]
191
+
192
+ async def is_rate_limited(self, user_id):
193
+ if user_id is None:
194
+ return False
195
+
196
+ async with self._lock:
197
+ now = time.time()
198
+ if user_id not in self.calls:
199
+ self.calls[user_id] = []
200
+
201
+ self.calls[user_id] = [
202
+ ts for ts in self.calls[user_id]
203
+ if now - ts <= self.time_period
204
+ ]
205
+
206
+ if len(self.calls[user_id]) >= self.max_calls:
207
+ return True
208
+
209
+ self.calls[user_id].append(now)
210
+ return False
211
+
212
+ async def get_reset_time(self, user_id):
213
+ if user_id is None:
214
+ return 0
215
+
216
+ async with self._lock:
217
+ if user_id not in self.calls or not self.calls[user_id]:
218
+ return 0
219
+
220
+ now = time.time()
221
+ oldest_call = min(self.calls[user_id])
222
+ return max(0, self.time_period - (now - oldest_call))
223
+
224
+
225
+ # Initialize rate limiter
226
+ rate_limiter = RateLimiter(max_calls=20, time_period=60)
227
+
228
+
229
+ # Helper Functions
230
+ async def handle_flood_wait(e):
231
+ wait_time = e.value
232
+ logger.debug(f"FloodWait encountered. Sleeping for {wait_time} seconds.")
233
+ jitter = random.uniform(0, 0.05 * wait_time)
234
+ await asyncio.sleep(wait_time + jitter)
235
+
236
+
237
+ def get_file_unique_id(media_message):
238
+ media_types = [
239
+ 'document', 'video', 'audio', 'photo', 'animation',
240
+ 'voice', 'video_note', 'sticker'
241
+ ]
242
+
243
+ for media_type in media_types:
244
+ media = getattr(media_message, media_type, None)
245
+ if media:
246
+ return media.file_unique_id
247
+
248
+ return None
249
+
250
+
251
+ async def forward_media(media_message):
252
+ for retry in range(3):
253
+ try:
254
+ result = await media_message.copy(chat_id=Var.BIN_CHANNEL)
255
+ return result
256
+ except Exception:
257
+ try:
258
+ result = await media_message.forward(chat_id=Var.BIN_CHANNEL)
259
+ return result
260
+ except FloodWait as flood_error:
261
+ if retry < 2:
262
+ await handle_flood_wait(flood_error)
263
+ else:
264
+ raise
265
+ except Exception as forward_error:
266
+ if retry == 2:
267
+ logger.error(f"Error forwarding media: {forward_error}")
268
+ raise
269
+ await asyncio.sleep(0.5)
270
+
271
+ raise Exception("Failed to forward media after multiple attempts")
272
+
273
+
274
+ async def log_request(log_msg, user, stream_link, online_link):
275
+ try:
276
+ user_name = user.first_name if hasattr(user, 'first_name') else user.title
277
+ await log_msg.reply_text(
278
+ MSG_NEW_FILE_REQUEST.format(
279
+ user_name=user_name,
280
+ user_id=user.id,
281
+ online_link=online_link,
282
+ stream_link=stream_link
283
+ ),
284
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
285
+ quote=True
286
+ )
287
+ except Exception:
288
+ pass
289
+
290
+
291
+ async def process_media_message(client, command_message, media_message, notify=True, shortener=True):
292
+ retries = 0
293
+ max_retries = 3
294
+ cache_key = get_file_unique_id(media_message)
295
+
296
+ if cache_key is None:
297
+ if notify:
298
+ await command_message.reply_text(MSG_ERROR_FILE_ID_EXTRACT, quote=True)
299
+ return None
300
+
301
+ cached_data = await CACHE.get(cache_key)
302
+ if cached_data:
303
+ if notify:
304
+ await send_links_to_user(
305
+ client,
306
+ command_message,
307
+ cached_data['media_name'],
308
+ cached_data['media_size'],
309
+ cached_data['stream_link'],
310
+ cached_data['online_link']
311
+ )
312
+ return cached_data['online_link']
313
+
314
+ while retries < max_retries:
315
+ try:
316
+ log_msg = await forward_media(media_message)
317
+ stream_link, online_link, media_name, media_size = await generate_media_links(log_msg, shortener=shortener)
318
+
319
+ await CACHE.set(cache_key, {
320
+ 'media_name': media_name,
321
+ 'media_size': media_size,
322
+ 'stream_link': stream_link,
323
+ 'online_link': online_link,
324
+ 'message_id': log_msg.id,
325
+ 'timestamp': time.time()
326
+ })
327
+
328
+ if notify:
329
+ await send_links_to_user(
330
+ client,
331
+ command_message,
332
+ media_name,
333
+ media_size,
334
+ stream_link,
335
+ online_link
336
+ )
337
+
338
+ await log_request(log_msg, command_message.from_user, stream_link, online_link)
339
+ return online_link
340
+
341
+ except FloodWait as e:
342
+ await handle_flood_wait(e)
343
+ retries += 1
344
+ continue
345
+ except (FileReferenceExpired, FileReferenceInvalid):
346
+ retries += 1
347
+ await asyncio.sleep(0.3)
348
+ continue
349
+ except MediaEmpty:
350
+ if notify:
351
+ await command_message.reply_text(MSG_MEDIA_ERROR, quote=True)
352
+ return None
353
+ except Exception as e:
354
+ logger.error(f"Error processing media: {e}")
355
+ if retries < max_retries - 1:
356
+ retries += 1
357
+ await asyncio.sleep(0.3)
358
+ continue
359
+
360
+ if notify:
361
+ await handle_user_error(
362
+ command_message,
363
+ MSG_ERROR_PROCESSING_MEDIA
364
+ )
365
+
366
+ await notify_owner(
367
+ client,
368
+ MSG_CRITICAL_ERROR.format(error=e, error_id=uuid.uuid4().hex[:8])
369
+ )
370
+ return None
371
+
372
+ return None
373
+
374
+
375
+ async def retry_failed_media(client, command_message, media_messages, status_msg=None, shortener=True):
376
+ results = []
377
+ for i, msg in enumerate(media_messages):
378
+ try:
379
+ result = await process_media_message(client, command_message, msg, notify=False, shortener=shortener)
380
+ if result:
381
+ results.append(result)
382
+
383
+ if status_msg and i % 2 == 0:
384
+ try:
385
+ await status_msg.edit(f"🔄 **Retrying Failed Files:** {len(results)}/{len(media_messages)} processed")
386
+ except MessageNotModified:
387
+ pass
388
+ except Exception as e:
389
+ logger.error(f"Error retrying media: {e}")
390
+
391
+ return results
392
+
393
+
394
+ async def process_multiple_messages(client, command_message, reply_msg, num_files, status_msg, shortener=True):
395
+ chat_id = command_message.chat.id
396
+ start_message_id = reply_msg.id
397
+ end_message_id = start_message_id + num_files - 1
398
+ message_ids = list(range(start_message_id, end_message_id + 1))
399
+
400
+ batch_size = 10
401
+ processed_count = 0
402
+ failed_count = 0
403
+ download_links = []
404
+ failed_messages = []
405
+ last_status_text = ""
406
+
407
+ try:
408
+ for i in range(0, len(message_ids), batch_size):
409
+ batch_ids = message_ids[i:i+batch_size]
410
+ new_status_text = MSG_PROCESSING_BATCH.format(
411
+ batch_number=(i//batch_size)+1,
412
+ total_batches=(len(message_ids)+batch_size-1)//batch_size,
413
+ file_count=len(batch_ids)
414
+ )
415
+
416
+ if new_status_text != last_status_text:
417
+ try:
418
+ await status_msg.edit(new_status_text)
419
+ last_status_text = new_status_text
420
+ except MessageNotModified:
421
+ pass
422
+
423
+ await asyncio.sleep(1.0)
424
+
425
+ messages = []
426
+ for retry in range(3):
427
+ try:
428
+ messages = await client.get_messages(
429
+ chat_id=chat_id,
430
+ message_ids=batch_ids
431
+ )
432
+ break
433
+ except FloodWait as e:
434
+ await handle_flood_wait(e)
435
+ except Exception as e:
436
+ if retry == 2:
437
+ logger.error(f"Failed to get batch {i//batch_size+1}: {e}")
438
+ await asyncio.sleep(0.5)
439
+
440
+ for msg in messages:
441
+ if msg and msg.media:
442
+ try:
443
+ result = await process_media_message(
444
+ client, command_message, msg, notify=False, shortener=shortener
445
+ )
446
+
447
+ if result:
448
+ download_links.append(result)
449
+ processed_count += 1
450
+ else:
451
+ failed_count += 1
452
+ failed_messages.append(msg)
453
+ except Exception as e:
454
+ failed_count += 1
455
+ failed_messages.append(msg)
456
+ logger.error(f"Failed to process {msg.id}: {e}")
457
+
458
+ await asyncio.sleep(1.0)
459
+
460
+ if processed_count % 5 == 0 or processed_count + failed_count == len(message_ids):
461
+ new_status_text = MSG_PROCESSING_STATUS.format(
462
+ processed=processed_count,
463
+ total=num_files,
464
+ failed=failed_count
465
+ )
466
+
467
+ if new_status_text != last_status_text:
468
+ try:
469
+ await status_msg.edit(new_status_text)
470
+ last_status_text = new_status_text
471
+ except MessageNotModified:
472
+ pass
473
+
474
+ # Handle retries for failed messages
475
+ if failed_messages and len(failed_messages) < num_files / 2:
476
+ new_status_text = MSG_RETRYING_FILES.format(count=len(failed_messages))
477
+ if new_status_text != last_status_text:
478
+ try:
479
+ await status_msg.edit(new_status_text)
480
+ last_status_text = new_status_text
481
+ except MessageNotModified:
482
+ pass
483
+
484
+ retry_results = await retry_failed_media(
485
+ client, command_message, failed_messages, status_msg, shortener
486
+ )
487
+
488
+ if retry_results:
489
+ download_links.extend(retry_results)
490
+ processed_count += len(retry_results)
491
+ failed_count -= len(retry_results)
492
+
493
+ # Send batch links to user
494
+ def chunk_list(lst, n):
495
+ for i in range(0, len(lst), n):
496
+ yield lst[i:i + n]
497
+
498
+ for chunk in chunk_list(download_links, 20):
499
+ links_text = "\n".join(chunk)
500
+ batch_links_message = MSG_BATCH_LINKS_READY.format(
501
+ count=len(chunk),
502
+ links=links_text
503
+ )
504
+
505
+ await command_message.reply_text(
506
+ batch_links_message,
507
+ quote=True,
508
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
509
+ parse_mode=enums.ParseMode.MARKDOWN
510
+ )
511
+
512
+ if len(chunk) > 10:
513
+ await asyncio.sleep(0.5)
514
+
515
+ # Update final status
516
+ new_status_text = MSG_PROCESSING_RESULT.format(
517
+ processed=processed_count,
518
+ total=num_files,
519
+ failed=failed_count
520
+ )
521
+
522
+ if new_status_text != last_status_text:
523
+ try:
524
+ await status_msg.edit(new_status_text)
525
+ last_status_text = new_status_text
526
+ except MessageNotModified:
527
+ pass
528
+
529
+ except Exception as e:
530
+ logger.error(f"Error in batch processing: {e}")
531
+ new_status_text = MSG_PROCESSING_ERROR.format(
532
+ error=str(e),
533
+ processed=processed_count,
534
+ total=num_files,
535
+ error_id=uuid.uuid4().hex[:8]
536
+ )
537
+
538
+ if new_status_text != last_status_text:
539
+ try:
540
+ await status_msg.edit(new_status_text)
541
+ last_status_text = new_status_text
542
+ except MessageNotModified:
543
+ pass
544
+
545
+
546
+ @StreamBot.on_message(filters.command("link") & ~filters.private)
547
+ @check_banned
548
+ @require_token
549
+ @shorten_link
550
+ @force_channel_check
551
+ async def link_handler(client, message, shortener=True):
552
+ user_id = message.from_user.id
553
+
554
+ if not await db.is_user_exist(user_id):
555
+ try:
556
+ invite_link = f"https://t.me/{client.me.username}?start=start"
557
+ await message.reply_text(
558
+ MSG_ERROR_START_BOT.format(invite_link=invite_link),
559
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
560
+ parse_mode=enums.ParseMode.MARKDOWN,
561
+ reply_markup=InlineKeyboardMarkup([[InlineKeyboardButton(MSG_BUTTON_START_CHAT, url=invite_link)]]),
562
+ quote=True
563
+ )
564
+ except Exception:
565
+ pass
566
+ return
567
+
568
+ if await rate_limiter.is_rate_limited(user_id):
569
+ reset_time = await rate_limiter.get_reset_time(user_id)
570
+ await message.reply_text(
571
+ MSG_ERROR_RATE_LIMIT.format(seconds=f"{reset_time:.0f}"),
572
+ quote=True
573
+ )
574
+ return
575
+
576
+ if message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
577
+ is_admin = await check_admin_privileges(client, message.chat.id)
578
+ if not is_admin:
579
+ await message.reply_text(
580
+ MSG_ERROR_NOT_ADMIN,
581
+ quote=True
582
+ )
583
+ return
584
+
585
+ if not message.reply_to_message:
586
+ await message.reply_text(
587
+ MSG_ERROR_REPLY_FILE,
588
+ quote=True
589
+ )
590
+ return
591
+
592
+ reply_msg = message.reply_to_message
593
+ if not reply_msg.media:
594
+ await message.reply_text(
595
+ MSG_ERROR_NO_FILE,
596
+ quote=True
597
+ )
598
+ return
599
+
600
+ command_parts = message.text.strip().split()
601
+ num_files = 1
602
+
603
+ if len(command_parts) > 1:
604
+ try:
605
+ num_files = int(command_parts[1])
606
+ if num_files < 1 or num_files > 100:
607
+ await message.reply_text(
608
+ MSG_ERROR_NUMBER_RANGE,
609
+ quote=True
610
+ )
611
+ return
612
+ except ValueError:
613
+ await message.reply_text(
614
+ MSG_ERROR_INVALID_NUMBER,
615
+ quote=True
616
+ )
617
+ return
618
+
619
+ processing_msg = await message.reply_text(
620
+ MSG_PROCESSING_REQUEST,
621
+ quote=True
622
+ )
623
+
624
+ try:
625
+ if num_files == 1:
626
+ result = await process_media_message(client, message, reply_msg, shortener=shortener)
627
+ if result:
628
+ if message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
629
+ try:
630
+ cached_data = await CACHE.get(get_file_unique_id(reply_msg))
631
+ if cached_data:
632
+ msg_text = (
633
+ MSG_LINKS.format(
634
+ file_name=cached_data['media_name'],
635
+ file_size=cached_data['media_size'],
636
+ download_link=cached_data['online_link'],
637
+ stream_link=cached_data['stream_link']
638
+ )
639
+ )
640
+ await client.send_message(
641
+ chat_id=message.from_user.id,
642
+ text=MSG_BATCH_LINKS_FROM.format(
643
+ chat_title=message.chat.title,
644
+ links_message=msg_text
645
+ ),
646
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
647
+ parse_mode=enums.ParseMode.MARKDOWN,
648
+ reply_markup=InlineKeyboardMarkup([
649
+ [
650
+ InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=cached_data['stream_link']),
651
+ InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=cached_data['online_link'])
652
+ ]
653
+ ]),
654
+ )
655
+ except Exception:
656
+ await message.reply_text(
657
+ MSG_ERROR_DM_FAILED,
658
+ quote=True
659
+ )
660
+
661
+ await processing_msg.delete()
662
+ else:
663
+ try:
664
+ await processing_msg.edit(MSG_ERROR_PROCESSING_MEDIA)
665
+ except MessageNotModified:
666
+ pass
667
+ else:
668
+ await process_multiple_messages(client, message, reply_msg, num_files, processing_msg, shortener)
669
+
670
+ except Exception as e:
671
+ logger.error(f"Error handling link command: {e}")
672
+ try:
673
+ await processing_msg.edit(MSG_ERROR_PROCESSING_MEDIA)
674
+ except MessageNotModified:
675
+ pass
676
+
677
+
678
+ @StreamBot.on_message(
679
+ filters.private & filters.incoming & (
680
+ filters.document | filters.video | filters.photo | filters.audio |
681
+ filters.voice | filters.animation | filters.video_note
682
+ ),
683
+ group=4
684
+ )
685
+ @check_banned
686
+ @require_token
687
+ @shorten_link
688
+ @force_channel_check
689
+ async def private_receive_handler(client, message, shortener=True):
690
+ if not message.from_user:
691
+ return
692
+
693
+ if await rate_limiter.is_rate_limited(message.from_user.id):
694
+ reset_time = await rate_limiter.get_reset_time(message.from_user.id)
695
+ await message.reply_text(
696
+ MSG_ERROR_RATE_LIMIT.format(seconds=f"{reset_time:.0f}"),
697
+ quote=True
698
+ )
699
+ return
700
+
701
+ await log_new_user(
702
+ bot=client,
703
+ user_id=message.from_user.id,
704
+ first_name=message.from_user.first_name
705
+ )
706
+
707
+ processing_msg = await message.reply_text(
708
+ MSG_PROCESSING_FILE,
709
+ quote=True
710
+ )
711
+
712
+ try:
713
+ result = await process_media_message(client, message, message, shortener=shortener)
714
+ if result:
715
+ await processing_msg.delete()
716
+ else:
717
+ try:
718
+ await processing_msg.edit(MSG_ERROR_PROCESSING_MEDIA)
719
+ except MessageNotModified:
720
+ pass
721
+ except Exception as e:
722
+ logger.error(f"Error in private handler: {e}")
723
+ try:
724
+ await processing_msg.edit(MSG_ERROR_PROCESSING_MEDIA)
725
+ except MessageNotModified:
726
+ pass
727
+
728
+
729
+ @StreamBot.on_message(
730
+ filters.channel & filters.incoming & (
731
+ filters.document | filters.video | filters.photo | filters.audio |
732
+ filters.voice | filters.animation | filters.video_note
733
+ ),
734
+ group=-1
735
+ )
736
+ @shorten_link
737
+ async def channel_receive_handler(client, broadcast, shortener=True):
738
+ if hasattr(Var, 'BANNED_CHANNELS') and int(broadcast.chat.id) in Var.BANNED_CHANNELS:
739
+ await client.leave_chat(broadcast.chat.id)
740
+ return
741
+
742
+ can_edit = False
743
+ try:
744
+ member = await client.get_chat_member(broadcast.chat.id, client.me.id)
745
+ can_edit = member.status in [
746
+ enums.ChatMemberStatus.ADMINISTRATOR,
747
+ enums.ChatMemberStatus.OWNER
748
+ ]
749
+ except Exception:
750
+ can_edit = False
751
+
752
+ retries = 0
753
+ max_retries = 3
754
+
755
+ while retries < max_retries:
756
+ try:
757
+ log_msg = await forward_media(broadcast)
758
+ stream_link, online_link, media_name, media_size = await generate_media_links(log_msg, shortener=shortener)
759
+
760
+ await log_request(log_msg, broadcast.chat, stream_link, online_link)
761
+
762
+ if can_edit:
763
+ try:
764
+ await client.edit_message_reply_markup(
765
+ chat_id=broadcast.chat.id,
766
+ message_id=broadcast.id,
767
+ reply_markup=InlineKeyboardMarkup([
768
+ [
769
+ InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
770
+ InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=online_link)
771
+ ]
772
+ ])
773
+ )
774
+ except Exception:
775
+ pass
776
+
777
+ break
778
+
779
+ except FloodWait as e:
780
+ await handle_flood_wait(e)
781
+ retries += 1
782
+ continue
783
+ except (FileReferenceExpired, FileReferenceInvalid):
784
+ retries += 1
785
+ await asyncio.sleep(0.5)
786
+ continue
787
+ except Exception as e:
788
+ logger.error(f"Error handling channel message: {e}")
789
+ if retries < max_retries - 1:
790
+ retries += 1
791
+ await asyncio.sleep(0.5)
792
+ continue
793
+
794
+ await notify_owner(
795
+ client,
796
+ MSG_CRITICAL_ERROR.format(error=e, error_id=uuid.uuid4().hex[:8])
797
+ )
798
+ break
799
+
800
+ # Start background tasks
801
+ StreamBot.loop.create_task(clean_cache_task())
802
+ rate_limiter.start_cleanup_task()
Thunder/server/__init__.py ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
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
Thunder/server/exceptions.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
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"
Thunder/server/stream_routes.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thunder/server/stream_routes.py
2
+
3
+ import asyncio
4
+ import inspect
5
+ import mimetypes
6
+ import re
7
+ import secrets
8
+ import time
9
+ import json
10
+ import traceback
11
+ from concurrent.futures import ThreadPoolExecutor
12
+ from aiohttp import web
13
+ from aiohttp.client_exceptions import ClientConnectionError
14
+ from cachetools import LRUCache
15
+ from functools import wraps
16
+ from urllib.parse import unquote, quote
17
+ from contextlib import asynccontextmanager
18
+ from pyrogram.errors import RPCError
19
+
20
+ from Thunder import StartTime, __version__
21
+ from Thunder.bot import multi_clients, StreamBot, work_loads
22
+ from Thunder.server.exceptions import FileNotFound, InvalidHash
23
+ from Thunder.utils.custom_dl import ByteStreamer
24
+ from Thunder.utils.logger import logger
25
+ from Thunder.utils.render_template import render_page
26
+ from Thunder.utils.time_format import get_readable_time
27
+ from Thunder.vars import Var
28
+ from Thunder.utils.messages import (
29
+ MSG_INVALID_SECURITY_CREDENTIALS,
30
+ MSG_FILE_NOT_FOUND,
31
+ MSG_MALFORMED_RANGE_HEADER,
32
+ MSG_INTERNAL_SERVER_ERROR
33
+ )
34
+
35
+ routes = web.RouteTableDef()
36
+
37
+ # Configuration constants.
38
+ SECURE_HASH_LENGTH = 6
39
+ CHUNK_SIZE = 1 * 1024 * 1024
40
+ MAX_CLIENTS = 50
41
+
42
+ # Updated regex: Correctly capture start and end values with named groups.
43
+ PATTERN_HASH_FIRST = re.compile(rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$")
44
+ PATTERN_ID_FIRST = re.compile(r"^(\d+)(?:/.*)?$")
45
+ RANGE_REGEX = re.compile(r"bytes=(?P<start>\d*)-(?P<end>\d*)")
46
+
47
+ # Global LRU cache for ByteStreamer instances.
48
+ class_cache = LRUCache(maxsize=Var.CACHE_SIZE)
49
+ cache_lock = asyncio.Lock()
50
+
51
+ def json_error(status, message, error_id=None):
52
+ if error_id and "{error_id}" in message:
53
+ message = message.format(error_id=error_id)
54
+ return json.dumps({"error": message})
55
+
56
+ def exception_handler(func):
57
+ @wraps(func)
58
+ async def wrapper(request):
59
+ try:
60
+ return await func(request)
61
+ except InvalidHash as e:
62
+ logger.debug(f"InvalidHash exception: {e}")
63
+ error_id = secrets.token_hex(6)
64
+ raise web.HTTPForbidden(
65
+ text=json_error(403, MSG_INVALID_SECURITY_CREDENTIALS, error_id=error_id),
66
+ content_type="application/json"
67
+ )
68
+ except FileNotFound as e:
69
+ logger.debug(f"FileNotFound exception: {e}")
70
+ error_id = secrets.token_hex(6)
71
+ raise web.HTTPNotFound(
72
+ text=json_error(404, MSG_FILE_NOT_FOUND, error_id=error_id),
73
+ content_type="application/json"
74
+ )
75
+ except (TimeoutError, RPCError) as e:
76
+ error_id = secrets.token_hex(6)
77
+ logger.warning(f"A {type(e).__name__} occurred after retries (ID: {error_id}): {e}")
78
+ raise web.HTTPInternalServerError(
79
+ text=json_error(500, MSG_INTERNAL_SERVER_ERROR, error_id=error_id),
80
+ content_type="application/json"
81
+ )
82
+ except (ClientConnectionError, asyncio.CancelledError):
83
+ return web.Response(status=499)
84
+ except web.HTTPException:
85
+ raise
86
+ except Exception as e:
87
+ error_id = secrets.token_hex(6)
88
+ logger.error(f"Unhandled exception (ID: {error_id}): {str(e)}")
89
+ logger.error(f"Stack trace for error {error_id}:\n{traceback.format_exc()}")
90
+
91
+ raise web.HTTPInternalServerError(
92
+ text=json_error(500, MSG_INTERNAL_SERVER_ERROR, error_id=error_id),
93
+ content_type="application/json"
94
+ )
95
+ return wrapper
96
+
97
+ def parse_media_request(path: str, query: dict) -> tuple[int, str]:
98
+ clean_path = unquote(path).strip('/')
99
+ match = PATTERN_HASH_FIRST.match(clean_path)
100
+ if match:
101
+ try:
102
+ message_id = int(match.group(2))
103
+ secure_hash = match.group(1)
104
+ if not re.match(r'^[a-zA-Z0-9_-]+$', secure_hash):
105
+ raise InvalidHash("Security token contains invalid characters")
106
+ return message_id, secure_hash
107
+ except ValueError:
108
+ raise InvalidHash("Invalid message ID format")
109
+ match = PATTERN_ID_FIRST.match(clean_path)
110
+ if match:
111
+ try:
112
+ message_id = int(match.group(1))
113
+ secure_hash = query.get("hash", "").strip()
114
+ if len(secure_hash) != SECURE_HASH_LENGTH:
115
+ raise InvalidHash("Security token length mismatch")
116
+ if not re.match(r'^[a-zA-Z0-9_-]+$', secure_hash):
117
+ raise InvalidHash("Security token contains invalid characters")
118
+ return message_id, secure_hash
119
+ except ValueError:
120
+ raise InvalidHash("Invalid message ID format")
121
+ raise InvalidHash("Invalid URL structure")
122
+
123
+ def optimal_client_selection():
124
+ if not work_loads:
125
+ error_id = secrets.token_hex(6)
126
+ raise web.HTTPInternalServerError(
127
+ text=json_error(500, MSG_INTERNAL_SERVER_ERROR, error_id=error_id),
128
+ content_type="application/json"
129
+ )
130
+ client_id, _ = min(work_loads.items(), key=lambda item: item[1])
131
+ return client_id, multi_clients[client_id]
132
+
133
+ async def get_cached_streamer(client) -> ByteStreamer:
134
+ streamer = class_cache.get(client)
135
+ if streamer is None:
136
+ async with cache_lock:
137
+ streamer = class_cache.get(client)
138
+ if streamer is None:
139
+ streamer = ByteStreamer(client)
140
+ class_cache[client] = streamer
141
+ return streamer
142
+
143
+ def sanitize_filename(filename: str) -> str:
144
+ return re.sub(r'[\n\r";]', '', filename)
145
+
146
+ @asynccontextmanager
147
+ async def track_workload(client_id):
148
+ work_loads[client_id] += 1
149
+ try:
150
+ yield
151
+ finally:
152
+ work_loads[client_id] -= 1
153
+
154
+ @routes.get("/", allow_head=True)
155
+ @exception_handler
156
+ async def root_redirect(request):
157
+ raise web.HTTPFound("https://github.com/fyaz05/FileToLink")
158
+
159
+ @routes.get("/status", allow_head=True)
160
+ async def status_endpoint(request):
161
+ uptime_seconds = time.time() - StartTime
162
+ uptime_formatted = get_readable_time(int(uptime_seconds))
163
+
164
+ sorted_workloads = dict(sorted(
165
+ {f"client_{k}": v for k, v in work_loads.items()}.items(),
166
+ key=lambda item: item[1]
167
+ ))
168
+
169
+ total_load = sum(work_loads.values())
170
+ if total_load < MAX_CLIENTS * 0.5:
171
+ status_level = "optimal"
172
+ elif total_load < MAX_CLIENTS * 0.8:
173
+ status_level = "high"
174
+ else:
175
+ status_level = "critical"
176
+
177
+ status_data = {
178
+ "server": {
179
+ "status": "operational",
180
+ "status_level": status_level,
181
+ "version": __version__,
182
+ "uptime": uptime_formatted,
183
+ "uptime_seconds": int(uptime_seconds),
184
+ "timestamp": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
185
+ },
186
+ "telegram_bot": {
187
+ "username": f"@{StreamBot.username}",
188
+ "active_clients": len(multi_clients)
189
+ },
190
+ "resources": {
191
+ "total_workload": total_load,
192
+ "max_clients": MAX_CLIENTS,
193
+ "workload_distribution": sorted_workloads,
194
+ "cache": {
195
+ "current": len(class_cache),
196
+ "maximum": Var.CACHE_SIZE,
197
+ "utilization_percent": round((len(class_cache) / Var.CACHE_SIZE) * 100, 2)
198
+ }
199
+ },
200
+ "system": {
201
+ "chunk_size": f"{CHUNK_SIZE / (1024 * 1024):.1f} MB",
202
+ }
203
+ }
204
+
205
+ return web.json_response(
206
+ status_data,
207
+ dumps=lambda obj: json.dumps(obj, indent=2, sort_keys=False)
208
+ )
209
+
210
+ @routes.get(r"/watch/{path:.+}", allow_head=True)
211
+ @exception_handler
212
+ async def media_preview(request: web.Request):
213
+ path = request.match_info["path"]
214
+ message_id, secure_hash = parse_media_request(path, request.query)
215
+ rendered_page = await render_page(message_id, secure_hash, requested_action='stream')
216
+ return web.Response(
217
+ text=rendered_page,
218
+ content_type='text/html',
219
+ headers={
220
+ "Cache-Control": "no-cache, must-revalidate",
221
+ "Content-Security-Policy": "default-src 'self' https:; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.plyr.io; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://cdn.plyr.io; img-src 'self' data: https:; font-src 'self' https://fonts.gstatic.com https://cdnjs.cloudflare.com; connect-src 'self' https:; media-src 'self' https:;",
222
+ "X-Content-Type-Options": "nosniff",
223
+ "X-Frame-Options": "SAMEORIGIN",
224
+ "X-XSS-Protection": "1; mode=block",
225
+ "Referrer-Policy": "strict-origin-when-cross-origin"
226
+ }
227
+ )
228
+
229
+ @routes.get(r"/{path:.+}", allow_head=True)
230
+ @exception_handler
231
+ async def media_delivery(request: web.Request):
232
+ client_id, client = optimal_client_selection()
233
+ async with track_workload(client_id):
234
+ path = request.match_info["path"]
235
+ message_id, secure_hash = parse_media_request(path, request.query)
236
+ return await handle_media_stream(request, message_id, secure_hash, client_id, client)
237
+
238
+ async def handle_media_stream(request, message_id, secure_hash, client_id, client):
239
+ streamer = await get_cached_streamer(client)
240
+ file_meta = await streamer.get_file_properties(message_id)
241
+ if file_meta.unique_id[:SECURE_HASH_LENGTH] != secure_hash:
242
+ raise InvalidHash("Security token mismatch")
243
+ file_size = file_meta.file_size
244
+ range_header = request.headers.get("Range", "")
245
+ if range_header:
246
+ range_match = RANGE_REGEX.fullmatch(range_header)
247
+ if not range_match:
248
+ logger.debug(f"Malformed range header received: {range_header}")
249
+ error_id = secrets.token_hex(6)
250
+ raise web.HTTPBadRequest(
251
+ text=json_error(400, MSG_MALFORMED_RANGE_HEADER, error_id=error_id),
252
+ content_type="application/json"
253
+ )
254
+ start = int(range_match.group("start")) if range_match.group("start") else 0
255
+ end = int(range_match.group("end")) if range_match.group("end") else file_size - 1
256
+ else:
257
+ try:
258
+ if hasattr(request, "http_range") and request.http_range:
259
+ start = request.http_range.start or 0
260
+ end = (request.http_range.stop or file_size) - 1
261
+ else:
262
+ start, end = 0, file_size - 1
263
+ except Exception:
264
+ start, end = 0, file_size - 1
265
+ if start < 0 or end >= file_size or start > end:
266
+ logger.debug(f"Invalid range request: start={start}, end={end}, file_size={file_size}")
267
+ raise web.HTTPRequestRangeNotSatisfiable(
268
+ headers={"Content-Range": f"bytes */{file_size}"}
269
+ )
270
+ offset = start - (start % CHUNK_SIZE)
271
+ first_chunk_cut = start - offset
272
+ last_chunk_cut = (end % CHUNK_SIZE) + 1
273
+ total_chunks = ((end - offset) // CHUNK_SIZE) + 1
274
+ mime_type = file_meta.mime_type or mimetypes.guess_type(file_meta.file_name)[0] or "application/octet-stream"
275
+ disposition = "inline" if mime_type.startswith(("video/", "audio/")) else "attachment"
276
+ original_filename = unquote(file_meta.file_name) if file_meta.file_name else f"file_{secrets.token_hex(4)}"
277
+ safe_filename = sanitize_filename(original_filename)
278
+ encoded_filename = quote(safe_filename)
279
+ headers = {
280
+ "Content-Type": mime_type,
281
+ "Content-Range": f"bytes {start}-{end}/{file_size}",
282
+ "Content-Length": str(end - start + 1),
283
+ "Content-Disposition": f"{disposition}; filename*=UTF-8''{encoded_filename}",
284
+ "Accept-Ranges": "bytes",
285
+ "Cache-Control": "public, max-age=31536000, immutable",
286
+ "Connection": "keep-alive",
287
+ "X-Content-Type-Options": "nosniff",
288
+ "Referrer-Policy": "strict-origin-when-cross-origin"
289
+ }
290
+ stream_generator = streamer.yield_file(
291
+ file_meta, client_id, offset, first_chunk_cut, last_chunk_cut, total_chunks, CHUNK_SIZE
292
+ )
293
+ return web.Response(
294
+ status=206 if range_header else 200,
295
+ body=stream_generator,
296
+ headers=headers
297
+ )
Thunder/template/dl.html ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta property="og:image" content="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/live.png">
6
+ <meta http-equiv="X-UA-Compatible" content="IE=edge">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <title>Downloading: {{ file_name }}</title>
9
+ <meta http-equiv="refresh" content="0;url={{ src }}">
10
+ <style>
11
+ body { font-family: Arial, sans-serif; text-align: center; padding: 40px; color: #333; margin: 0; }
12
+ .container { max-width: 600px; margin: auto; }
13
+ p { margin-bottom: 20px; font-size: 1em; }
14
+ a { color: #0066cc; text-decoration: none; font-weight: bold; }
15
+ a:hover { text-decoration: underline; }
16
+ .message { padding: 20px; border: 1px solid #ddd; border-radius: 5px; background-color: #f9f9f9; }
17
+ </style>
18
+ </head>
19
+ <body>
20
+ <div class="container">
21
+ <div class="message">
22
+ <p>Your download for <strong>{{ file_name }}</strong> should start automatically.</p>
23
+ <p>If it doesn't, please <a href="{{ src }}" download="{{ file_name }}">click here to download</a>.</p>
24
+ </div>
25
+ </div>
26
+ </body>
27
+ </html>
Thunder/template/req.html ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="dark">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ heading }}</title>
7
+
8
+ <meta name="description" content="Easily share and distribute files">
9
+ <meta property="og:title" content="Thunder FileToLink">
10
+ <meta property="og:description" content="File Streamer">
11
+ <meta property="og:type" content="website">
12
+ <meta property="og:image" content="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/live.png">
13
+ <meta name="theme-color" content="#1a1a2e">
14
+ <meta name="msapplication-navbutton-color" content="#1a1a2e">
15
+ <meta name="apple-mobile-web-app-status-bar-style" content="#1a1a2e">
16
+ <meta name="color-scheme" content="dark light">
17
+ <meta name="application-name" content="Thunder FileToLink">
18
+
19
+ <link rel="icon" href="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/Thunder.jpg">
20
+ <link rel="apple-touch-icon" href="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/Thunder.jpg">
21
+
22
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
23
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css">
24
+ <link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css">
25
+
26
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/reqstyle.css">
27
+ </head>
28
+ <body>
29
+ <div class="loading-overlay" id="loading-overlay">
30
+ <div class="loading-spinner">
31
+ <div></div>
32
+ <div></div>
33
+ </div>
34
+ </div>
35
+ <button class="toggle-dark-mode" onclick="app.toggleDarkMode()" aria-label="Toggle Light/Dark Mode" aria-pressed="false" title="Toggle Light/Dark Mode"><i class="fas fa-moon"></i></button>
36
+ <div class="bg-animation"><div class="bg-gradient"></div></div>
37
+
38
+ <div class="container">
39
+ <header class="header">
40
+ <div class="file-info">
41
+ <h1 id="file-name">{{ file_name }}</h1>
42
+ <div class="file-meta"></div>
43
+ </div>
44
+ </header>
45
+
46
+ <main id="main-content" role="main" aria-label="Main content">
47
+ <div class="player-container">
48
+ <{{ tag }} id="player" playsinline controls preload="metadata" controlsList="nodownload">
49
+ <source src="{{ src }}" type="{{ tag }}/mp4">
50
+ Your browser does not support the {{ tag }} tag.
51
+ </{{ tag }}>
52
+ </div>
53
+
54
+ <section class="controls-section" aria-label="File options">
55
+ <div class="dropdown-container" id="stream-dropdown-container">
56
+ <button type="button" class="action-button secondary-button stream-btn" onclick="app.toggleStreamMenu(event)" aria-haspopup="true" aria-expanded="false" aria-controls="stream-menu" id="stream-btn-label">
57
+ <i class="fas fa-play-circle" aria-hidden="true"></i><span>Stream</span>
58
+ </button>
59
+ <div class="dropdown-menu" id="stream-menu" aria-labelledby="stream-btn-label">
60
+ <div class="stream-menu-header">
61
+ <h3>Open with external player</h3>
62
+ <button type="button" aria-label="Close menu" onclick="app.closeStreamMenu()" class="stream-menu-close">
63
+ <i class="fas fa-times" aria-hidden="true"></i>
64
+ </button>
65
+ </div>
66
+ <div class="stream-menu-content" role="menu">
67
+ <div class="stream-category">
68
+ <div class="category-heading" role="presentation">
69
+ <i class="fas fa-desktop" aria-hidden="true"></i>
70
+ <span>Desktop Players</span>
71
+ </div>
72
+ <div class="player-grid">
73
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('vlc-pc')" title="Open in VLC Player (PC)">
74
+ <div class="player-icon">
75
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/HydroStreamerBot/vlc.png" alt="VLC Player icon" loading="lazy" width="32" height="32">
76
+ </div>
77
+ <span class="player-name">VLC Player</span>
78
+ </button>
79
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('potplayer')" title="Open in PotPlayer">
80
+ <div class="player-icon">
81
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/pot.png" alt="PotPlayer icon" loading="lazy" width="32" height="32">
82
+ </div>
83
+ <span class="player-name">PotPlayer</span>
84
+ </button>
85
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('mpc')" title="Open in MPC-HC">
86
+ <div class="player-icon">
87
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/classic.png" alt="MPC-HC icon" loading="lazy" width="32" height="32">
88
+ </div>
89
+ <span class="player-name">MPC-HC</span>
90
+ </button>
91
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('kmpc')" title="Open in KM Player (PC)">
92
+ <div class="player-icon">
93
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/HydroStreamerBot/km.webp" alt="KM Player icon" loading="lazy" width="32" height="32">
94
+ </div>
95
+ <span class="player-name">KM Player</span>
96
+ </button>
97
+ </div>
98
+ </div>
99
+ <div class="stream-category">
100
+ <div class="category-heading" role="presentation">
101
+ <i class="fas fa-mobile-alt" aria-hidden="true"></i>
102
+ <span>Mobile Players</span>
103
+ </div>
104
+ <div class="player-grid">
105
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('vlc')">
106
+ <div class="player-icon">
107
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/HydroStreamerBot/vlc.png" alt="VLC Mobile icon" loading="lazy" width="32" height="32">
108
+ </div>
109
+ <span class="player-name">VLC Mobile</span>
110
+ </button>
111
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('mxpro')">
112
+ <div class="player-icon">
113
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/HydroStreamerBot/mx.png" alt="MX Pro icon" loading="lazy" width="32" height="32">
114
+ </div>
115
+ <span class="player-name">MX Pro</span>
116
+ </button>
117
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('mx')">
118
+ <div class="player-icon">
119
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/HydroStreamerBot/mx.png" alt="MX Player icon" loading="lazy" width="32" height="32">
120
+ </div>
121
+ <span class="player-name">MX Player</span>
122
+ </button>
123
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('nplayer')">
124
+ <div class="player-icon">
125
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/HydroStreamerBot/nPlayer.webp" alt="nPlayer icon" loading="lazy" width="32" height="32">
126
+ </div>
127
+ <span class="player-name">nPlayer</span>
128
+ </button>
129
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('splayer')">
130
+ <div class="player-icon">
131
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/HydroStreamerBot/splayer.webp" alt="S Player icon" loading="lazy" width="32" height="32">
132
+ </div>
133
+ <span class="player-name">S Player</span>
134
+ </button>
135
+ <button type="button" class="player-card dropdown-item" tabindex="0" role="menuitem" onclick="app.playOnline('km')">
136
+ <div class="player-icon">
137
+ <img src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/HydroStreamerBot/km.webp" alt="KM Player Mobile icon" loading="lazy" width="32" height="32">
138
+ </div>
139
+ <span class="player-name">KM Player</span>
140
+ </button>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ <button type="button" class="action-button secondary-button" onclick="app.download()" title="Download the video"><i class="fas fa-cloud-download-alt" aria-hidden="true"></i><span>Download</span></button>
147
+ <button type="button" class="action-button secondary-button" onclick="app.copyLink()" title="Copy video link"><i class="fas fa-link" aria-hidden="true"></i><span>Copy Link</span></button>
148
+ </section>
149
+ </main>
150
+ </div>
151
+
152
+ <footer>
153
+ <p>Made by AI © 2025 | <a href="https://github.com/fyaz05/FileToLink" target="_blank" rel="noopener noreferrer"><i class="fab fa-github" aria-hidden="true"></i> GitHub</a></p>
154
+ </footer>
155
+
156
+ <div class="toast" id="toast" role="alert" aria-live="polite" aria-hidden="true">
157
+ <div class="toast-icon" aria-hidden="true"><i class="fas fa-info-circle"></i></div>
158
+ <div class="toast-message">Action completed.</div>
159
+ </div>
160
+
161
+ <script src="https://cdn.plyr.io/3.7.8/plyr.polyfilled.js"></script>
162
+ <script src="https://cdn.jsdelivr.net/gh/fyaz05/Resources@main/FileToLink/reqscript.js"></script>
163
+ </body>
164
+ </html>
Thunder/utils/bot_utils.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thunder/utils/bot_utils.py
2
+
3
+ import asyncio
4
+ import time
5
+ import random
6
+ import hashlib
7
+ import uuid
8
+ from typing import Optional, Tuple
9
+ from urllib.parse import quote_plus, quote
10
+
11
+ from pyrogram import Client, enums
12
+ from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, User, CallbackQuery, LinkPreviewOptions
13
+ from pyrogram.enums import ChatMemberStatus
14
+ from pyrogram.raw.functions.messages import DeleteHistory
15
+ from pyrogram.errors import FloodWait, UserNotParticipant, PeerIdInvalid, ChannelPrivate, ChatAdminRequired, BadRequest
16
+
17
+ from Thunder.bot import StreamBot
18
+ from Thunder.vars import Var
19
+ from Thunder.utils.database import db
20
+ from Thunder.utils.human_readable import humanbytes
21
+ from Thunder.utils.logger import logger
22
+ from Thunder.utils.messages import (
23
+ MSG_BUTTON_GET_HELP,
24
+ MSG_NEW_USER,
25
+ MSG_DC_UNKNOWN,
26
+ MSG_DC_USER_INFO,
27
+ MSG_LINKS,
28
+ MSG_BUTTON_STREAM_NOW,
29
+ MSG_BUTTON_DOWNLOAD,
30
+ MSG_ERROR_GENERIC_CALLBACK
31
+ )
32
+ from Thunder.utils.file_properties import get_name, get_media_file_size, get_hash
33
+ from Thunder.utils.shortener import shorten
34
+
35
+
36
+ async def notify_channel(bot: Client, text: str):
37
+ try:
38
+ if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
39
+ await bot.send_message(chat_id=Var.BIN_CHANNEL, text=text)
40
+ except Exception:
41
+ pass
42
+
43
+ async def notify_owner(client: Client, text: str):
44
+ try:
45
+ owner_ids = Var.OWNER_ID
46
+ if isinstance(owner_ids, (list, tuple, set)):
47
+ tasks = [
48
+ client.send_message(chat_id=owner_id, text=text)
49
+ for owner_id in owner_ids
50
+ ]
51
+ results = await asyncio.gather(*tasks)
52
+ await asyncio.sleep(0.1)
53
+ else:
54
+ await client.send_message(chat_id=owner_ids, text=text)
55
+ await asyncio.sleep(0.1)
56
+
57
+ if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
58
+ await client.send_message(chat_id=Var.BIN_CHANNEL, text=text)
59
+ await asyncio.sleep(0.1)
60
+ except Exception as e:
61
+ logger.error(f"Failed to send message to owner: {e}")
62
+
63
+ async def handle_user_error(message: Message, error_msg: str):
64
+ try:
65
+ await message.reply_text(
66
+ error_msg,
67
+ quote=True,
68
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
69
+ reply_markup=InlineKeyboardMarkup([
70
+ [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]
71
+ ])
72
+ )
73
+ except Exception:
74
+ pass
75
+
76
+ async def log_new_user(bot: Client, user_id: int, first_name: str):
77
+ try:
78
+ if not await db.is_user_exist(user_id):
79
+ await db.add_user(user_id)
80
+ if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
81
+ await bot.send_message(
82
+ Var.BIN_CHANNEL,
83
+ MSG_NEW_USER.format(
84
+ first_name=first_name,
85
+ user_id=user_id
86
+ )
87
+ )
88
+ except Exception:
89
+ pass
90
+
91
+ async def generate_media_links(log_msg: Message, shortener: bool = True) -> Tuple[str, str, str, str]:
92
+ try:
93
+ base_url = Var.URL.rstrip("/")
94
+
95
+ file_id = log_msg.id
96
+
97
+ media_name = get_name(log_msg)
98
+ if isinstance(media_name, bytes):
99
+ media_name = media_name.decode('utf-8', errors='replace')
100
+ else:
101
+ media_name = str(media_name)
102
+
103
+ media_size = humanbytes(get_media_file_size(log_msg))
104
+ file_name_encoded = quote(media_name)
105
+ hash_value = get_hash(log_msg)
106
+
107
+ stream_link_raw = f"{base_url}/watch/{hash_value}{file_id}/{file_name_encoded}"
108
+ online_link_raw = f"{base_url}/{hash_value}{file_id}/{file_name_encoded}"
109
+
110
+ stream_link = stream_link_raw
111
+ online_link = online_link_raw
112
+
113
+ if shortener and getattr(Var, "SHORTEN_MEDIA_LINKS", False):
114
+ stream_link = await shorten(stream_link_raw)
115
+ online_link = await shorten(online_link_raw)
116
+
117
+ return stream_link, online_link, media_name, media_size
118
+ except Exception as e:
119
+ logger.error(f"Error generating media links: {e}")
120
+ return "", "", "", ""
121
+
122
+ async def send_links_to_user(client: Client, command_message: Message, media_name: str, media_size: str, stream_link: str, online_link: str):
123
+ msg_text = MSG_LINKS.format(
124
+ file_name=media_name,
125
+ file_size=media_size,
126
+ download_link=online_link,
127
+ stream_link=stream_link
128
+ )
129
+
130
+ try:
131
+ await command_message.reply_text(
132
+ msg_text,
133
+ quote=True,
134
+ link_preview_options=LinkPreviewOptions(is_disabled=True),
135
+ parse_mode=enums.ParseMode.MARKDOWN,
136
+ reply_markup=InlineKeyboardMarkup([
137
+ [
138
+ InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
139
+ InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=online_link)
140
+ ]
141
+ ]),
142
+ )
143
+ await asyncio.sleep(0.3)
144
+ except Exception as e:
145
+ logger.error(f"Error sending links to user: {e}")
146
+ raise
147
+
148
+ async def generate_dc_text(user: User) -> str:
149
+ dc_id = user.dc_id if user.dc_id is not None else MSG_DC_UNKNOWN
150
+ return MSG_DC_USER_INFO.format(
151
+ user_name=user.first_name or 'User',
152
+ user_id=user.id,
153
+ dc_id=dc_id
154
+ )
155
+
156
+ async def get_user_safely(bot: Client, query) -> Optional[User]:
157
+ try:
158
+ if isinstance(query, str):
159
+ if query.startswith('@'):
160
+ return await bot.get_users(query)
161
+ elif query.isdigit():
162
+ return await bot.get_users(int(query))
163
+ elif isinstance(query, int):
164
+ return await bot.get_users(query)
165
+ return None
166
+ except Exception:
167
+ return None
168
+
169
+ async def check_admin_privileges(client: Client, chat_id: int) -> bool:
170
+ try:
171
+ member = await client.get_chat_member(chat_id, client.me.id)
172
+ return member.status in [
173
+ ChatMemberStatus.ADMINISTRATOR,
174
+ ChatMemberStatus.OWNER
175
+ ]
176
+ except Exception:
177
+ return False
178
+
179
+ async def _execute_command_from_callback(client: Client, callback_query: CallbackQuery, command_name: str, command_func):
180
+ try:
181
+ await callback_query.answer()
182
+ mock_message = callback_query.message
183
+ mock_message.from_user = callback_query.from_user
184
+ mock_message.chat = callback_query.message.chat
185
+ mock_message.text = f"/{command_name}"
186
+ mock_message.command = [command_name]
187
+ await command_func(client, mock_message)
188
+ logger.info(f"User {callback_query.from_user.id} used {command_name} via callback")
189
+ except Exception as e:
190
+ logger.error(f"Error in {command_name}_callback: {e}")
191
+ await callback_query.answer(MSG_ERROR_GENERIC_CALLBACK.format(error_id=uuid.uuid4().hex[:8]), show_alert=True)
Thunder/utils/broadcast_helper.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thunder/utils/broadcast_helper.py
2
+
3
+ import asyncio
4
+ import traceback
5
+ from typing import Tuple
6
+
7
+ from pyrogram.errors import FloodWait, InputUserDeactivated, UserIsBlocked, PeerIdInvalid
8
+ from pyrogram.types import Message
9
+ from Thunder.utils.logger import logger
10
+
11
+
12
+ async def send_msg(user_id: int, message: Message) -> Tuple[int, str]:
13
+ # Attempt to forward a message to a user and handle exceptions
14
+ try:
15
+ await message.forward(chat_id=user_id)
16
+ return 200, None # Success code
17
+
18
+ except FloodWait as e:
19
+ logger.warning(f"FloodWait error: sleeping for {e.value} seconds.")
20
+ await asyncio.sleep(e.value + 1)
21
+ return await send_msg(user_id, message) # Retry after wait
22
+
23
+ except InputUserDeactivated:
24
+ error_msg = f"{user_id} : deactivated"
25
+ logger.error(error_msg)
26
+ return 400, error_msg
27
+
28
+ except UserIsBlocked:
29
+ error_msg = f"{user_id} : blocked the bot"
30
+ logger.error(error_msg)
31
+ return 400, error_msg
32
+
33
+ except PeerIdInvalid:
34
+ error_msg = f"{user_id} : user ID invalid"
35
+ logger.error(error_msg)
36
+ return 400, error_msg
37
+
38
+ except Exception as e:
39
+ error_msg = f"{user_id} : {traceback.format_exc()}"
40
+ logger.error(f"Unexpected error: {error_msg}", exc_info=True)
41
+ return 500, error_msg
Thunder/utils/config_parser.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thunder/utils/config_parser.py
2
+
3
+ import os
4
+ from typing import Dict, Optional
5
+ from Thunder.utils.logger import logger
6
+
7
+
8
+ class TokenParser:
9
+ # Class to parse multiple bot tokens from environment variables
10
+
11
+ def __init__(self, config_file: Optional[str] = None):
12
+ # Initialize the TokenParser
13
+ self.tokens: Dict[int, str] = {}
14
+ self.config_file = config_file
15
+
16
+ def parse_from_env(self) -> Dict[int, str]:
17
+ # Parse bot tokens from environment variables
18
+ # MULTI_TOKEN1=token_1, MULTI_TOKEN2=token_2, etc.
19
+
20
+ # Filter environment variables that start with "MULTI_TOKEN"
21
+ multi_tokens = {
22
+ key: value for key, value in os.environ.items()
23
+ if key.startswith("MULTI_TOKEN") and value.strip()
24
+ }
25
+
26
+ if not multi_tokens:
27
+ logger.info("No MULTI_TOKEN environment variables found. Running with primary bot only.")
28
+ return {}
29
+
30
+ # Extract numeric part and sort tokens
31
+ sorted_tokens = sorted(
32
+ multi_tokens.items(),
33
+ key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0)
34
+ )
35
+
36
+ # Map to a dictionary with integer keys starting at 1
37
+ self.tokens = {
38
+ index + 1: token for index, (_, token) in enumerate(sorted_tokens) if token.strip()
39
+ }
40
+
41
+ if not self.tokens:
42
+ logger.info("No valid MULTI_TOKEN environment variables found. Running with primary bot only.")
43
+ return {}
44
+
45
+ return self.tokens
Thunder/utils/custom_dl.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thunder/utils/custom_dl.py
2
+
3
+ import math
4
+ import asyncio
5
+ import time
6
+ from typing import Dict, Union, Optional, AsyncGenerator
7
+ from pyrogram import Client, utils, raw
8
+ from pyrogram.session import Session, Auth
9
+ from pyrogram.errors import AuthBytesInvalid, RPCError, FloodWait, FileReferenceExpired, FileReferenceInvalid
10
+ from pyrogram.file_id import FileId, FileType, ThumbnailSource
11
+ from Thunder.vars import Var
12
+ from Thunder.bot import work_loads
13
+ from Thunder.server.exceptions import FileNotFound
14
+ from .file_properties import get_file_ids
15
+ from Thunder.utils.logger import logger
16
+
17
+ class ByteStreamer:
18
+ # A custom class that handles streaming of media files from Telegram servers
19
+
20
+ def __init__(self, client: Client):
21
+ self.client = client
22
+ self.clean_timer = 30 * 60 # Cache clean interval in seconds (30 minutes)
23
+ self.cached_file_ids: Dict[int, FileId] = {}
24
+ self.file_references_cache: Dict[int, Dict[str, Union[FileId, float]]] = {}
25
+ self.cache_lock = asyncio.Lock()
26
+ self.media_sessions_lock = asyncio.Lock()
27
+
28
+ # Start cache cleaning tasks
29
+ self.cache_cleaner_task = asyncio.create_task(self.clean_cache())
30
+ self.session_cleaner_task = asyncio.create_task(self.cleanup_media_sessions())
31
+
32
+ async def shutdown(self):
33
+ if hasattr(self, 'cache_cleaner_task'):
34
+ self.cache_cleaner_task.cancel()
35
+ try:
36
+ await self.cache_cleaner_task
37
+ except asyncio.CancelledError:
38
+ pass
39
+
40
+ if hasattr(self, 'session_cleaner_task'):
41
+ self.session_cleaner_task.cancel()
42
+ try:
43
+ await self.session_cleaner_task
44
+ except asyncio.CancelledError:
45
+ pass
46
+
47
+ # Clean up all media sessions
48
+ for dc_id, session in list(self.client.media_sessions.items()):
49
+ try:
50
+ await session.stop()
51
+ except Exception as e:
52
+ logger.debug(f"Error stopping media session for DC {dc_id}: {e}")
53
+
54
+ self.client.media_sessions.clear()
55
+
56
+ async def get_file_properties(self, message_id: int) -> FileId:
57
+ async with self.cache_lock:
58
+ file_id = self.cached_file_ids.get(message_id)
59
+
60
+ if not file_id:
61
+ file_id = await self.generate_file_properties(message_id)
62
+ async with self.cache_lock:
63
+ self.cached_file_ids[message_id] = file_id
64
+
65
+ return file_id
66
+
67
+ async def generate_file_properties(self, message_id: int) -> FileId:
68
+ file_id = await get_file_ids(self.client, Var.BIN_CHANNEL, message_id)
69
+
70
+ if not file_id:
71
+ logger.debug(f"Message ID {message_id} not found in the channel.")
72
+ raise FileNotFound(f"File with message ID {message_id} not found.")
73
+
74
+ async with self.cache_lock:
75
+ self.cached_file_ids[message_id] = file_id
76
+
77
+ return file_id
78
+
79
+ async def refresh_file_reference(self, file_id: FileId) -> Optional[FileId]:
80
+ logger.debug(f"Refreshing file reference for message ID {file_id.message_id}")
81
+
82
+ # Check if we have a fresh cached reference
83
+ async with self.cache_lock:
84
+ cached_ref = self.file_references_cache.get(file_id.message_id)
85
+ if cached_ref and time.time() - cached_ref["timestamp"] < 3600: # Less than 1 hour old
86
+ return cached_ref["file_id"]
87
+
88
+ try:
89
+ # Fetch the message again to get fresh file reference
90
+ message = await self.client.get_messages(
91
+ chat_id=Var.BIN_CHANNEL,
92
+ message_ids=file_id.message_id
93
+ )
94
+
95
+ if not message or not message.media:
96
+ logger.debug(f"Media message {file_id.message_id} not found when refreshing file reference")
97
+ return None
98
+
99
+ # Extract new file ID
100
+ new_file_id = await get_file_ids(self.client, Var.BIN_CHANNEL, file_id.message_id)
101
+ if not new_file_id:
102
+ logger.error(f"Failed to extract file ID from message {file_id.message_id}")
103
+ return None
104
+
105
+ # Cache the new file reference
106
+ async with self.cache_lock:
107
+ self.file_references_cache[file_id.message_id] = {
108
+ "file_id": new_file_id,
109
+ "timestamp": time.time()
110
+ }
111
+
112
+ return new_file_id
113
+ except Exception as e:
114
+ logger.error(f"Error refreshing file reference: {e}")
115
+ return None
116
+
117
+ async def generate_media_session(self, client: Client, file_id: FileId) -> Session:
118
+ async with self.media_sessions_lock:
119
+ media_session = client.media_sessions.get(file_id.dc_id, None)
120
+
121
+ if media_session is None:
122
+ if file_id.dc_id != await client.storage.dc_id():
123
+ media_session = Session(
124
+ client,
125
+ file_id.dc_id,
126
+ await Auth(
127
+ client, file_id.dc_id, await client.storage.test_mode()
128
+ ).create(),
129
+ await client.storage.test_mode(),
130
+ is_media=True,
131
+ )
132
+ await media_session.start()
133
+
134
+ for attempt in range(6):
135
+ if attempt > 0:
136
+ # Add exponential backoff between attempts
137
+ await asyncio.sleep(1 * (2 ** (attempt - 1)))
138
+
139
+ try:
140
+ exported_auth = await client.invoke(
141
+ raw.functions.auth.ExportAuthorization(dc_id=file_id.dc_id)
142
+ )
143
+
144
+ try:
145
+ await media_session.send(
146
+ raw.functions.auth.ImportAuthorization(
147
+ id=exported_auth.id, bytes=exported_auth.bytes
148
+ )
149
+ )
150
+ break
151
+ except AuthBytesInvalid:
152
+ continue
153
+ except FloodWait as e:
154
+ logger.warning(f"FloodWait during auth export: {e.value} seconds")
155
+ await asyncio.sleep(e.value + 1)
156
+ continue
157
+ else:
158
+ await media_session.stop()
159
+ raise AuthBytesInvalid
160
+ else:
161
+ media_session = Session(
162
+ client,
163
+ file_id.dc_id,
164
+ await client.storage.auth_key(),
165
+ await client.storage.test_mode(),
166
+ is_media=True,
167
+ )
168
+ await media_session.start()
169
+
170
+ # Mark the session creation time for cleanup
171
+ setattr(media_session, 'last_used', time.time())
172
+ client.media_sessions[file_id.dc_id] = media_session
173
+ else:
174
+ # Update last used time
175
+ setattr(media_session, 'last_used', time.time())
176
+
177
+ return media_session
178
+
179
+ @staticmethod
180
+ async def get_location(file_id: FileId) -> Union[
181
+ raw.types.InputPhotoFileLocation,
182
+ raw.types.InputDocumentFileLocation,
183
+ raw.types.InputPeerPhotoFileLocation,
184
+ None
185
+ ]:
186
+ file_type = file_id.file_type
187
+
188
+ if file_type == FileType.CHAT_PHOTO:
189
+ if file_id.chat_id > 0:
190
+ peer = raw.types.InputPeerUser(
191
+ user_id=file_id.chat_id, access_hash=file_id.chat_access_hash
192
+ )
193
+ else:
194
+ if file_id.chat_access_hash == 0:
195
+ peer = raw.types.InputPeerChat(chat_id=-file_id.chat_id)
196
+ else:
197
+ peer = raw.types.InputPeerChannel(
198
+ channel_id=utils.get_channel_id(file_id.chat_id),
199
+ access_hash=file_id.chat_access_hash,
200
+ )
201
+ location = raw.types.InputPeerPhotoFileLocation(
202
+ peer=peer,
203
+ volume_id=file_id.volume_id,
204
+ local_id=file_id.local_id,
205
+ big=file_id.thumbnail_source == ThumbnailSource.CHAT_PHOTO_BIG,
206
+ )
207
+ elif file_type == FileType.PHOTO:
208
+ if not file_id.file_reference:
209
+ logger.debug("Missing file_reference for PHOTO type")
210
+ return None
211
+ location = raw.types.InputPhotoFileLocation(
212
+ id=file_id.media_id,
213
+ access_hash=file_id.access_hash,
214
+ file_reference=file_id.file_reference,
215
+ thumb_size=file_id.thumbnail_size,
216
+ )
217
+ else:
218
+ if not file_id.file_reference:
219
+ logger.debug("Missing file_reference for DOCUMENT type")
220
+ return None
221
+ location = raw.types.InputDocumentFileLocation(
222
+ id=file_id.media_id,
223
+ access_hash=file_id.access_hash,
224
+ file_reference=file_id.file_reference,
225
+ thumb_size=file_id.thumbnail_size,
226
+ )
227
+ return location
228
+
229
+ async def yield_file(
230
+ self,
231
+ file_id: FileId,
232
+ index: int,
233
+ offset: int,
234
+ first_part_cut: int,
235
+ last_part_cut: int,
236
+ part_count: int,
237
+ chunk_size: int,
238
+ ) -> AsyncGenerator[bytes, None]:
239
+ client = self.client
240
+ work_loads[index] += 1
241
+
242
+ try:
243
+ media_session = await self.generate_media_session(client, file_id)
244
+ current_part = 1
245
+
246
+ # Get file location
247
+ location = await self.get_location(file_id)
248
+
249
+ # Handle missing file reference
250
+ if location is None:
251
+ logger.warning(f"Missing file reference for {file_id.message_id}, attempting to refresh")
252
+ refreshed_file_id = await self.refresh_file_reference(file_id)
253
+ if refreshed_file_id:
254
+ location = await self.get_location(refreshed_file_id)
255
+ file_id = refreshed_file_id
256
+
257
+ if location is None:
258
+ logger.error(f"Failed to get location for {file_id.message_id} even after refreshing file reference")
259
+ raise FileNotFound(f"File location not found after refresh for {file_id.message_id}")
260
+
261
+ max_retries = 5
262
+
263
+ while current_part <= part_count:
264
+ try:
265
+ r = await media_session.send(
266
+ raw.functions.upload.GetFile(
267
+ location=location, offset=offset, limit=chunk_size
268
+ ),
269
+ timeout=Var.TIMEOUT
270
+ )
271
+ except FloodWait as e:
272
+ logger.warning(f"FloodWait encountered: waiting for {e.value} seconds.")
273
+ await asyncio.sleep(e.value + 1)
274
+ continue
275
+ except (FileReferenceExpired, FileReferenceInvalid) as e:
276
+ logger.warning(f"File reference for {file_id.message_id} expired/invalid: {e}. Attempting refresh.")
277
+ refreshed_file_id = await self.refresh_file_reference(file_id)
278
+ if refreshed_file_id:
279
+ file_id = refreshed_file_id # Update file_id
280
+ location = await self.get_location(file_id)
281
+ if location is None:
282
+ logger.error(f"Failed to get location for {file_id.message_id} after file reference refresh.")
283
+ raise FileNotFoundError(f"File location not found after refresh for {file_id.message_id}")
284
+ logger.info(f"Successfully refreshed file reference for {file_id.message_id} and got new location.")
285
+ retry_count = 0
286
+ continue
287
+ else:
288
+ logger.error(f"Failed to refresh file reference for {file_id.message_id}.")
289
+ raise FileNotFoundError(f"Failed to refresh file reference for {file_id.message_id}")
290
+ except asyncio.TimeoutError as e: # Specific handling for asyncio.TimeoutError
291
+ retry_count += 1
292
+ logger.warning(f"TimeoutError during file fetch for {file_id.message_id} (attempt {retry_count}/{max_retries}): {e}")
293
+ if retry_count >= max_retries:
294
+ logger.error(f"Max retries reached for TimeoutError on {file_id.message_id}: {e}")
295
+ raise TimeoutError(f"Max retries for TimeoutError on {file_id.message_id} after {max_retries} attempts.")
296
+ await asyncio.sleep(1 * (2 ** (retry_count - 1)))
297
+ continue
298
+ except RPCError as e:
299
+ retry_count += 1
300
+ logger.warning(f"RPC Error during file fetch for {file_id.message_id} (attempt {retry_count}/{max_retries}): {e}")
301
+ if retry_count >= max_retries:
302
+ logger.error(f"Max retries reached for RPCError on {file_id.message_id}: {e}")
303
+ raise RPCError(f"Max retries for RPCError on {file_id.message_id}: {e}")
304
+ await asyncio.sleep(1 * (2 ** (retry_count - 1)))
305
+ continue
306
+ except ConnectionError as e:
307
+ retry_count += 1
308
+ logger.warning(f"Connection error during file fetch for {file_id.message_id} (attempt {retry_count}/{max_retries}): {e}")
309
+ if retry_count >= max_retries:
310
+ logger.error(f"Max retries reached for ConnectionError on {file_id.message_id}: {e}")
311
+ raise ConnectionError(f"Max retries for ConnectionError on {file_id.message_id}: {e}")
312
+ await asyncio.sleep(1) # Brief pause before retry
313
+ continue
314
+ except Exception as e:
315
+ logger.error(f"Unexpected error during file fetch for {file_id.message_id}: {str(e)}", exc_info=True)
316
+ raise
317
+
318
+ if isinstance(r, raw.types.upload.File):
319
+ chunk = r.bytes
320
+ if not chunk:
321
+ break
322
+ elif part_count == 1:
323
+ yield chunk[first_part_cut:last_part_cut]
324
+ elif current_part == 1:
325
+ yield chunk[first_part_cut:]
326
+ elif current_part == part_count:
327
+ yield chunk[:last_part_cut]
328
+ else:
329
+ yield chunk
330
+
331
+ current_part += 1
332
+ offset += chunk_size
333
+ retry_count = 0 # Reset retry count after success
334
+
335
+ if current_part > part_count:
336
+ break
337
+ except (AttributeError) as e:
338
+ logger.error(f"Error while yielding file for {file_id.message_id if 'file_id' in locals() else 'unknown file'} (AttributeError): {e}", exc_info=True)
339
+ raise
340
+ finally:
341
+ work_loads[index] -= 1
342
+
343
+ async def clean_cache(self) -> None:
344
+ while True:
345
+ try:
346
+ await asyncio.sleep(self.clean_timer)
347
+
348
+ async with self.cache_lock:
349
+ # Instead of clearing everything, we could selectively clean
350
+ # based on timestamps if we tracked them
351
+ self.cached_file_ids.clear()
352
+
353
+ # Clean file reference cache based on age
354
+ now = time.time()
355
+ expired_refs = [
356
+ key for key, value in self.file_references_cache.items()
357
+ if now - value["timestamp"] > 3600 # 1 hour expiry
358
+ ]
359
+ for key in expired_refs:
360
+ self.file_references_cache.pop(key, None)
361
+
362
+ if expired_refs:
363
+ logger.debug(f"Cache cleaned. Removed {len(expired_refs)} expired file references.")
364
+ except asyncio.CancelledError:
365
+ break
366
+ except Exception as e:
367
+ logger.error(f"Error in cache cleaning task: {e}")
368
+ await asyncio.sleep(60) # Wait before retrying
369
+
370
+ async def cleanup_media_sessions(self) -> None:
371
+ while True:
372
+ try:
373
+ await asyncio.sleep(1800) # 30 minutes
374
+
375
+ async with self.media_sessions_lock:
376
+ now = time.time()
377
+ sessions_to_remove = []
378
+
379
+ for dc_id, session in list(self.client.media_sessions.items()):
380
+ if hasattr(session, 'last_used') and now - session.last_used > 1800: # 30 minutes
381
+ sessions_to_remove.append(dc_id)
382
+
383
+ for dc_id in sessions_to_remove:
384
+ try:
385
+ await self.client.media_sessions[dc_id].stop()
386
+ self.client.media_sessions.pop(dc_id, None)
387
+ except Exception as e:
388
+ logger.error(f"Error stopping media session for DC {dc_id}: {e}")
389
+
390
+ if sessions_to_remove:
391
+ logger.debug(f"Cleaned up {len(sessions_to_remove)} inactive media sessions")
392
+ except asyncio.CancelledError:
393
+ break
394
+ except Exception as e:
395
+ logger.error(f"Error in media session cleaning task: {e}")
396
+ await asyncio.sleep(60) # Wait before retrying
Thunder/utils/database.py ADDED
@@ -0,0 +1,228 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database utilities for managing users, bans, and tokens via MongoDB."""
2
+ import datetime
3
+ from typing import Optional, Dict, Any
4
+ from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
5
+ from Thunder.utils.logger import logger
6
+ from Thunder.vars import Var
7
+
8
+ class Database:
9
+ # Database class for handling user data using MongoDB
10
+
11
+ def __init__(self, uri: str, database_name: str, *args, **kwargs):
12
+ # Initialize database connection
13
+ self._client = AsyncIOMotorClient(uri, *args, **kwargs)
14
+ self.db = self._client[database_name]
15
+ self.col: AsyncIOMotorCollection = self.db.users
16
+ self.banned_users_col: AsyncIOMotorCollection = self.db.banned_users
17
+ self.token_col: AsyncIOMotorCollection = self.db.tokens
18
+ self.authorized_users_col: AsyncIOMotorCollection = self.db.authorized_users
19
+ self.restart_message_col: AsyncIOMotorCollection = self.db.restart_message
20
+
21
+ async def ensure_indexes(self):
22
+ # Ensure necessary indexes are created
23
+ await self.banned_users_col.create_index("user_id", unique=True)
24
+ await self.token_col.create_index("token", unique=True)
25
+ await self.authorized_users_col.create_index("user_id", unique=True)
26
+ # Add unique index on users.id
27
+ await self.col.create_index("id", unique=True)
28
+
29
+ # TTL index to auto-remove expired tokens
30
+ await self.token_col.create_index("expires_at", expireAfterSeconds=0)
31
+ await self.restart_message_col.create_index("message_id", unique=True)
32
+ # TTL index to auto-remove expired restart messages
33
+ await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600)
34
+
35
+ def new_user(self, user_id: int) -> dict:
36
+ # Create new user document
37
+ return {
38
+ 'id': user_id,
39
+ 'join_date': datetime.datetime.utcnow()
40
+ }
41
+
42
+ async def add_user(self, user_id: int):
43
+ # Add new user if not exists
44
+ if not await self.is_user_exist(user_id):
45
+ user = self.new_user(user_id)
46
+ await self.col.insert_one(user)
47
+
48
+ async def add_user_pass(self, user_id: int, ag_pass: str):
49
+ # Add or update user password
50
+ await self.add_user(user_id)
51
+ await self.col.update_one({'id': user_id}, {'$set': {'ag_p': ag_pass}})
52
+
53
+ async def get_user_pass(self, user_id: int) -> Optional[str]:
54
+ # Get user password
55
+ user_pass = await self.col.find_one({'id': user_id}, {'ag_p': 1})
56
+ return user_pass.get('ag_p') if user_pass else None
57
+
58
+ async def is_user_exist(self, user_id: int) -> bool:
59
+ # Check if user exists
60
+ user = await self.col.find_one({'id': user_id}, {'_id': 1})
61
+ return bool(user)
62
+
63
+ async def total_users_count(self) -> int:
64
+ # Count total users
65
+ return await self.col.count_documents({})
66
+
67
+ async def get_all_users(self):
68
+ # Get all users (returns cursor)
69
+ return self.col.find({})
70
+
71
+ async def delete_user(self, user_id: int):
72
+ # Delete user
73
+ await self.col.delete_one({'id': user_id})
74
+
75
+ async def create_index(self):
76
+ # Create index for faster queries
77
+ await self.col.create_index("id", unique=True)
78
+
79
+ async def get_active_users(self, days: int = 7):
80
+ # Get users active within specified days
81
+ cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=days)
82
+ return self.col.find({'join_date': {'$gte': cutoff}})
83
+
84
+ async def add_banned_user(
85
+ self, user_id: int, banned_by: Optional[int] = None,
86
+ reason: Optional[str] = None, ban_time: Optional[str] = None
87
+ ):
88
+ # Add or update banned user with upsert
89
+ ban_data = {
90
+ "user_id": user_id,
91
+ "banned_at": datetime.datetime.utcnow(),
92
+ "banned_by": banned_by,
93
+ "reason": reason
94
+ }
95
+ try:
96
+ await self.banned_users_col.update_one(
97
+ {"user_id": user_id},
98
+ {"$set": ban_data},
99
+ upsert=True
100
+ )
101
+ except Exception as e:
102
+ logger.error(
103
+ f"Database error in add_banned_user for user {user_id}: {e}"
104
+ )
105
+ raise RuntimeError(f"Failed to ban user {user_id}: {e}")
106
+
107
+ async def remove_banned_user(self, user_id: int) -> bool:
108
+ # Remove banned user and return if document was deleted
109
+ try:
110
+ result = await self.banned_users_col.delete_one(
111
+ {"user_id": user_id}
112
+ )
113
+ return result.deleted_count > 0
114
+ except Exception as e:
115
+ logger.error(
116
+ f"Database error in remove_banned_user for user {user_id}: {e}"
117
+ )
118
+ return False
119
+
120
+ async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
121
+ # Check if user is banned and return ban details
122
+ try:
123
+ return await self.banned_users_col.find_one({"user_id": user_id})
124
+ except Exception as e:
125
+ logger.error(
126
+ f"Database error in is_user_banned for user {user_id}: {e}"
127
+ )
128
+ return None
129
+
130
+ async def check_user_token(self, user_id: int) -> Dict[str, bool]:
131
+ """Check if user has valid token."""
132
+ try:
133
+ # Only return active (not expired) tokens
134
+ token_data = await self.token_col.find_one({
135
+ "user_id": user_id,
136
+ "expires_at": {"$gt": datetime.datetime.utcnow()}
137
+ })
138
+ return {"has_token": bool(token_data)}
139
+ except Exception as e:
140
+ logger.error(f"Database error checking token for {user_id}: {e}")
141
+ return {"has_token": False}
142
+
143
+ async def update_user_token(self, user_id: int, token: str) -> None:
144
+ """Update user's active token in database."""
145
+ try:
146
+ # Find the token data first
147
+ token_data = await self.token_col.find_one({"token": token})
148
+
149
+ if token_data:
150
+ # Link token to user and refresh expiration
151
+ await self.token_col.update_one(
152
+ {"token": token},
153
+ {"$set": {
154
+ "user_id": user_id,
155
+ "expires_at": datetime.datetime.utcnow() + datetime.timedelta(hours=Var.TOKEN_TTL_HOURS)
156
+ }}
157
+ )
158
+ else:
159
+ # If no token data found, create a basic entry (this is a fallback)
160
+ logger.warning(f"Token {token} not found in database when assigning to user {user_id}")
161
+ await self.token_col.insert_one({
162
+ "user_id": user_id,
163
+ "token": token,
164
+ "created_at": datetime.datetime.utcnow(),
165
+ "expires_at": datetime.datetime.utcnow() + datetime.timedelta(hours=Var.TOKEN_TTL_HOURS)
166
+ })
167
+ except Exception as e:
168
+ logger.error(f"Database error updating token for {user_id}: {e}")
169
+ raise RuntimeError(f"Failed to update token: {e}")
170
+
171
+ async def save_broadcast_state(self, broadcast_id, state_data):
172
+ broadcast_collection = self.db.broadcasts
173
+ await broadcast_collection.update_one(
174
+ {"_id": broadcast_id},
175
+ {"$set": state_data},
176
+ upsert=True
177
+ )
178
+
179
+ async def get_broadcast_state(self, broadcast_id):
180
+ broadcast_collection = self.db.broadcasts
181
+ state = await broadcast_collection.find_one({"_id": broadcast_id})
182
+ return state
183
+
184
+ async def list_active_broadcasts(self):
185
+ broadcast_collection = self.db.broadcasts
186
+ cursor = broadcast_collection.find({"is_cancelled": False})
187
+ return await cursor.to_list(length=100)
188
+
189
+ async def add_restart_message(self, message_id: int, chat_id: int) -> None:
190
+ """Store the restart message ID and chat ID."""
191
+ try:
192
+ await self.restart_message_col.insert_one(
193
+ {"message_id": message_id, "chat_id": chat_id, "timestamp": datetime.datetime.utcnow()}
194
+ )
195
+ logger.debug(f"Restart message {message_id} in chat {chat_id} saved to DB.")
196
+ except Exception as e:
197
+ logger.error(f"Database error saving restart message: {e}")
198
+
199
+ async def get_restart_message(self) -> Optional[Dict[str, Any]]:
200
+ """Retrieve the restart message from the database."""
201
+ try:
202
+ # Get the most recent restart message
203
+ restart_msg = await self.restart_message_col.find_one(sort=[("timestamp", -1)])
204
+ if restart_msg:
205
+ logger.debug(f"Retrieved restart message {restart_msg['message_id']} from DB.")
206
+ return restart_msg
207
+ except Exception as e:
208
+ logger.error(f"Database error retrieving restart message: {e}")
209
+ return None
210
+
211
+ async def delete_restart_message(self, message_id: int) -> None:
212
+ """Delete the restart message from the database."""
213
+ try:
214
+ result = await self.restart_message_col.delete_one({"message_id": message_id})
215
+ if result.deleted_count > 0:
216
+ logger.debug(f"Restart message {message_id} deleted from DB.")
217
+ else:
218
+ logger.debug(f"Attempted to delete non-existent restart message {message_id}.")
219
+ except Exception as e:
220
+ logger.error(f"Database error deleting restart message: {e}")
221
+
222
+ async def close(self):
223
+ # Close database connection
224
+ if self._client:
225
+ self._client.close()
226
+
227
+ # Initialize the Database instance
228
+ db = Database(Var.DATABASE_URL, Var.NAME)
Thunder/utils/decorators.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import wraps
2
+ from pyrogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
3
+ from Thunder.vars import Var
4
+ from Thunder.utils.messages import (
5
+ MSG_DECORATOR_BANNED,
6
+ MSG_TOKEN_INVALID,
7
+ MSG_ERROR_UNAUTHORIZED
8
+ )
9
+ from Thunder.utils.database import db
10
+ from Thunder.utils.logger import logger
11
+ from Thunder.utils.tokens import check, allowed, generate
12
+ from Thunder.utils.shortener import shorten
13
+
14
+ def _check_message_user(message):
15
+ """Helper to validate message and user."""
16
+ if not message.from_user:
17
+ return None
18
+ return message.from_user.id
19
+
20
+ def check_banned(func):
21
+ @wraps(func)
22
+ async def wrapper(client, message: Message, *args, **kwargs):
23
+ user_id = _check_message_user(message)
24
+ if not user_id:
25
+ return await func(client, message, *args, **kwargs)
26
+
27
+ if user_id in Var.OWNER_ID:
28
+ return await func(client, message, *args, **kwargs)
29
+
30
+ ban_details = await db.is_user_banned(user_id)
31
+ if ban_details:
32
+ banned_at = ban_details.get('banned_at')
33
+ if banned_at and hasattr(banned_at, 'strftime'):
34
+ ban_time = banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
35
+ else:
36
+ ban_time = str(banned_at) if banned_at else 'N/A'
37
+
38
+ ban_message = MSG_DECORATOR_BANNED.format(
39
+ reason=ban_details.get('reason', 'Not specified'),
40
+ ban_time=ban_time
41
+ )
42
+ await message.reply_text(ban_message, quote=True)
43
+ return
44
+
45
+ return await func(client, message, *args, **kwargs)
46
+ return wrapper
47
+
48
+ def require_token(func):
49
+ @wraps(func)
50
+ async def wrapper(client, message: Message, *args, **kwargs):
51
+ user_id = _check_message_user(message)
52
+ if not user_id:
53
+ return await func(client, message, *args, **kwargs)
54
+
55
+ if not getattr(Var, "TOKEN_ENABLED", False):
56
+ return await func(client, message, *args, **kwargs)
57
+
58
+ if user_id in Var.OWNER_ID:
59
+ return await func(client, message, *args, **kwargs)
60
+
61
+ if await allowed(user_id):
62
+ return await func(client, message, *args, **kwargs)
63
+
64
+ if not await check(user_id):
65
+ token_data = await generate(user_id)
66
+ me = await client.get_me()
67
+ deep_link = f"https://t.me/{me.username}?start={token_data['token']}"
68
+
69
+ try:
70
+ short_url = await shorten(deep_link)
71
+ except Exception as e:
72
+ logger.error(f"URL shortening failed: {e}")
73
+ short_url = deep_link
74
+
75
+ await message.reply_text(
76
+ MSG_TOKEN_INVALID,
77
+ reply_markup=InlineKeyboardMarkup(
78
+ [[InlineKeyboardButton("Get Token", url=short_url)]]
79
+ ),
80
+ quote=True
81
+ )
82
+ return
83
+
84
+ return await func(client, message, *args, **kwargs)
85
+ return wrapper
86
+
87
+ def shorten_link(func):
88
+ @wraps(func)
89
+ async def wrapper(client, message: Message, *args, **kwargs):
90
+ user_id = None
91
+ if message.from_user:
92
+ user_id = message.from_user.id
93
+
94
+ if user_id and (user_id in Var.OWNER_ID or await allowed(user_id)):
95
+ kwargs['shortener'] = False
96
+ else:
97
+ kwargs['shortener'] = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
98
+
99
+ return await func(client, message, *args, **kwargs)
100
+ return wrapper
101
+
102
+
103
+ def get_owner_ids():
104
+ """Helper function to safely get owner IDs."""
105
+ owner_ids = Var.OWNER_ID if hasattr(Var, 'OWNER_ID') and Var.OWNER_ID is not None else []
106
+ if isinstance(owner_ids, int):
107
+ owner_ids = [owner_ids]
108
+ return owner_ids
109
+
110
+
111
+ def owner_only(func):
112
+ """Decorator to restrict access to bot owners."""
113
+ async def wrapper(client, callback_query):
114
+ owner_ids = get_owner_ids()
115
+ if callback_query.from_user.id not in owner_ids:
116
+ await callback_query.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True)
117
+ return
118
+ await func(client, callback_query)
119
+ return wrapper
Thunder/utils/file_properties.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.logger import logger
8
+ from typing import Any, Optional
9
+
10
+ def get_media_from_message(message: Message) -> Optional[Any]:
11
+ # Extract media from message using known media type attributes
12
+ media_types = ("audio", "document", "photo", "sticker",
13
+ "animation", "video", "voice", "video_note")
14
+
15
+ for attr in media_types:
16
+ if media := getattr(message, attr, None):
17
+ return media
18
+ return None
19
+
20
+ def parse_file_id(message: Message) -> Optional[FileId]:
21
+ # Decode FileId from media if available
22
+ if media := get_media_from_message(message):
23
+ return FileId.decode(media.file_id)
24
+ logger.warning(f"No media found in message: {message.id}")
25
+ return None
26
+
27
+ def parse_file_unique_id(message: Message) -> Optional[str]:
28
+ # Extract unique file ID from media if available
29
+ if media := get_media_from_message(message):
30
+ return media.file_unique_id
31
+ logger.warning("No media found for unique ID extraction")
32
+ return None
33
+
34
+ async def get_file_ids(client: Client, chat_id: int, message_id: int) -> FileId:
35
+ # Fetch and process media file details from message
36
+ try:
37
+ message = await client.get_messages(chat_id, message_id)
38
+ if not message or message.empty:
39
+ logger.debug("Message not found or empty")
40
+ raise FileNotFound("Message not found/empty")
41
+
42
+ if not (media := get_media_from_message(message)):
43
+ logger.debug("Message contains no media")
44
+ raise FileNotFound("No media in message")
45
+
46
+ file_id = parse_file_id(message)
47
+ if not file_id:
48
+ logger.debug("Failed to parse file ID")
49
+ raise FileNotFound("Invalid file ID")
50
+
51
+ # Enhance FileId object with additional metadata
52
+ file_id.file_size = getattr(media, "file_size", 0)
53
+ file_id.mime_type = getattr(media, "mime_type", "")
54
+ file_id.file_name = getattr(media, "file_name", "")
55
+ file_id.unique_id = parse_file_unique_id(message)
56
+
57
+ return file_id
58
+
59
+ except Exception as e:
60
+ logger.debug(f"File ID processing failed: {str(e)}")
61
+ raise
62
+
63
+ def get_hash(media_msg: Message) -> str:
64
+ # Generate short hash from file unique ID
65
+ if media := get_media_from_message(media_msg):
66
+ return media.file_unique_id[:6]
67
+ return ""
68
+
69
+ def get_name(media_msg: Message) -> str:
70
+ # Extract filename from media if available
71
+ return getattr(get_media_from_message(media_msg), "file_name", "")
72
+
73
+ def get_media_file_size(message: Message) -> int:
74
+ # Get file size from media content
75
+ return getattr(get_media_from_message(message), "file_size", 0)
Thunder/utils/force_channel.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Decorator to enforce channel membership before allowing command execution in Pyrogram bots.
3
+ """
4
+
5
+ from typing import Any, Callable, Coroutine
6
+ import asyncio
7
+ import uuid
8
+
9
+ from pyrogram import Client
10
+ from pyrogram.enums import ChatMemberStatus
11
+ from pyrogram.errors import UserNotParticipant, ChatAdminRequired, RightForbidden, RPCError, FloodWait
12
+ from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
13
+
14
+ from Thunder.vars import Var
15
+ from Thunder.utils.logger import logger
16
+ from Thunder.utils.messages import (
17
+ MSG_FORCE_CHANNEL_ERROR,
18
+ MSG_FORCE_CHANNEL_RPC_ERROR,
19
+ MSG_FORCE_CHANNEL_GENERIC_ERROR,
20
+ MSG_FORCE_CHANNEL_NO_LINK,
21
+ MSG_FORCE_CHANNEL_MEMBERSHIP_REQUIRED,
22
+ MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION,
23
+ MSG_FORCE_CHANNEL_ACCESS_REQUIRED
24
+ )
25
+
26
+ def force_channel_check(
27
+ func: Callable[[Client, Message], Coroutine[Any, Any, Any]]
28
+ ) -> Callable[[Client, Message], Coroutine[Any, Any, Any]]:
29
+ """Decorator to enforce channel membership before allowing command execution."""
30
+ async def wrapper(client: Client, message: Message, *args, **kwargs) -> None:
31
+ if not Var.FORCE_CHANNEL_ID:
32
+ await func(client, message, *args, **kwargs)
33
+ return
34
+
35
+ if message.chat.id == Var.FORCE_CHANNEL_ID:
36
+ await func(client, message, *args, **kwargs)
37
+ return
38
+
39
+ user_id = message.from_user.id
40
+ try:
41
+ member = await client.get_chat_member(
42
+ chat_id=Var.FORCE_CHANNEL_ID,
43
+ user_id=user_id
44
+ )
45
+ if member.status in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]:
46
+ await func(client, message, *args, **kwargs)
47
+ return
48
+ except UserNotParticipant:
49
+ logger.debug(f"[ForceChannelCheck] User {user_id} is not a participant of FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID}. Prompting to join.")
50
+ except (ChatAdminRequired, RightForbidden):
51
+ logger.error(f"[ForceChannelCheck] Bot lacks permissions in FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID}.")
52
+ await message.reply(MSG_FORCE_CHANNEL_ERROR.format(error_id=uuid.uuid4().hex[:8]))
53
+ return
54
+ except asyncio.TimeoutError:
55
+ error_id = uuid.uuid4().hex[:8]
56
+ logger.error(f"[ForceChannelCheck] TimeoutError (ID: {error_id}) for user {user_id} while checking membership in {Var.FORCE_CHANNEL_ID}.")
57
+ await message.reply(MSG_FORCE_CHANNEL_RPC_ERROR.format(error_id=error_id))
58
+ return
59
+ except RPCError as e:
60
+ logger.error(f"[ForceChannelCheck] RPCError for user {user_id} in {Var.FORCE_CHANNEL_ID}: {e}")
61
+ await message.reply(MSG_FORCE_CHANNEL_RPC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
62
+ return
63
+ except FloodWait as e:
64
+ wait_time: int = e.value
65
+ logger.debug(f"[ForceChannelCheck] Flood wait for {wait_time} seconds")
66
+ await asyncio.sleep(wait_time)
67
+ return await wrapper(client, message, *args, **kwargs)
68
+ except Exception as e:
69
+ logger.error(f"[ForceChannelCheck] Unexpected error for user {user_id} in {Var.FORCE_CHANNEL_ID}: {e}")
70
+ await message.reply(MSG_FORCE_CHANNEL_GENERIC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
71
+ return
72
+
73
+ try:
74
+ chat = await client.get_chat(Var.FORCE_CHANNEL_ID)
75
+ invite_link = f"https://t.me/{chat.username}" if chat.username else chat.invite_link
76
+ if not invite_link:
77
+ logger.warning(f"[ForceChannelCheck] No invite link for {Var.FORCE_CHANNEL_ID} ({chat.title})")
78
+ await message.reply(MSG_FORCE_CHANNEL_NO_LINK)
79
+ return
80
+
81
+ await message.reply(
82
+ MSG_FORCE_CHANNEL_MEMBERSHIP_REQUIRED.format(channel_name=chat.title, error_id=uuid.uuid4().hex[:8]),
83
+ reply_markup=InlineKeyboardMarkup([
84
+ [InlineKeyboardButton(MSG_FORCE_CHANNEL_ACCESS_REQUIRED, url=invite_link)],
85
+ ]),
86
+ )
87
+ except asyncio.TimeoutError:
88
+ error_id = uuid.uuid4().hex[:8]
89
+ logger.error(f"[ForceChannelCheck] TimeoutError (ID: {error_id}) getting chat/invite link for {Var.FORCE_CHANNEL_ID}.")
90
+ await message.reply(MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION.format(error_id=error_id))
91
+ except RPCError as e:
92
+ logger.error(f"[ForceChannelCheck] RPCError getting chat/invite link for {Var.FORCE_CHANNEL_ID}: {e}")
93
+ await message.reply(MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION.format(error_id=uuid.uuid4().hex[:8]))
94
+ except Exception as e:
95
+ logger.error(f"[ForceChannelCheck] Unexpected error getting chat/invite link for {Var.FORCE_CHANNEL_ID}: {e}")
96
+ await message.reply(MSG_FORCE_CHANNEL_GENERIC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
97
+ return wrapper
98
+
99
+
Thunder/utils/human_readable.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thunder/utils/human_readable.py
2
+
3
+ def humanbytes(size: int, decimal_places: int = 2) -> str:
4
+ # Converts bytes to human-readable format (KB, MB, GB, etc.)
5
+ if not size:
6
+ return "0 B"
7
+
8
+ power = 2 ** 10
9
+ n = 0
10
+ units = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
11
+
12
+ while size >= power and n < len(units) - 1:
13
+ size /= power
14
+ n += 1
15
+
16
+ return f"{round(size, decimal_places)} {units[n]}B"
Thunder/utils/keepalive.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+
8
+ async def ping_server():
9
+ # Periodically pings server URL to keep it alive
10
+ sleep_time = Var.PING_INTERVAL
11
+
12
+ while True:
13
+ await asyncio.sleep(sleep_time)
14
+ try:
15
+ async with aiohttp.ClientSession(
16
+ timeout=aiohttp.ClientTimeout(total=10) ) as session:
17
+ async with session.get(Var.URL) as resp:
18
+ pass
19
+ except asyncio.TimeoutError:
20
+ logger.warning("Server ping timeout")
21
+ except Exception as e:
22
+ logger.error(f"Ping error: {str(e)}")
Thunder/utils/logger.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thunder/utils/logger.py
2
+
3
+ import logging
4
+ from logging.handlers import RotatingFileHandler
5
+ import sys
6
+ import os
7
+
8
+ # Get project root and setup log directory
9
+ PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10
+ LOG_DIR = os.path.join(PROJECT_ROOT, 'logs')
11
+ os.makedirs(LOG_DIR, exist_ok=True)
12
+ LOG_FILE = os.path.join(LOG_DIR, 'bot.txt')
13
+
14
+ # Configure logger
15
+ logger = logging.getLogger('ThunderBot')
16
+ logger.setLevel(logging.INFO)
17
+ logger.propagate = False
18
+
19
+ # Setup log formatting
20
+ log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
21
+
22
+ # Configure rotating file handler (10MB max, 5 backup files)
23
+ file_handler = RotatingFileHandler(
24
+ LOG_FILE,
25
+ maxBytes=10 * 1024 * 1024,
26
+ backupCount=5
27
+ )
28
+ file_handler.setFormatter(log_formatter)
29
+ file_handler.setLevel(logging.INFO)
30
+
31
+
32
+ # Configure console output
33
+ console_handler = logging.StreamHandler(sys.stdout)
34
+ console_handler.setFormatter(log_formatter)
35
+ console_handler.setLevel(logging.INFO)
36
+
37
+
38
+ # Add handlers to our logger instance
39
+ logger.addHandler(file_handler)
40
+ logger.addHandler(console_handler)
41
+
42
+ __all__ = ['logger', 'LOG_FILE']
Thunder/utils/messages.py ADDED
@@ -0,0 +1,433 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Centralized message constants for the Thunder File to Link Bot.
3
+ All user-facing strings should be defined here to maintain consistency and ease of maintenance.
4
+ """
5
+
6
+ # =====================================================================================
7
+ # ====== ERROR MESSAGES ======
8
+ # =====================================================================================
9
+
10
+ # ------ General Errors ------
11
+ MSG_ERROR_GENERIC = "⚠️ **Oops!** Something went wrong. Please try again. If the issue persists, contact support."
12
+ MSG_ERROR_USER_INFO = "❗ **User Not Found:** Couldn't find user. Please check the ID or Username."
13
+ MSG_ERROR_INVALID_ARG = "❗ Please provide a valid Telegram user ID or username."
14
+ MSG_ERROR_RATE_LIMIT = "⏳ **Slow Down!** Too many requests. Please wait `{seconds}` seconds."
15
+ MSG_ERROR_PRIVATE_CHAT_ONLY = "⚠️ **Private Chat Only:** This command works only in a private chat with me."
16
+
17
+ # ------ User Input & Validation Errors ------
18
+ MSG_INVALID_USER_ID = "❌ **Invalid User ID:** Please provide a numeric user ID."
19
+ 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."
20
+ MSG_ERROR_REPLY_FILE = "⚠️ Please use the /link command in reply to a file."
21
+ MSG_ERROR_NO_FILE = "⚠️ The message you're replying to does not contain any file."
22
+ MSG_ERROR_INVALID_NUMBER = "⚠️ **Invalid number specified.**"
23
+ MSG_ERROR_NUMBER_RANGE = "⚠️ **Please specify a number between 1 and 100.**"
24
+ MSG_ERROR_DM_FAILED = "⚠️ I couldn't send you a Direct Message. Please start the bot first."
25
+
26
+ # ------ File & Media Errors ------
27
+ MSG_ERROR_FILE_INVALID = "🚫 **File Error:** Invalid file. It might be deleted or inaccessible."
28
+ 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."
29
+ MSG_ERROR_LINK_GENERATION = "⚠️ **Link Generation Failed!** 🔗 Unable to create links for this file. It might be inaccessible or corrupted."
30
+ MSG_ERROR_FILE_ID_EXTRACT = "⚠️ **File Error:** Could not extract file identifier from the media. Please try sending the file again."
31
+ MSG_MEDIA_ERROR = "⚠️ **Media Error:** The file appears to be empty or corrupted. Please try sending a valid file."
32
+ MSG_ERROR_PROCESSING_MEDIA = "⚠️ **Oops!** Something went wrong while processing your media. Please try again. If the issue persists, contact support."
33
+ MSG_ERROR_CHANNEL_BANNED = "🚫 **Channel Banned:** Files from this channel are blocked."
34
+ MSG_ERROR_NO_MEDIA_FOUND = "⚠️ **No Media Found:** Please send or reply to a valid media file."
35
+ MSG_FILE_ACCESS_ERROR = "⚙️ **Error Retrieving File!** Could not fetch details. File might be unavailable, ID incorrect, or deleted from storage."
36
+
37
+ # ------ Admin Action Errors (Ban, Auth, etc.) ------
38
+ MSG_BAN_ERROR = "🚨 **Ban error:** {error}"
39
+ MSG_UNBAN_ERROR = "🚨 **Unban error:** {error}"
40
+ MSG_AUTHORIZE_FAILED = (
41
+ "❌ **Authorization Failed:** "
42
+ "Could not authorize user `{user_id}`."
43
+ )
44
+ MSG_DEAUTHORIZE_FAILED = (
45
+ "❌ **Deauthorization Failed:** "
46
+ "User `{user_id}` was not authorized or an error occurred."
47
+ )
48
+ MSG_TOKEN_FAILED = (
49
+ "⚠️ **Token Activation Failed!**\n\n"
50
+ "> ❗ Reason: {reason}\n\n"
51
+ "🔑 Please check your token or contact support."
52
+ )
53
+ MSG_TOKEN_ERROR = "⚙️ **Token Activation Error:** Something went wrong. Please try again."
54
+ MSG_SHELL_ERROR = """**❌ Shell Command Error ❌**
55
+ <pre>{error}</pre>"""
56
+ MSG_SHELL_LARGE_OUTPUT = """Output is too large, sending as a file.
57
+ Error: {error}"""
58
+
59
+ # ------ System & Bot Errors ------
60
+ MSG_ERROR_NOT_ADMIN = "⚠️ **Admin Required:** I need admin privileges to work here."
61
+ MSG_DC_INVALID_USAGE = "🤔 **Invalid Usage:** Please reply to a user's message or a media file to get DC info."
62
+ MSG_DC_ANON_ERROR = "😥 **Cannot Get Your DC Info:** Unable to identify you. This command might not work for anonymous users."
63
+ MSG_DC_FILE_ERROR = "⚙️ **Error Getting File DC Info:** Could not fetch details. File might be inaccessible."
64
+ MSG_STATS_ERROR = "❌ **Stats Error:** Could not retrieve system statistics."
65
+ MSG_STATUS_ERROR = "❌ **Status Error:** Could not retrieve system status."
66
+ MSG_DB_ERROR = "❌ **Database Error:** Could not retrieve user count."
67
+ MSG_LOG_ERROR = "❌ **Log Retrieval Error:** Could not get logs\n\n> ❗ Error: `{error}`"
68
+ MSG_RESTART_FAILED = "❌ **Restart Failed:** Could not reboot the bot."
69
+ MSG_CRITICAL_ERROR = (
70
+ "🚨 **Critical Media Processing Error** 🚨\n\n"
71
+ "> ⚠️ Details:\n```\n{error}\n```\n\n"
72
+ "Please investigate immediately! (ID: {error_id})"
73
+ )
74
+
75
+ # =====================================================================================
76
+ # ====== ADMIN MESSAGES ======
77
+ # =====================================================================================
78
+
79
+ # ------ Ban/Unban ------
80
+ MSG_DECORATOR_BANNED = "You are currently banned and cannot use this bot.\nReason: {reason}\nBanned on: {ban_time}"
81
+ MSG_BAN_USAGE = "⚠️ **Usage:** /ban [user_id] [reason]"
82
+ MSG_CANNOT_BAN_OWNER = "❌ **Cannot ban an owner.**"
83
+ MSG_ADMIN_USER_BANNED = "✅ **User {user_id} has been banned."
84
+ MSG_BAN_REASON_SUFFIX = "\n📝 **Reason:** {reason}"
85
+ MSG_ADMIN_NO_BAN_REASON = "No reason provided"
86
+ MSG_USER_BANNED_NOTIFICATION = "🚫 **You have been banned from using this bot.**"
87
+ MSG_COULD_NOT_NOTIFY_USER = "⚠️ Could not notify user {user_id}: {error}"
88
+ MSG_UNBAN_USAGE = "⚠️ **Usage:** /unban <user_id>"
89
+ MSG_ADMIN_USER_UNBANNED = "✅ **User {user_id} has been unbanned."
90
+ MSG_USER_UNBANNED_NOTIFICATION = "🎉 **You have been unbanned from using this bot.**"
91
+ MSG_USER_NOT_IN_BAN_LIST = "ℹ️ **User {user_id} was not found in the ban list."
92
+
93
+ # ------ Token & Authorization ------
94
+ MSG_TOKEN_DISABLED = "🚫 **Token System Disabled:** This feature is not currently enabled."
95
+ MSG_AUTHORIZE_USAGE = "🔑 **Usage:** `/authorize <user_id>`"
96
+ MSG_DEAUTHORIZE_USAGE = "🔒 **Usage:** `/deauthorize <user_id>`"
97
+ MSG_AUTHORIZE_SUCCESS = (
98
+ "✅ **User Authorized!**\n\n"
99
+ "> 👤 User ID: `{user_id}`\n"
100
+ "> 🔑 Access: Permanent"
101
+ )
102
+ MSG_DEAUTHORIZE_SUCCESS = (
103
+ "✅ **User Deauthorized!**\n\n"
104
+ "> 👤 User ID: `{user_id}`\n"
105
+ "> 🔒 Access: Revoked"
106
+ )
107
+ MSG_TOKEN_ACTIVATED = (
108
+ "✅ **Token Activated Successfully!**\n\n"
109
+ "> 🗓️ Access granted until: **{expiry_date}**\n"
110
+ "> 📝 Details: _{description}_\n\n"
111
+ "✨ Enjoy all the features!"
112
+ )
113
+ MSG_TOKEN_VERIFIED = "🎉 **Token Verified!** You're all set to use the bot's features."
114
+ MSG_TOKEN_INVALID = "🚫 **Expired or Invalid Token.** Please get a new token to continue."
115
+ MSG_NO_AUTH_USERS = "ℹ️ **No Authorized Users Found:** The list is currently empty."
116
+ MSG_AUTH_USER_INFO = """{i}. 👤 User ID: `{user_id}`
117
+ • Authorized by: `{authorized_by}`
118
+ • Date: `{auth_time}`\n\n"""
119
+ MSG_ADMIN_AUTH_LIST_HEADER = "🔐 **Authorized Users List**\n\n"
120
+
121
+ # ------ Shell Commands ------
122
+ MSG_SHELL_USAGE = (
123
+ "<b>Usage:</b>\n"
124
+ "/shell <command>\n\n"
125
+ "<b>Example:</b>\n"
126
+ "/shell ls -l"
127
+ )
128
+ MSG_SHELL_EXECUTING = "Executing Command... ⚙️\n<pre>{command}</pre>"
129
+ MSG_SHELL_OUTPUT = """**Shell Command Output:**
130
+ <pre>{output}</pre>"""
131
+ MSG_SHELL_OUTPUT_STDOUT = "<b>[stdout]:</b>\n<pre>{output}</pre>"
132
+ MSG_SHELL_OUTPUT_STDERR = "<b>[stderr]:</b>\n<pre>{error}</pre>"
133
+ MSG_SHELL_NO_OUTPUT = "✅ <b>Command Executed:</b> No output."
134
+ MSG_ADMIN_SHELL_STDOUT_PLAIN = "[stdout]:\n{output}\n"
135
+ MSG_ADMIN_SHELL_STDERR_PLAIN = "[stderr]:\n{error}\n"
136
+ MSG_SHELL_OUTPUT_FILENAME = "shell_output.txt"
137
+
138
+ # ------ Admin View & Control ------
139
+ MSG_ADMIN_RESTART_BROADCAST = "🔄 Restart Broadcast"
140
+ MSG_ADMIN_CANCEL_BROADCAST = "🛑 Cancel Broadcast"
141
+ MSG_ADMIN_CANCEL_BROADCAST_BUTTON_TEXT = "ID: {broadcast_id} | Progress: {progress} | Time: {elapsed}"
142
+ MSG_ADMIN_BOT_WORKLOAD_HEADER = "🤖 **Bot Workload Distribution:**\n\n"
143
+ MSG_ADMIN_BOT_WORKLOAD_ITEM = " {bot_name}: {load}\n"
144
+ MSG_ADMIN_BROADCAST_PROGRESS_ITEM = "ID: {broadcast_id} | Progress: {progress} | Time: {elapsed}"
145
+ MSG_ADMIN_RESTART_DONE = "✅ **Restart Successful!**"
146
+
147
+ # =====================================================================================
148
+ # ====== BUTTON TEXTS (User-facing) ======
149
+ # =====================================================================================
150
+ MSG_BUTTON_STREAM_NOW = "🖥️ Stream"
151
+ MSG_BUTTON_DOWNLOAD = "📥 Download"
152
+ MSG_BUTTON_GET_HELP = "📖 Get Help"
153
+ MSG_BUTTON_QUICK_START = "🚀 Quick Start"
154
+ MSG_BUTTON_CANCEL_BROADCAST = "🛑 Cancel Broadcast"
155
+ MSG_BUTTON_VIEW_PROFILE = "👤 View User Profile"
156
+ MSG_BUTTON_ABOUT = "ℹ️ About Bot"
157
+ MSG_BUTTON_STATUS = "📡 Status"
158
+ MSG_BUTTON_JOIN_CHANNEL = "📢 Join {channel_title}"
159
+ MSG_BUTTON_GITHUB = "🛠️ GitHub"
160
+ MSG_BUTTON_START_CHAT = "📩 Start Chat"
161
+ MSG_BUTTON_JOIN_CHAT = "📢 Join {chat_title}"
162
+
163
+ # ------ Quick Start Guide ------
164
+ MSG_QUICK_START_GUIDE = (
165
+ "🚀 **Quick Start Guide** 🚀\n\n"
166
+ "Welcome to the Thunder File to Link Bot! Here's how to get started:\n\n"
167
+ "1. **Private Chat:** Send any file directly to me, and I'll reply with download and stream links.\n"
168
+ "2. **Groups:** Reply to a file with the `/link` command. For multiple files, reply to the first file with `/link <number>` (e.g., `/link 5`).\n"
169
+ "3. **Explore:** Use `/help` to see all available commands and features.\n\n"
170
+ "Enjoy fast and easy file sharing! ⚡"
171
+ )
172
+
173
+ # =====================================================================================
174
+ # ====== COMMAND RESPONSES (User-facing) ======
175
+ # =====================================================================================
176
+
177
+ # ------ Welcome, Help, About ------
178
+ MSG_WELCOME = (
179
+ "🌟 **Welcome, {user_name}!** 🌟\n\n"
180
+ "I'm **Thunder File to Link Bot** ⚡\n"
181
+ "I generate direct download and streaming links for your files.\n\n"
182
+ "**How to use:**\n"
183
+ "1. Send any file to me for private links.\n"
184
+ "2. In groups, reply to a file with `/link`.\n\n"
185
+ "» Use `/help` for all commands and detailed information.\n\n"
186
+ "🚀 Send a file to begin!"
187
+ )
188
+
189
+ MSG_HELP = (
190
+ "📘 **Thunder Bot - Help Guide** 📖\n\n"
191
+ "How to get direct download & streaming links:\n\n"
192
+ "**🚀 Private Chat (with me):**\n"
193
+ "> 1. Send me **any file** (document, video, audio, photo, etc.).\n"
194
+ "> 2. I'll instantly reply with your links! ⚡\n\n"
195
+ "**👥 Using in Groups:**\n"
196
+ "> • Reply to any file with `/link`.\n"
197
+ "> • **Batch Mode:** Reply to the *first* file with `/link <number>` (e.g., `/link 5` for 5 files, up to 100).\n"
198
+ "> • Bot needs administrator rights in the group to function.\n"
199
+ "> • Links are posted in the group & sent to you privately (if you have started a chat with me).\n"
200
+ "> • *Optional:* If the bot is an admin with delete rights, it can be configured to auto-link new files.\n\n"
201
+ "**📢 Using in Channels:**\n"
202
+ "> • Add me as an administrator with necessary permissions.\n"
203
+ "> • I can be configured to auto-detect new media files.\n"
204
+ "> • Inline stream/download buttons can be added to files automatically.\n"
205
+ "> • Files from banned channels (owner configuration) are rejected.\n"
206
+ "> • Auto-posting links for new files is a configurable option.\n\n"
207
+ "**⚙️ Available Commands:**\n"
208
+ "> `/start` 👋 - Welcome message & quick start information.\n"
209
+ "> `/help` 📖 - Shows this help message.\n"
210
+ "> `/link <num>` 🔗 - (Groups) Generate links. For batch processing: `/link <number>` (1-100 files).\n"
211
+ "> `/about` ℹ️ - Learn more about me and my features.\n"
212
+ "> `/ping` 📡 - Check my responsiveness and online status.\n"
213
+ "> `/dc` 🌍 - View DC information (for yourself, another user, or a file).\n\n"
214
+ "**💡 Pro Tips:**\n"
215
+ "> • You can forward files from other chats directly to me.\n"
216
+ "> • If you encounter a rate limit message, please wait the specified time. ⏳\n"
217
+ "> • For `/link` in groups to work reliably (and for private link delivery), ensure you've started a private chat with me first.\n"
218
+ "> • Processing batch files might take a bit longer. Please be patient. 🐌\n\n"
219
+ "❓ Questions? Please ask in our support group!"
220
+ )
221
+
222
+ MSG_ABOUT = (
223
+ "🌟 **About Thunder File to Link Bot** ℹ️\n\n"
224
+ "I'm your go-to bot for **instant direct download & streaming links!** ⚡\n\n"
225
+ "**🚀 Key Features:**\n"
226
+ "> **Instant Links:** Get your links within seconds.\n"
227
+ "> **Online Streaming:** Watch videos or listen to audio directly (for supported formats).\n"
228
+ "> **Universal File Support:** Handles documents, videos, audio, photos, and more.\n"
229
+ "> **High-Speed Access:** Optimized for fast link generation and file access.\n"
230
+ "> **Secure & Reliable:** Your files are handled with care during processing.\n"
231
+ "> **User-Friendly Interface:** Designed for ease of use on any device.\n"
232
+ "> **Efficient Processing:** Built for speed and reliability.\n"
233
+ "> **Batch Mode:** Process multiple files at once in groups using `/link <number>`.\n"
234
+ "> **Versatile Usage:** Works in private chats, groups, and channels (with admin setup).\n\n"
235
+ "💖 If you find me useful, please consider sharing me with your friends!"
236
+ )
237
+
238
+ # ------ Ping ------
239
+ MSG_PING_START = "🛰️ **Pinging...** Please wait."
240
+ MSG_PING_RESPONSE = (
241
+ "🚀 **PONG! Bot is Online!** ⚡\n"
242
+ "> ⏱️ **Response Time:** {time_taken_ms:.2f} ms\n"
243
+ "> 🤖 **Bot Status:** `Active & Ready`"
244
+ )
245
+
246
+ # ------ DC Info ------
247
+ MSG_DC_USER_INFO = (
248
+ "📍 **Information**\n"
249
+ "> 👤 **User:** [{user_name}](tg://user?id={user_id})\n"
250
+ "> 🆔 **User ID:** `{user_id}`\n"
251
+ "> 🌍 **DC ID:** `{dc_id}`"
252
+ )
253
+
254
+ MSG_DC_FILE_INFO = (
255
+ "🗂️ **File Information**\n"
256
+ ">`{file_name}`\n"
257
+ "💾 **File Size:** `{file_size}`\n"
258
+ "📁 **File Type:** `{file_type}`\n"
259
+ "🌍 **DC ID:** `{dc_id}`"
260
+ )
261
+
262
+ MSG_DC_UNKNOWN = "Unknown"
263
+
264
+ # ------ File Link Generation ------
265
+ MSG_LINKS = (
266
+ "✨ **Your Links are Ready!** ✨\n\n"
267
+ "> `{file_name}`\n\n"
268
+ "📂 **File Size:** `{file_size}`\n\n"
269
+ "🔗 **Download Link:**\n`{download_link}`\n\n"
270
+ "🖥️ **Stream Link:**\n`{stream_link}`\n\n"
271
+ "⌛️ *Note: Links remain active while the bot is running and the file is accessible.*"
272
+ )
273
+
274
+ # =====================================================================================
275
+ # ====== USER NOTIFICATIONS ======
276
+ # =====================================================================================
277
+
278
+ MSG_NEW_USER = (
279
+ "✨ **New User Alert!** ✨\n"
280
+ "> 👤 **Name:** [{first_name}](tg://user?id={user_id})\n"
281
+ "> 🆔 **User ID:** `{user_id}`\n\n"
282
+ )
283
+
284
+ MSG_PRIVATE_CHAT_WELCOME = (
285
+ "👋 **Welcome!** Send me any file to get started.\n"
286
+ "> 📤 I'll generate instant download & streaming links for you.\n"
287
+ "> ⚡ Fast and reliable service.\n"
288
+ "> 🔒 Your files are handled securely."
289
+ )
290
+ MSG_DEFAULT_WELCOME = "📢 Don't forget our channel for the latest news & features!"
291
+ MSG_COMMUNITY_CHANNEL = "📢 **{channel_title}:** 🔒 Join this channel to use the bot."
292
+
293
+ # =====================================================================================
294
+ # ====== PROCESSING MESSAGES ======
295
+ # =====================================================================================
296
+
297
+ # ------ General File Processing ------
298
+ MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**"
299
+ MSG_PROCESSING_FILE = "⏳ **Processing your file...**"
300
+ MSG_DEFAULT_FILENAME = "Untitled File"
301
+ MSG_NEW_FILE_REQUEST = (
302
+ "> 👤 **Source:** [{user_name}](tg://user?id={user_id})\n"
303
+ "> 🆔 **ID:** `{user_id}`\n\n"
304
+ "🔗 **Download:** `{online_link}`\n\n"
305
+ "🖥️ **Stream:** `{stream_link}`"
306
+ )
307
+
308
+ # ------ Batch Processing ------
309
+ MSG_PROCESSING_BATCH = "🔄 **Processing Batch {batch_number}/{total_batches}** ({file_count} files)"
310
+ MSG_PROCESSING_STATUS = "📊 **Processing Files:** {processed}/{total} complete, {failed} failed"
311
+ MSG_PROCESSING_WARNING = "⚠️ **Warning:** Too many files failed processing. Please try again with fewer files or contact support."
312
+ MSG_BATCH_LINKS_READY = "🔗 **Batch Links ({count} files):**\n\n`{links}`"
313
+ MSG_BATCH_LINKS_FROM = "📬 **Links from {chat_title}**\n\n`{links_message}`"
314
+ MSG_PROCESSING_RESULT = "✅ **Process Complete:** {processed}/{total} files processed successfully, {failed} failed"
315
+ MSG_PROCESSING_ERROR = "❌ **Error Processing Files:** {error}\n\n{processed}/{total} files were processed (ID: {error_id})"
316
+ MSG_RETRYING_FILES = "🔄 **Retrying {count} Failed Files...**"
317
+
318
+ # =====================================================================================
319
+ # ====== BROADCAST MESSAGES ======
320
+ # =====================================================================================
321
+ MSG_BROADCAST_START = "📣 **Starting Broadcast...**\n\n> ⏳ Please wait for completion."
322
+ MSG_BROADCAST_PROGRESS = (
323
+ "📊 **Broadcast Progress**\n\n"
324
+ "> 👥 **Total Users:** `{total_users}`\n"
325
+ "> ✅ **Processed:** `{processed}/{total_users}`\n"
326
+ "> ⏱️ **Elapsed:** `{elapsed_time}`\n\n"
327
+ "> ✓ **Sent:** `{successes}`\n"
328
+ "> ✗ **Failed:** `{failures}`"
329
+ )
330
+
331
+ # =====================================================================================
332
+ # ====== PERMISSION MESSAGES ======
333
+ # =====================================================================================
334
+ MSG_ERROR_UNAUTHORIZED = "You are not authorized to view this information."
335
+ MSG_ERROR_BROADCAST_RESTART = "Please use the /broadcast command to start a new broadcast."
336
+ MSG_ERROR_BROADCAST_INSTRUCTION = "To start a new broadcast, use the /broadcast command and reply to the message you want to broadcast."
337
+ MSG_ERROR_CALLBACK_UNSUPPORTED = "This button is not active or no longer supported."
338
+ MSG_ERROR_GENERIC_CALLBACK = "An error occurred. Please try again later. (ID: {error_id})"
339
+ MSG_BROADCAST_COMPLETE = (
340
+ "📢 **Broadcast Completed Successfully!** 📢\n"
341
+ "──────────────────────────────\n"
342
+ "⏱️ **Duration:** `{elapsed_time}`\n"
343
+ "👥 **Total Users:** `{total_users}`\n"
344
+ "✅ **Successful Deliveries:** `{successes}`\n"
345
+ "❌ **Failed Deliveries:** `{failures}`\n"
346
+ "──────────────────────────────\n"
347
+ "🗑️ **Accounts Removed (Blocked/Deactivated):** `{deleted_accounts}`\n" # Clarified
348
+ "──────────────────────────────"
349
+ )
350
+ MSG_BROADCAST_CANCEL = "🛑 **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations..."
351
+ MSG_BROADCAST_FAILED = (
352
+ "❌ **Broadcast Failed!** 😞\n\n"
353
+ "> ❗ **Error Details:**\n```\n{error}\n``` (ID: {error_id})"
354
+ )
355
+ MSG_INVALID_BROADCAST_CMD = "Please reply to the message you want to broadcast."
356
+ MSG_NO_ACTIVE_BROADCASTS = "ℹ️ **No Active Broadcasts:** Nothing to cancel at the moment."
357
+ MSG_BROADCAST_NOT_FOUND = "⚠️ **Broadcast Not Found:** This broadcast is no longer active or has finished."
358
+ MSG_MULTIPLE_BROADCASTS = "🔄 **Multiple Broadcasts Active:** Select one to cancel:"
359
+ MSG_CANCELLING_BROADCAST = "🛑 **Cancelling Broadcast:** `{broadcast_id}`\n\n> ⏳ Stopping operations... Please wait."
360
+
361
+ # =====================================================================================
362
+ # ====== FORCE CHANNEL MESSAGES ======
363
+ # =====================================================================================
364
+
365
+ MSG_FORCE_CHANNEL_ERROR = "Sorry, there was an issue verifying access. Please try again later. (ID: {error_id})"
366
+ MSG_FORCE_CHANNEL_RPC_ERROR = "An unexpected error occurred while checking channel membership. Please try again. (ID: {error_id})"
367
+ MSG_FORCE_CHANNEL_GENERIC_ERROR = "An error occurred. Please try again. (ID: {error_id})"
368
+ MSG_FORCE_CHANNEL_NO_LINK = "To use this bot, you must join our main channel. Please contact an admin for assistance."
369
+ MSG_FORCE_CHANNEL_ACCESS_REQUIRED = (
370
+ "🚫 **Access Required**\n\n"
371
+ "Please join our channel to use this bot:\n{invite_link}\n\n"
372
+ "After joining, try your command again."
373
+ )
374
+ MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION = "⚠️ Temporary service interruption. Please try again later. (ID: {error_id})"
375
+ MSG_FORCE_CHANNEL_MEMBERSHIP_REQUIRED = "🔒 This command requires channel membership. Please contact support if you need assistance. (ID: {error_id})"
376
+
377
+ # =====================================================================================
378
+ # ====== FILE TYPE DESCRIPTIONS ======
379
+ # =====================================================================================
380
+ MSG_FILE_TYPE_DOCUMENT = "📄 Document"
381
+ MSG_FILE_TYPE_PHOTO = "🖼️ Photo"
382
+ MSG_FILE_TYPE_VIDEO = "🎬 Video"
383
+ MSG_FILE_TYPE_AUDIO = "🎵 Audio"
384
+ MSG_FILE_TYPE_VOICE = "🎤 Voice Message"
385
+ MSG_FILE_TYPE_STICKER = "🎨 Sticker"
386
+ MSG_FILE_TYPE_ANIMATION = "🎞️ Animation (GIF)"
387
+ MSG_FILE_TYPE_VIDEO_NOTE = "📹 Video Note"
388
+ MSG_FILE_TYPE_UNKNOWN = "❓ Unknown File Type"
389
+
390
+ # =====================================================================================
391
+ # ====== SYSTEM & STATUS MESSAGES (Bot Health, Logs, Stats) ======
392
+ # =====================================================================================
393
+
394
+ MSG_RESTARTING = "🔄 **Restarting Bot...**\n\n> ⏳ Please wait a moment."
395
+ MSG_LOG_FILE_CAPTION = "📄 **System Logs**\n\n> ℹ️ Latest log file"
396
+ MSG_LOG_FILE_EMPTY = "ℹ️ **Log File Empty:** No data found in the log file."
397
+ MSG_LOG_FILE_MISSING = "⚠️ **Log File Missing:** Could not find the log file."
398
+ MSG_SYSTEM_STATUS = (
399
+ "✅ **System Status:** Operational\n\n"
400
+ "> 🕒 **Uptime:** `{uptime}`\n"
401
+ "> 🤖 **Active Bot Instances:** `{active_bots}`\n\n"
402
+ "{workloads}\n"
403
+ "> ♻️ **Bot Version:** `{version}`"
404
+ )
405
+ MSG_SYSTEM_STATS = (
406
+ "📊 **System Statistics**\n\n"
407
+ "> 🕒 **Uptime:** `{uptime}`\n\n"
408
+ "💾 **Storage (Server):**\n"
409
+ "> 📀 Total: `{total}`\n"
410
+ "> 📝 Used: `{used}`\n"
411
+ "> 📭 Free: `{free}`\n\n"
412
+ "📶 **Network (Server):**\n"
413
+ "> 🔺 Upload: `{upload}`\n"
414
+ "> 🔻 Download: `{download}`\n\n"
415
+ )
416
+ MSG_PERFORMANCE_STATS = (
417
+ "⚙️ **Performance (Server):**\n"
418
+ "> 🖥️ CPU: `{cpu_percent}%`\n"
419
+ "> 🧠 RAM: `{ram_percent}%`\n"
420
+ "> 📦 Disk: `{disk_percent}%`"
421
+ )
422
+ MSG_DB_STATS = "📊 **Database Statistics**\n\n> 👥 **Total Users:** `{total_users}`"
423
+ MSG_BOT_WORKLOAD_ITEM = "🔹 Bot {num}: {load}"
424
+ MSG_BOT_WORKLOAD_TEXT = " {bot_name}: {load}\n"
425
+
426
+ # =====================================================================================
427
+ # ====== API & SERVER ERROR MESSAGES ======
428
+ # =====================================================================================
429
+
430
+ MSG_INVALID_SECURITY_CREDENTIALS = "Invalid security credentials"
431
+ MSG_FILE_NOT_FOUND = "File not found"
432
+ MSG_MALFORMED_RANGE_HEADER = "Malformed range header"
433
+ MSG_INTERNAL_SERVER_ERROR = "Internal server error (Reference ID: {error_id})"
Thunder/utils/render_template.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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.logger import logger
11
+
12
+ # Initialize Jinja2 environment
13
+ template_env = Environment(loader=FileSystemLoader('Thunder/template'), enable_async=True)
14
+
15
+ async def render_page(id: int, secure_hash: str, requested_action: str | None = None) -> str: # Re-add requested_action
16
+ file_data = await get_file_ids(StreamBot, int(Var.BIN_CHANNEL), id)
17
+ if file_data.unique_id[:6] != secure_hash:
18
+ logger.debug(f'Link hash: {secure_hash} - Expected: {file_data.unique_id[:6]}')
19
+ logger.debug(f"Invalid hash for message ID {id}")
20
+ raise InvalidHash
21
+
22
+ quoted_filename = urllib.parse.quote(file_data.file_name.replace('/', '_'))
23
+ src = urllib.parse.urljoin(Var.URL, f'{secure_hash}{str(id)}/{quoted_filename}')
24
+
25
+ safe_filename = html_module.escape(file_data.file_name)
26
+
27
+ template_name: str
28
+ context: dict
29
+
30
+ if requested_action == 'stream':
31
+ template_name = 'req.html'
32
+ context = {
33
+ 'tag': "video",
34
+ 'heading': f"View {safe_filename}",
35
+ 'file_name': safe_filename,
36
+ 'src': src
37
+ }
38
+ else: # Default to download
39
+ template_name = 'dl.html'
40
+ context = {
41
+ 'file_name': safe_filename,
42
+ 'src': src
43
+ }
44
+
45
+ template = template_env.get_template(template_name)
46
+ html_content = await template.render_async(**context)
47
+ return html_content
Thunder/utils/shortener.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ '''URL shortening utility.'''
2
+ import asyncio
3
+ from typing import Dict, Optional
4
+ from base64 import b64encode
5
+ from random import random, choice
6
+ from urllib.parse import quote, urlparse
7
+ from warnings import filterwarnings
8
+ import cloudscraper
9
+
10
+ from Thunder.vars import Var
11
+ from Thunder.utils.logger import logger
12
+
13
+ # Global shortener dictionary, scraper session, lock, and status flag
14
+ shortener_dict: Dict[str, str] = {}
15
+ _scraper_session: Optional[cloudscraper.CloudScraper] = None
16
+ _init_lock = asyncio.Lock()
17
+ _shortener_system_ready = False
18
+
19
+ # Suppress insecure HTTPS warnings
20
+ try:
21
+ import urllib3
22
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
23
+ except (ImportError, AttributeError):
24
+ filterwarnings('ignore', message='Unverified HTTPS request')
25
+
26
+ def _init_shorteners() -> bool:
27
+ """Initializes shortener config and the global scraper session."""
28
+ global shortener_dict, _scraper_session, _shortener_system_ready
29
+
30
+ _shortener_system_ready = False # Assume failure until success
31
+ shortener_dict = {}
32
+ _scraper_session = None
33
+
34
+ # Check if shortening is enabled via Var
35
+ is_enabled = getattr(Var, "SHORTEN_ENABLED", False) or getattr(Var, "SHORTEN_MEDIA_LINKS", False)
36
+ if not is_enabled:
37
+ logger.info("ⓘ URL shortening is disabled")
38
+ return False
39
+
40
+ # Get URL shortener configuration
41
+ site = getattr(Var, "URL_SHORTENER_SITE", "")
42
+ api_key = getattr(Var, "URL_SHORTENER_API_KEY", "")
43
+
44
+ if site and api_key:
45
+ shortener_dict[site] = api_key
46
+ try:
47
+ _scraper_session = cloudscraper.create_scraper(
48
+ browser={
49
+ 'browser': 'chrome',
50
+ 'platform': 'android',
51
+ 'desktop': False
52
+ }
53
+ )
54
+ logger.info(f"✓ Shortener initialized: {site} (session created)")
55
+ _shortener_system_ready = True
56
+ return True
57
+ except Exception as e:
58
+ logger.error(f"Failed to create scraper session: {e}")
59
+ # Reset to ensure clean state on session creation failure
60
+ shortener_dict = {}
61
+ _scraper_session = None
62
+ _shortener_system_ready = False
63
+ return False
64
+
65
+ logger.warning("ⓘ URL shortener not configured or API key missing")
66
+ _shortener_system_ready = False # Explicitly ensure it's false
67
+ return False
68
+
69
+ def _validate_url(url: str) -> bool:
70
+ """Checks if a string is a well-formed URL."""
71
+ try:
72
+ result = urlparse(url)
73
+ return all([result.scheme, result.netloc])
74
+ except Exception:
75
+ return False
76
+
77
+ async def short_url(longurl: str, attempt: int = 0) -> str:
78
+ """Shortens a URL using a configured service, with retries and fallback."""
79
+ global _shortener_system_ready, _scraper_session # Ensure globals are accessible
80
+
81
+ if not _shortener_system_ready: # First check (no lock)
82
+ async with _init_lock:
83
+ if not _shortener_system_ready: # Double check (with lock)
84
+ _init_shorteners() # Attempt initialization
85
+
86
+ if not _shortener_system_ready:
87
+ # Initialization failed or not enabled, _init_shorteners logs details
88
+ return longurl
89
+
90
+ if _scraper_session is None:
91
+ logger.error(
92
+ "Critical state: _shortener_system_ready is True, but _scraper_session is None. "
93
+ "Attempting a locked re-initialization."
94
+ )
95
+ async with _init_lock:
96
+ _init_shorteners()
97
+
98
+ if not _shortener_system_ready or _scraper_session is None:
99
+ logger.error(
100
+ "Critical: Scraper session remains uninitialized after re-attempt. "
101
+ "URL shortening will be skipped for this request."
102
+ )
103
+ return longurl
104
+
105
+ if attempt >= 3:
106
+ logger.warning(f"Max attempts reached for {longurl}. Returning original.")
107
+ return longurl
108
+
109
+ if not _validate_url(longurl):
110
+ logger.warning(f"Invalid URL format: {longurl}. Returning original.")
111
+ return longurl
112
+
113
+ try:
114
+ session = _scraper_session
115
+
116
+ # Ensure shortener_dict is not empty before trying to access its items
117
+ if not shortener_dict:
118
+ logger.error("Shortener dictionary is empty. Cannot proceed with shortening.")
119
+ return longurl # Or raise an exception if this state is unexpected
120
+
121
+ _shortener, _shortener_api = choice(list(shortener_dict.items()))
122
+
123
+ # --- Shorte.st ---
124
+ if "shorte.st" in _shortener:
125
+ headers = {"public-api-token": _shortener_api}
126
+ data = {"urlToShorten": quote(longurl)}
127
+ response = session.put(
128
+ "https://api.shorte.st/v1/data/url",
129
+ headers=headers,
130
+ data=data
131
+ )
132
+
133
+ if response.status_code != 200:
134
+ raise Exception(f"HTTP error {response.status_code}")
135
+
136
+ return response.json()["shortenedUrl"]
137
+
138
+ # --- Linkvertise ---
139
+ elif "linkvertise" in _shortener:
140
+ url = quote(b64encode(longurl.encode("utf-8")))
141
+ linkvertise = [
142
+ f"https://link-to.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
143
+ f"https://up-to-down.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
144
+ f"https://direct-link.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
145
+ f"https://file-link.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
146
+ ]
147
+ return choice(linkvertise)
148
+
149
+ # --- Bitly.com ---
150
+ elif "bitly.com" in _shortener:
151
+ headers = {"Authorization": f"Bearer {_shortener_api}"}
152
+ response = session.post(
153
+ "https://api-ssl.bit.ly/v4/shorten",
154
+ json={"long_url": longurl},
155
+ headers=headers
156
+ )
157
+
158
+ if response.status_code != 200:
159
+ raise Exception(f"HTTP error {response.status_code}")
160
+
161
+ return response.json()["link"]
162
+
163
+ # --- Ouo.io ---
164
+ elif "ouo.io" in _shortener:
165
+ response = session.get(
166
+ f"http://ouo.io/api/{_shortener_api}?s={longurl}",
167
+ verify=False
168
+ )
169
+
170
+ if not response.text or response.status_code != 200:
171
+ raise Exception(f"HTTP error {response.status_code}")
172
+
173
+ return response.text
174
+
175
+ # --- Cutt.ly ---
176
+ elif "cutt.ly" in _shortener:
177
+ response = session.get(
178
+ f"http://cutt.ly/api/api.php?key={_shortener_api}&short={longurl}"
179
+ )
180
+
181
+ if response.status_code != 200:
182
+ raise Exception(f"HTTP error {response.status_code}")
183
+
184
+ return response.json()["url"]["shortLink"]
185
+
186
+ # --- Generic & Fallback ---
187
+ else:
188
+ # Generic shortener handling
189
+ response = session.get(
190
+ f"https://{_shortener}/api?api={_shortener_api}&url={quote(longurl)}"
191
+ )
192
+
193
+ if response.status_code != 200:
194
+ raise Exception(f"Generic API error {response.status_code}")
195
+
196
+ result = response.json()
197
+ shorted = result.get("shortenedUrl")
198
+
199
+ # Fallback to shrtco.de if generic failed or no result
200
+ if not shorted:
201
+ try:
202
+ shrtco_response = session.get(
203
+ f"https://api.shrtco.de/v2/shorten?url={quote(longurl)}"
204
+ )
205
+
206
+ if shrtco_response.status_code != 200:
207
+ logger.warning(f"shrtco.de fallback failed with status {shrtco_response.status_code}")
208
+ return longurl # Return original if fallback also fails badly
209
+
210
+ shrtco_result = shrtco_response.json()
211
+ if not shrtco_result.get("ok") or "result" not in shrtco_result or "full_short_link" not in shrtco_result["result"]:
212
+ logger.warning(f"shrtco.de fallback response malformed: {shrtco_result}")
213
+ return longurl
214
+
215
+ shrtco_link = shrtco_result["result"]["full_short_link"]
216
+
217
+ # Try the configured generic shortener again with the shrtco.de link
218
+ response = session.get(
219
+ f"https://{_shortener}/api?api={_shortener_api}&url={shrtco_link}"
220
+ )
221
+
222
+ if response.status_code != 200:
223
+ logger.warning(f"Generic shortener retry with shrtco.de link failed: status {response.status_code}")
224
+ return longurl # Return original if retry fails
225
+
226
+ result = response.json()
227
+ shorted = result.get("shortenedUrl")
228
+ except Exception as fallback_e:
229
+ logger.error(f"shrtco.de fallback exception: {fallback_e}")
230
+ return longurl # Return original on fallback exception
231
+
232
+ return shorted if shorted else longurl
233
+
234
+ except Exception as e:
235
+ shortener_name = _shortener if '_shortener' in locals() else 'unknown'
236
+ logger.error(f"URL shortener error ({shortener_name} for {longurl}): {e}, attempt {attempt + 1}")
237
+
238
+ await asyncio.sleep(1) # Wait before retrying
239
+ return await short_url(longurl, attempt + 1)
240
+
241
+ async def shorten(url: str) -> str:
242
+ """Async public wrapper for short_url."""
243
+ return await short_url(url)
Thunder/utils/time_format.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thunder/utils/time_format.py
2
+
3
+ def get_readable_time(seconds: int) -> str:
4
+ # Convert seconds to human-readable time format (e.g., "2d 5h 30m 10s")
5
+ periods = [
6
+ ('d', 86400), # Days
7
+ ('h', 3600), # Hours
8
+ ('m', 60), # Minutes
9
+ ('s', 1) # Seconds
10
+ ]
11
+ result = []
12
+
13
+ for suffix, period in periods:
14
+ if seconds >= period:
15
+ value, seconds = divmod(seconds, period)
16
+ result.append(f"{int(value)}{suffix}")
17
+
18
+ return ' '.join(result) if result else '0s'
Thunder/utils/tokens.py ADDED
@@ -0,0 +1,265 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Token generation, verification, and authorization management for the Thunder bot.
3
+ """
4
+
5
+ import uuid
6
+ from datetime import datetime, timedelta
7
+ from typing import Optional, Dict, Any, AsyncIterator, List, Union
8
+ from motor.motor_asyncio import AsyncIOMotorCursor
9
+
10
+ from Thunder.utils.database import db
11
+ from Thunder.utils.logger import logger
12
+ from Thunder.vars import Var
13
+
14
+ async def check(user_id: int) -> bool:
15
+ """
16
+ Verify if a user has a valid token.
17
+
18
+ Args:
19
+ user_id: User's Telegram ID
20
+
21
+ Returns:
22
+ bool: True if user has valid token, False otherwise
23
+ """
24
+ try:
25
+ # Check if token system is enabled
26
+ if not getattr(Var, "TOKEN_ENABLED", False):
27
+ return True # Token system disabled
28
+
29
+ # Check if user is owner
30
+ if isinstance(Var.OWNER_ID, (list, tuple, set)):
31
+ is_owner = user_id in Var.OWNER_ID
32
+ else:
33
+ is_owner = user_id == Var.OWNER_ID
34
+
35
+ # Check if user is owner or authorized
36
+ if is_owner or await allowed(user_id):
37
+ return True
38
+
39
+ # Check for valid token
40
+ token_data = await db.token_col.find_one({"user_id": user_id})
41
+ if not token_data:
42
+ return False
43
+
44
+ # Check if token is expired
45
+ if token_data["expires_at"] <= datetime.utcnow():
46
+ return False
47
+
48
+ return True
49
+ except Exception as e:
50
+ logger.error(f"Error in token check for user {user_id}: {e}")
51
+ return False
52
+
53
+ async def generate(user_id: int) -> Dict[str, Any]:
54
+ """
55
+ Generate a new token for a user.
56
+
57
+ Args:
58
+ user_id: User's Telegram ID
59
+
60
+ Returns:
61
+ dict: Token data with expiration time
62
+ """
63
+ try:
64
+ duration = getattr(Var, "TOKEN_TTL_HOURS", 24)
65
+ token = str(uuid.uuid4())
66
+ created_at = datetime.utcnow()
67
+ expires_at = created_at + timedelta(hours=duration)
68
+
69
+ token_data = {
70
+ "user_id": user_id,
71
+ "token": token,
72
+ "created_at": created_at,
73
+ "expires_at": expires_at
74
+ }
75
+
76
+ # Store or update token in database
77
+ await db.token_col.update_one(
78
+ {"user_id": user_id},
79
+ {"$set": token_data},
80
+ upsert=True
81
+ )
82
+
83
+ return token_data
84
+ except Exception as e:
85
+ logger.error(f"Failed to generate token for user {user_id}: {e}")
86
+ raise
87
+
88
+ async def allowed(user_id: int) -> bool:
89
+ """
90
+ Check if a user is authorized (permanently allowed).
91
+
92
+ Args:
93
+ user_id: User's Telegram ID
94
+
95
+ Returns:
96
+ bool: True if user is authorized, False otherwise
97
+ """
98
+ try:
99
+ auth_data = await db.authorized_users_col.find_one({"user_id": user_id})
100
+ return bool(auth_data)
101
+ except Exception as e:
102
+ logger.error(f"Error checking authorization for user {user_id}: {e}")
103
+ return False
104
+
105
+ async def authorize(user_id: int, authorized_by: int) -> bool:
106
+ """
107
+ Authorize a user to use the bot permanently.
108
+
109
+ Args:
110
+ user_id: User's Telegram ID to authorize
111
+ authorized_by: Admin's Telegram ID who authorized
112
+
113
+ Returns:
114
+ bool: True if successful, False otherwise
115
+ """
116
+ try:
117
+ auth_data = {
118
+ "user_id": user_id,
119
+ "authorized_by": authorized_by,
120
+ "authorized_at": datetime.utcnow()
121
+ }
122
+
123
+ await db.authorized_users_col.update_one(
124
+ {"user_id": user_id},
125
+ {"$set": auth_data},
126
+ upsert=True
127
+ )
128
+
129
+ return True
130
+ except Exception as e:
131
+ logger.error(f"Failed to authorize user {user_id}: {e}")
132
+ return False
133
+
134
+ async def deauthorize(user_id: int) -> bool:
135
+ """
136
+ Remove a user's authorization.
137
+
138
+ Args:
139
+ user_id: User's Telegram ID to deauthorize
140
+
141
+ Returns:
142
+ bool: True if successful, False otherwise
143
+ """
144
+ try:
145
+ result = await db.authorized_users_col.delete_one({"user_id": user_id})
146
+ return result.deleted_count > 0
147
+ except Exception as e:
148
+ logger.error(f"Failed to deauthorize user {user_id}: {e}")
149
+ return False
150
+
151
+ async def get_user(user_id: int) -> Optional[Dict[str, Any]]:
152
+ """
153
+ Get information about a user's token.
154
+
155
+ Args:
156
+ user_id: User's Telegram ID
157
+
158
+ Returns:
159
+ dict or None: Token data if found, None otherwise
160
+ """
161
+ try:
162
+ return await db.token_col.find_one({"user_id": user_id})
163
+ except Exception as e:
164
+ logger.error(f"Failed to get token data for user {user_id}: {e}")
165
+ return None
166
+
167
+ async def get(token: str) -> Optional[Dict[str, Any]]:
168
+ """
169
+ Retrieve token record by token string.
170
+
171
+ Args:
172
+ token: Token string to look up
173
+
174
+ Returns:
175
+ dict or None: Token data if found, None otherwise
176
+ """
177
+ try:
178
+ return await db.token_col.find_one({"token": token})
179
+ except Exception as e:
180
+ logger.error(f"Failed to get token data: {e}")
181
+ return None
182
+
183
+ async def list_allowed() -> List[Dict[str, Any]]:
184
+ """
185
+ Get all authorized users.
186
+
187
+ Returns:
188
+ List[Dict[str, Any]]: List of authorized users data
189
+ """
190
+ try:
191
+ cursor = db.authorized_users_col.find({})
192
+ return await cursor.to_list(length=None)
193
+ except Exception as e:
194
+ logger.error(f"Failed to list authorized users: {e}")
195
+ return []
196
+
197
+ async def list_tokens() -> List[Dict[str, Any]]:
198
+ """
199
+ Get all active tokens.
200
+
201
+ Returns:
202
+ List[Dict[str, Any]]: List of active tokens data
203
+ """
204
+ try:
205
+ cursor = db.token_col.find({})
206
+ return await cursor.to_list(length=None)
207
+ except Exception as e:
208
+ logger.error(f"Failed to list tokens: {e}")
209
+ return []
210
+
211
+ async def validate_activation_token(token: str) -> Dict[str, Any]:
212
+ """
213
+ Validates an activation token and returns validation status.
214
+
215
+ Args:
216
+ token: The token string to validate
217
+
218
+ Returns:
219
+ dict: Validation result with status and details
220
+ """
221
+ try:
222
+ # Remove 'token' prefix if present
223
+ clean_token = token
224
+ if token.startswith("token"):
225
+ # Handle different possible separators
226
+ if len(token) > 5 and token[5] in ('-', '_'):
227
+ clean_token = token[6:] # Skip prefix and separator
228
+ else:
229
+ clean_token = token[5:] # Skip just the prefix
230
+
231
+ # Check if token exists in database
232
+ token_record = await get(clean_token)
233
+ if not token_record:
234
+ return {
235
+ "valid": False,
236
+ "reason": "Token does not exist or has been revoked",
237
+ "token": clean_token
238
+ }
239
+
240
+ # Check if token is expired
241
+ if token_record["expires_at"] <= datetime.utcnow():
242
+ return {
243
+ "valid": False,
244
+ "reason": "Token has expired",
245
+ "token": clean_token,
246
+ "expiry_date": token_record["expires_at"].strftime("%Y-%m-%d %H:%M:%S UTC")
247
+ }
248
+
249
+ # Token is valid
250
+ return {
251
+ "valid": True,
252
+ "token": clean_token,
253
+ "user_id": token_record["user_id"],
254
+ "expiry_date": token_record["expires_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
255
+ "created_at": token_record["created_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
256
+ "description": "Access token"
257
+ }
258
+
259
+ except Exception as e:
260
+ logger.error(f"Error validating token: {e}")
261
+ return {
262
+ "valid": False,
263
+ "reason": f"An error occurred during validation: {str(e)}",
264
+ "token": token
265
+ }
Thunder/vars.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thunder/vars.py - Configuration variables for the Thunder bot.
3
+ """
4
+
5
+ from typing import Set, Optional, List, Dict
6
+ import os
7
+ from dotenv import load_dotenv
8
+ from Thunder.utils.logger import logger
9
+
10
+ # Load environment variables from config.env
11
+ load_dotenv("config.env")
12
+
13
+ # Helper functions for parsing environment variables
14
+ def str_to_bool(val: str) -> bool:
15
+ """Convert string value to boolean."""
16
+ return val.lower() in ("true", "1", "t", "y", "yes")
17
+
18
+ def str_to_int_list(val: str) -> List[int]:
19
+ """Convert space-separated string to list of integers."""
20
+ if not val:
21
+ return []
22
+ return [int(x) for x in val.split() if x.isdigit()]
23
+
24
+ def str_to_int_set(val: str) -> Set[int]:
25
+ """Convert space-separated string to set of integers."""
26
+ if not val:
27
+ return set()
28
+ return set(int(x) for x in val.split() if x.isdigit())
29
+
30
+ class Var:
31
+ """Configuration variables for the Thunder bot."""
32
+
33
+ # Telegram API credentials
34
+ API_ID: int = int(os.getenv("API_ID", ""))
35
+ if not API_ID:
36
+ logger.critical("CRITICAL: API_ID is not configured in config.env!")
37
+ raise ValueError("CRITICAL: API_ID is not configured in config.env!")
38
+
39
+ API_HASH: str = os.getenv("API_HASH", "")
40
+ if not API_HASH:
41
+ logger.critical("CRITICAL: API_HASH is not configured in config.env!")
42
+ raise ValueError("CRITICAL: API_HASH is not configured in config.env!")
43
+
44
+ # Bot token and identity
45
+ BOT_TOKEN: str = os.getenv("BOT_TOKEN", "")
46
+ if not BOT_TOKEN:
47
+ logger.critical("CRITICAL: BOT_TOKEN is not configured in config.env!")
48
+ raise ValueError("CRITICAL: BOT_TOKEN is not configured in config.env!")
49
+ NAME: str = os.getenv("NAME", "ThunderF2L")
50
+
51
+ # Performance settings
52
+ SLEEP_THRESHOLD: int = int(os.getenv("SLEEP_THRESHOLD", "60"))
53
+ WORKERS: int = int(os.getenv("WORKERS", "100"))
54
+ TIMEOUT: int = int(os.getenv("TIMEOUT", "30"))
55
+
56
+ # Channel for file storage
57
+ BIN_CHANNEL: int = int(os.getenv("BIN_CHANNEL", "0"))
58
+ if not BIN_CHANNEL:
59
+ logger.critical("CRITICAL: BIN_CHANNEL is not configured in config.env!")
60
+ raise ValueError("CRITICAL: BIN_CHANNEL is not configured in config.env!")
61
+
62
+ # Web server configuration
63
+ PORT: int = int(os.getenv("PORT", "8080"))
64
+ BIND_ADDRESS: str = os.getenv("BIND_ADDRESS", "0.0.0.0")
65
+ PING_INTERVAL: int = int(os.getenv("PING_INTERVAL", "840"))
66
+ NO_PORT: bool = str_to_bool(os.getenv("NO_PORT", "True"))
67
+ CACHE_SIZE: int = int(os.getenv("CACHE_SIZE", "100"))
68
+
69
+ # Owner details
70
+ OWNER_ID: List[int] = str_to_int_list(os.getenv("OWNER_ID", ""))
71
+ if not OWNER_ID:
72
+ logger.warning("WARNING: OWNER_ID is empty. No user will have admin access.")
73
+ OWNER_USERNAME: str = os.getenv("OWNER_USERNAME", "")
74
+
75
+ # Domain and URL configuration
76
+ FQDN: str = os.getenv("FQDN", "") or BIND_ADDRESS
77
+ HAS_SSL: bool = str_to_bool(os.getenv("HAS_SSL", "False"))
78
+ PROTOCOL: str = "https" if HAS_SSL else "http"
79
+ PORT_SEGMENT: str = "" if NO_PORT else f":{PORT}"
80
+ URL: str = f"{PROTOCOL}://{FQDN}{PORT_SEGMENT}/"
81
+
82
+ # Database configuration
83
+ DATABASE_URL: str = os.getenv("DATABASE_URL", "")
84
+ if not DATABASE_URL:
85
+ logger.critical("CRITICAL: DATABASE_URL is not configured in config.env!")
86
+ raise ValueError("CRITICAL: DATABASE_URL is not configured in config.env!")
87
+
88
+ # Channel configurations
89
+ BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", ""))
90
+
91
+ # Multi-client support flag
92
+ MULTI_CLIENT: bool = False
93
+
94
+ # Force channel configuration
95
+ FORCE_CHANNEL_ID: Optional[int] = None
96
+ force_channel_env = os.getenv("FORCE_CHANNEL_ID", "").strip()
97
+ if force_channel_env:
98
+ try:
99
+ FORCE_CHANNEL_ID = int(force_channel_env)
100
+ except ValueError:
101
+ logger.warning(f"Invalid FORCE_CHANNEL_ID '{force_channel_env}' in environment; must be an integer.")
102
+
103
+ # Token System
104
+ TOKEN_ENABLED: bool = str_to_bool(os.getenv("TOKEN_ENABLED", "False"))
105
+ TOKEN_TTL_HOURS: int = int(os.getenv("TOKEN_TTL_HOURS", "24"))
106
+
107
+ # URL Shortener
108
+ SHORTEN_ENABLED: bool = str_to_bool(os.getenv("SHORTEN_ENABLED", "False"))
109
+ SHORTEN_MEDIA_LINKS: bool = str_to_bool(os.getenv("SHORTEN_MEDIA_LINKS", "False"))
110
+ URL_SHORTENER_API_KEY: str = os.getenv("URL_SHORTENER_API_KEY", "")
111
+ URL_SHORTENER_SITE: str = os.getenv("URL_SHORTENER_SITE", "")
config_sample.env ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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