Spaces:
Running
Running
Fyaz Mohammed commited on
Commit ·
1a1e2eb
0
Parent(s):
1.8.5
Browse files- .gitignore +21 -0
- .python-version +1 -0
- Dockerfile +23 -0
- LICENSE +24 -0
- Procfile +1 -0
- README.md +471 -0
- Thunder/__init__.py +9 -0
- Thunder/__main__.py +182 -0
- Thunder/bot/__init__.py +23 -0
- Thunder/bot/clients.py +89 -0
- Thunder/bot/plugins/admin.py +557 -0
- Thunder/bot/plugins/callbacks.py +166 -0
- Thunder/bot/plugins/common.py +458 -0
- Thunder/bot/plugins/stream.py +802 -0
- Thunder/server/__init__.py +9 -0
- Thunder/server/exceptions.py +10 -0
- Thunder/server/stream_routes.py +297 -0
- Thunder/template/dl.html +27 -0
- Thunder/template/req.html +164 -0
- Thunder/utils/bot_utils.py +191 -0
- Thunder/utils/broadcast_helper.py +41 -0
- Thunder/utils/config_parser.py +45 -0
- Thunder/utils/custom_dl.py +396 -0
- Thunder/utils/database.py +228 -0
- Thunder/utils/decorators.py +119 -0
- Thunder/utils/file_properties.py +75 -0
- Thunder/utils/force_channel.py +99 -0
- Thunder/utils/human_readable.py +16 -0
- Thunder/utils/keepalive.py +22 -0
- Thunder/utils/logger.py +42 -0
- Thunder/utils/messages.py +433 -0
- Thunder/utils/render_template.py +47 -0
- Thunder/utils/shortener.py +243 -0
- Thunder/utils/time_format.py +18 -0
- Thunder/utils/tokens.py +265 -0
- Thunder/vars.py +111 -0
- config_sample.env +83 -0
- requirements.txt +15 -0
.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
|