Spaces:
Running
Running
Fyaz Mohammed commited on
Fixed token logic, refactored byte streamer, client selection and workload handling
Browse files- **New Features**
- Added explicit token activation tracking and validation for improved access control.
- Enhanced error messages for invalid or expired activation links and token requirements.
- **Refactor**
- Simplified and improved performance of media streaming and file handling.
- Streamlined token generation and management with clearer activation logic.
- Optimised client selection and workload handling for streaming requests.
- Refined command handling with improved error logging and message parsing.
- **Bug Fixes**
- Improved error handling and user feedback for invalid commands and payloads.
- **Style**
- Updated user-facing messages for clarity and consistency.
- Thunder/bot/plugins/common.py +426 -431
- Thunder/server/stream_routes.py +154 -234
- Thunder/utils/custom_dl.py +43 -354
- Thunder/utils/database.py +183 -190
- Thunder/utils/decorators.py +162 -147
- Thunder/utils/messages.py +3 -7
- Thunder/utils/tokens.py +112 -139
Thunder/bot/plugins/common.py
CHANGED
|
@@ -1,431 +1,426 @@
|
|
| 1 |
-
# Thunder/bot/plugins/common.py
|
| 2 |
-
|
| 3 |
-
import time
|
| 4 |
-
import asyncio
|
| 5 |
-
import uuid
|
| 6 |
-
from
|
| 7 |
-
from
|
| 8 |
-
from
|
| 9 |
-
from pyrogram
|
| 10 |
-
from pyrogram.
|
| 11 |
-
from pyrogram.
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
from Thunder.
|
| 21 |
-
from Thunder.
|
| 22 |
-
from Thunder.utils.
|
| 23 |
-
from Thunder.utils.
|
| 24 |
-
from Thunder.utils.
|
| 25 |
-
from Thunder.utils.
|
| 26 |
-
from Thunder.utils.
|
| 27 |
-
from Thunder.utils.
|
| 28 |
-
from Thunder.utils.
|
| 29 |
-
from Thunder.utils.
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
@
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
if
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
"
|
| 310 |
-
"
|
| 311 |
-
"
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
if
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
await
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
logger.warning(f"Error deleting command messages: {e}")
|
| 428 |
-
except Exception as e:
|
| 429 |
-
await handle_callback_error(callback_query, e, "close_panel_callback")
|
| 430 |
-
finally:
|
| 431 |
-
raise StopPropagation
|
|
|
|
| 1 |
+
# Thunder/bot/plugins/common.py
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import asyncio
|
| 5 |
+
import uuid
|
| 6 |
+
from datetime import datetime, timedelta
|
| 7 |
+
from typing import Tuple, Optional
|
| 8 |
+
from urllib.parse import quote_plus
|
| 9 |
+
from pyrogram import filters, StopPropagation
|
| 10 |
+
from pyrogram.client import Client
|
| 11 |
+
from pyrogram.errors import RPCError, MessageNotModified
|
| 12 |
+
from pyrogram.types import (
|
| 13 |
+
InlineKeyboardButton,
|
| 14 |
+
InlineKeyboardMarkup,
|
| 15 |
+
Message,
|
| 16 |
+
User,
|
| 17 |
+
CallbackQuery,
|
| 18 |
+
LinkPreviewOptions
|
| 19 |
+
)
|
| 20 |
+
from Thunder.bot import StreamBot
|
| 21 |
+
from Thunder.vars import Var
|
| 22 |
+
from Thunder.utils.database import db
|
| 23 |
+
from Thunder.utils.messages import *
|
| 24 |
+
from Thunder.utils.human_readable import humanbytes
|
| 25 |
+
from Thunder.utils.file_properties import get_media_file_size, get_name
|
| 26 |
+
from Thunder.utils.force_channel import force_channel_check
|
| 27 |
+
from Thunder.utils.logger import logger
|
| 28 |
+
from Thunder.utils.tokens import check
|
| 29 |
+
from Thunder.utils.decorators import check_banned
|
| 30 |
+
from Thunder.utils.bot_utils import (
|
| 31 |
+
notify_channel,
|
| 32 |
+
log_new_user,
|
| 33 |
+
generate_media_links,
|
| 34 |
+
handle_user_error,
|
| 35 |
+
generate_dc_text,
|
| 36 |
+
get_user_safely
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
def has_media(message):
|
| 40 |
+
return any(
|
| 41 |
+
hasattr(message, attr) and getattr(message, attr)
|
| 42 |
+
for attr in ["document", "photo", "video", "audio", "voice",
|
| 43 |
+
"sticker", "animation", "video_note"]
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
@check_banned
|
| 47 |
+
@StreamBot.on_message(filters.command("start") & filters.private)
|
| 48 |
+
async def start_command(bot: Client, message: Message):
|
| 49 |
+
user_id = message.from_user.id if message.from_user else None
|
| 50 |
+
user_id_str = str(user_id) if user_id else 'unknown'
|
| 51 |
+
|
| 52 |
+
try:
|
| 53 |
+
if message.from_user:
|
| 54 |
+
await log_new_user(bot, user_id, message.from_user.first_name)
|
| 55 |
+
|
| 56 |
+
if len(message.command) == 2:
|
| 57 |
+
payload = message.command[1]
|
| 58 |
+
|
| 59 |
+
token_doc = await db.token_col.find_one({"token": payload})
|
| 60 |
+
|
| 61 |
+
if token_doc:
|
| 62 |
+
if token_doc["user_id"] == user_id:
|
| 63 |
+
if not token_doc.get("activated", False):
|
| 64 |
+
token_expires_at = token_doc.get("expires_at")
|
| 65 |
+
if isinstance(token_expires_at, datetime) and token_expires_at > datetime.utcnow():
|
| 66 |
+
await db.token_col.update_one(
|
| 67 |
+
{"token": payload, "user_id": user_id},
|
| 68 |
+
{"$set": {"activated": True}}
|
| 69 |
+
)
|
| 70 |
+
duration_hours = getattr(Var, "TOKEN_TTL_HOURS", 24)
|
| 71 |
+
await message.reply_text(
|
| 72 |
+
MSG_TOKEN_ACTIVATED.format(duration_hours=duration_hours),
|
| 73 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 74 |
+
quote=True
|
| 75 |
+
)
|
| 76 |
+
else:
|
| 77 |
+
await message.reply_text(
|
| 78 |
+
MSG_TOKEN_FAILED.format(reason="This activation link has expired.", error_id=uuid.uuid4().hex[:8]),
|
| 79 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 80 |
+
quote=True
|
| 81 |
+
)
|
| 82 |
+
else:
|
| 83 |
+
await message.reply_text(
|
| 84 |
+
MSG_TOKEN_FAILED.format(reason="Token has already been activated.", error_id=uuid.uuid4().hex[:8]),
|
| 85 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 86 |
+
quote=True
|
| 87 |
+
)
|
| 88 |
+
else:
|
| 89 |
+
logger.warning(f"User {user_id} attempted to activate token {payload} belonging to user {token_doc.get('user_id')}")
|
| 90 |
+
await message.reply_text(
|
| 91 |
+
MSG_TOKEN_FAILED.format(reason="This activation link is not for your account.", error_id=uuid.uuid4().hex[:8]),
|
| 92 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 93 |
+
quote=True
|
| 94 |
+
)
|
| 95 |
+
return
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
msg_id = int(payload)
|
| 99 |
+
retrieved_messages = await bot.get_messages(chat_id=Var.BIN_CHANNEL, message_ids=msg_id)
|
| 100 |
+
if not retrieved_messages:
|
| 101 |
+
await handle_user_error(message, MSG_ERROR_FILE_INVALID)
|
| 102 |
+
return
|
| 103 |
+
|
| 104 |
+
file_msg = retrieved_messages[0] if isinstance(retrieved_messages, list) else retrieved_messages
|
| 105 |
+
if not file_msg:
|
| 106 |
+
await handle_user_error(message, MSG_ERROR_FILE_INVALID)
|
| 107 |
+
return
|
| 108 |
+
|
| 109 |
+
stream_link, download_link, file_name, file_size = await generate_media_links(file_msg)
|
| 110 |
+
reply_text = MSG_LINKS.format(
|
| 111 |
+
file_name=file_name,
|
| 112 |
+
file_size=file_size,
|
| 113 |
+
download_link=download_link,
|
| 114 |
+
stream_link=stream_link
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
await message.reply_text(
|
| 118 |
+
text=reply_text,
|
| 119 |
+
reply_markup=InlineKeyboardMarkup([
|
| 120 |
+
[
|
| 121 |
+
InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
|
| 122 |
+
InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=download_link)
|
| 123 |
+
]
|
| 124 |
+
]),
|
| 125 |
+
quote=True,
|
| 126 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 127 |
+
)
|
| 128 |
+
return
|
| 129 |
+
except ValueError:
|
| 130 |
+
logger.warning(f"Invalid /start payload from user {user_id_str}: {payload}")
|
| 131 |
+
await message.reply_text(
|
| 132 |
+
MSG_START_INVALID_PAYLOAD.format(error_id=uuid.uuid4().hex[:8]),
|
| 133 |
+
quote=True,
|
| 134 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 135 |
+
)
|
| 136 |
+
return
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.error(f"Error processing /start payload '{payload}' for user {user_id_str}: {e}")
|
| 139 |
+
await handle_user_error(message, MSG_FILE_ACCESS_ERROR)
|
| 140 |
+
return
|
| 141 |
+
|
| 142 |
+
parts = message.text.strip().split(maxsplit=1)
|
| 143 |
+
if len(message.command) == 1 or (len(parts) > 1 and parts[1].lower() == "start"):
|
| 144 |
+
welcome_text = MSG_WELCOME.format(user_name=message.from_user.first_name if message.from_user else "Guest")
|
| 145 |
+
|
| 146 |
+
if Var.FORCE_CHANNEL_ID:
|
| 147 |
+
error_id_context = uuid.uuid4().hex[:8]
|
| 148 |
+
try:
|
| 149 |
+
chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
|
| 150 |
+
invite_link = getattr(chat, 'invite_link', None)
|
| 151 |
+
chat_username = getattr(chat, 'username', None)
|
| 152 |
+
chat_title = getattr(chat, 'title', 'Channel')
|
| 153 |
+
|
| 154 |
+
if not invite_link and chat_username:
|
| 155 |
+
invite_link = f"https://t.me/{chat_username}"
|
| 156 |
+
|
| 157 |
+
if invite_link:
|
| 158 |
+
welcome_text += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=chat_title)}"
|
| 159 |
+
else:
|
| 160 |
+
logger.warning(f"(ID: {error_id_context}) Could not retrieve invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /start message text (Channel: {chat_title}, User: {user_id_str}).")
|
| 161 |
+
except Exception as e:
|
| 162 |
+
logger.error(f"(ID: {error_id_context}) Error adding force channel link to /start message text (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e}")
|
| 163 |
+
|
| 164 |
+
reply_markup_buttons = [
|
| 165 |
+
[
|
| 166 |
+
InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
|
| 167 |
+
InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command"),
|
| 168 |
+
],
|
| 169 |
+
[
|
| 170 |
+
InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"),
|
| 171 |
+
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")
|
| 172 |
+
]
|
| 173 |
+
]
|
| 174 |
+
|
| 175 |
+
if Var.FORCE_CHANNEL_ID:
|
| 176 |
+
try:
|
| 177 |
+
chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
|
| 178 |
+
invite_link = getattr(chat, 'invite_link', None)
|
| 179 |
+
chat_username = getattr(chat, 'username', None)
|
| 180 |
+
if not invite_link and chat_username: invite_link = f"https://t.me/{chat_username}"
|
| 181 |
+
if invite_link:
|
| 182 |
+
reply_markup_buttons.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=getattr(chat, 'title', 'Channel')), url=invite_link)])
|
| 183 |
+
except Exception as e:
|
| 184 |
+
logger.error(f"Error adding force channel button to /start message (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e}")
|
| 185 |
+
|
| 186 |
+
reply_markup = InlineKeyboardMarkup(reply_markup_buttons)
|
| 187 |
+
await message.reply_text(text=welcome_text, reply_markup=reply_markup, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
|
| 188 |
+
return
|
| 189 |
+
|
| 190 |
+
except Exception as e:
|
| 191 |
+
logger.error(f"Error in start_command for user {user_id_str}: {e}")
|
| 192 |
+
await handle_user_error(message, MSG_ERROR_GENERIC)
|
| 193 |
+
|
| 194 |
+
@check_banned
|
| 195 |
+
@StreamBot.on_message(filters.command("help") & filters.private)
|
| 196 |
+
async def help_command(bot: Client, message: Message):
|
| 197 |
+
try:
|
| 198 |
+
user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
|
| 199 |
+
if message.from_user:
|
| 200 |
+
await log_new_user(bot, message.from_user.id, message.from_user.first_name)
|
| 201 |
+
|
| 202 |
+
help_text = MSG_HELP
|
| 203 |
+
buttons = [
|
| 204 |
+
[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]
|
| 205 |
+
]
|
| 206 |
+
|
| 207 |
+
if Var.FORCE_CHANNEL_ID:
|
| 208 |
+
error_id_context_help = uuid.uuid4().hex[:8]
|
| 209 |
+
try:
|
| 210 |
+
chat = await bot.get_chat(Var.FORCE_CHANNEL_ID)
|
| 211 |
+
invite_link = getattr(chat, 'invite_link', None)
|
| 212 |
+
chat_username = getattr(chat, 'username', None)
|
| 213 |
+
chat_title = getattr(chat, 'title', 'Channel')
|
| 214 |
+
|
| 215 |
+
if not invite_link and chat_username:
|
| 216 |
+
invite_link = f"https://t.me/{chat_username}"
|
| 217 |
+
|
| 218 |
+
if invite_link:
|
| 219 |
+
buttons.append([
|
| 220 |
+
InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat_title), url=invite_link)
|
| 221 |
+
])
|
| 222 |
+
else:
|
| 223 |
+
logger.warning(f"(ID: {error_id_context_help}) Could not retrieve or construct invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /help command (User: {user_id_str}).")
|
| 224 |
+
except Exception as e_help:
|
| 225 |
+
logger.error(f"(ID: {error_id_context_help}) Error adding force channel button to /help command (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_help}")
|
| 226 |
+
|
| 227 |
+
buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")])
|
| 228 |
+
reply_markup = InlineKeyboardMarkup(buttons)
|
| 229 |
+
|
| 230 |
+
await message.reply_text(
|
| 231 |
+
text=help_text,
|
| 232 |
+
reply_markup=reply_markup,
|
| 233 |
+
quote=True,
|
| 234 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 235 |
+
)
|
| 236 |
+
except Exception as e:
|
| 237 |
+
logger.error(f"Error in help_command: {e}")
|
| 238 |
+
await handle_user_error(message, MSG_ERROR_GENERIC)
|
| 239 |
+
|
| 240 |
+
@check_banned
|
| 241 |
+
@StreamBot.on_message(filters.command("about") & filters.private)
|
| 242 |
+
async def about_command(bot: Client, message: Message):
|
| 243 |
+
try:
|
| 244 |
+
if message.from_user:
|
| 245 |
+
await log_new_user(bot, message.from_user.id, message.from_user.first_name)
|
| 246 |
+
|
| 247 |
+
about_text = MSG_ABOUT
|
| 248 |
+
buttons = [
|
| 249 |
+
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")],
|
| 250 |
+
[InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink/"),
|
| 251 |
+
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 252 |
+
]
|
| 253 |
+
reply_markup = InlineKeyboardMarkup(buttons)
|
| 254 |
+
|
| 255 |
+
await message.reply_text(
|
| 256 |
+
text=about_text,
|
| 257 |
+
reply_markup=reply_markup,
|
| 258 |
+
quote=True,
|
| 259 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 260 |
+
)
|
| 261 |
+
except Exception as e:
|
| 262 |
+
logger.error(f"Error in about_command: {e}")
|
| 263 |
+
await handle_user_error(message, MSG_ERROR_GENERIC)
|
| 264 |
+
|
| 265 |
+
@check_banned
|
| 266 |
+
@force_channel_check
|
| 267 |
+
@StreamBot.on_message(filters.command("dc"))
|
| 268 |
+
async def dc_command(bot: Client, message: Message):
|
| 269 |
+
try:
|
| 270 |
+
if not message.from_user and not message.reply_to_message:
|
| 271 |
+
await handle_user_error(message, MSG_DC_ANON_ERROR)
|
| 272 |
+
return
|
| 273 |
+
|
| 274 |
+
async def process_dc_info(user: User):
|
| 275 |
+
dc_text = await generate_dc_text(user)
|
| 276 |
+
buttons = []
|
| 277 |
+
|
| 278 |
+
if user.username:
|
| 279 |
+
profile_url = f"https://t.me/{user.username}"
|
| 280 |
+
else:
|
| 281 |
+
profile_url = f"tg://user?id={user.id}"
|
| 282 |
+
|
| 283 |
+
buttons.append([
|
| 284 |
+
InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=profile_url)
|
| 285 |
+
])
|
| 286 |
+
buttons.append([
|
| 287 |
+
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")
|
| 288 |
+
])
|
| 289 |
+
|
| 290 |
+
dc_keyboard = InlineKeyboardMarkup(buttons)
|
| 291 |
+
await message.reply_text(
|
| 292 |
+
dc_text,
|
| 293 |
+
reply_markup=dc_keyboard,
|
| 294 |
+
quote=True,
|
| 295 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
async def process_file_dc_info(file_msg: Message):
|
| 299 |
+
try:
|
| 300 |
+
file_name = get_name(file_msg) or "Untitled File"
|
| 301 |
+
file_size = humanbytes(get_media_file_size(file_msg))
|
| 302 |
+
|
| 303 |
+
file_type_map = {
|
| 304 |
+
"document": MSG_FILE_TYPE_DOCUMENT,
|
| 305 |
+
"photo": MSG_FILE_TYPE_PHOTO,
|
| 306 |
+
"video": MSG_FILE_TYPE_VIDEO,
|
| 307 |
+
"audio": MSG_FILE_TYPE_AUDIO,
|
| 308 |
+
"voice": MSG_FILE_TYPE_VOICE,
|
| 309 |
+
"sticker": MSG_FILE_TYPE_STICKER,
|
| 310 |
+
"animation": MSG_FILE_TYPE_ANIMATION,
|
| 311 |
+
"video_note": MSG_FILE_TYPE_VIDEO_NOTE
|
| 312 |
+
}
|
| 313 |
+
|
| 314 |
+
file_type_attr = next((attr for attr in file_type_map if getattr(file_msg, attr, None)), "unknown")
|
| 315 |
+
file_type_display = file_type_map.get(file_type_attr, MSG_FILE_TYPE_UNKNOWN)
|
| 316 |
+
|
| 317 |
+
actual_media = None
|
| 318 |
+
if file_type_attr == "photo" and file_msg.photo and file_msg.photo.sizes:
|
| 319 |
+
actual_media = file_msg.photo.sizes[-1]
|
| 320 |
+
elif file_type_attr != "unknown":
|
| 321 |
+
potential = getattr(file_msg, file_type_attr, None)
|
| 322 |
+
if potential:
|
| 323 |
+
actual_media = potential
|
| 324 |
+
|
| 325 |
+
dc_id = MSG_DC_UNKNOWN
|
| 326 |
+
if hasattr(file_msg, 'raw') and hasattr(file_msg.raw, 'media'):
|
| 327 |
+
if hasattr(file_msg.raw.media, 'document') and hasattr(file_msg.raw.media.document, 'dc_id'):
|
| 328 |
+
dc_id = file_msg.raw.media.document.dc_id
|
| 329 |
+
|
| 330 |
+
dc_text = MSG_DC_FILE_INFO.format(
|
| 331 |
+
file_name=file_name,
|
| 332 |
+
file_size=file_size,
|
| 333 |
+
file_type=file_type_display,
|
| 334 |
+
dc_id=dc_id
|
| 335 |
+
)
|
| 336 |
+
|
| 337 |
+
await message.reply_text(
|
| 338 |
+
dc_text,
|
| 339 |
+
quote=True,
|
| 340 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 341 |
+
)
|
| 342 |
+
except Exception as e:
|
| 343 |
+
logger.error(f"Error processing file info for DC command: {e}")
|
| 344 |
+
await handle_user_error(message, MSG_DC_FILE_ERROR)
|
| 345 |
+
|
| 346 |
+
args = message.text.strip().split(maxsplit=1)
|
| 347 |
+
if len(args) > 1:
|
| 348 |
+
query = args[1].strip()
|
| 349 |
+
user = await get_user_safely(bot, query)
|
| 350 |
+
if user:
|
| 351 |
+
await process_dc_info(user)
|
| 352 |
+
else:
|
| 353 |
+
await handle_user_error(message, MSG_ERROR_USER_INFO)
|
| 354 |
+
return
|
| 355 |
+
|
| 356 |
+
if message.reply_to_message:
|
| 357 |
+
if has_media(message.reply_to_message):
|
| 358 |
+
await process_file_dc_info(message.reply_to_message)
|
| 359 |
+
return
|
| 360 |
+
elif message.reply_to_message.from_user:
|
| 361 |
+
await process_dc_info(message.reply_to_message.from_user)
|
| 362 |
+
return
|
| 363 |
+
else:
|
| 364 |
+
await handle_user_error(message, MSG_DC_INVALID_USAGE)
|
| 365 |
+
return
|
| 366 |
+
|
| 367 |
+
if message.from_user:
|
| 368 |
+
await process_dc_info(message.from_user)
|
| 369 |
+
else:
|
| 370 |
+
await handle_user_error(message, MSG_DC_ANON_ERROR)
|
| 371 |
+
except Exception as e:
|
| 372 |
+
logger.error(f"Error in dc_command: {e}")
|
| 373 |
+
await handle_user_error(message, MSG_ERROR_GENERIC)
|
| 374 |
+
|
| 375 |
+
@check_banned
|
| 376 |
+
@force_channel_check
|
| 377 |
+
@StreamBot.on_message(filters.command("ping") & filters.private)
|
| 378 |
+
async def ping_command(bot: Client, message: Message):
|
| 379 |
+
try:
|
| 380 |
+
start_time = time.time()
|
| 381 |
+
sent_msg = await message.reply_text(MSG_PING_START, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
|
| 382 |
+
end_time = time.time()
|
| 383 |
+
time_taken_ms = (end_time - start_time) * 1000
|
| 384 |
+
|
| 385 |
+
buttons = [
|
| 386 |
+
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
|
| 387 |
+
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 388 |
+
]
|
| 389 |
+
|
| 390 |
+
await sent_msg.edit_text(
|
| 391 |
+
MSG_PING_RESPONSE.format(time_taken_ms=time_taken_ms),
|
| 392 |
+
reply_markup=InlineKeyboardMarkup(buttons),
|
| 393 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 394 |
+
)
|
| 395 |
+
except Exception as e:
|
| 396 |
+
logger.error(f"Error in ping_command: {e}")
|
| 397 |
+
await handle_user_error(message, MSG_ERROR_GENERIC)
|
| 398 |
+
|
| 399 |
+
async def handle_callback_error(callback_query, error, operation="callback"):
|
| 400 |
+
error_id = uuid.uuid4().hex[:8]
|
| 401 |
+
logger.error(f"Error in {operation}: {error}")
|
| 402 |
+
try:
|
| 403 |
+
await callback_query.answer(
|
| 404 |
+
MSG_ERROR_GENERIC_CALLBACK.format(error_id=error_id),
|
| 405 |
+
show_alert=True
|
| 406 |
+
)
|
| 407 |
+
except Exception as e:
|
| 408 |
+
logger.error(f"Failed to send error callback: {e}")
|
| 409 |
+
|
| 410 |
+
@StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
|
| 411 |
+
async def close_panel_callback(client: Client, callback_query: CallbackQuery):
|
| 412 |
+
try:
|
| 413 |
+
await callback_query.answer()
|
| 414 |
+
await callback_query.message.delete()
|
| 415 |
+
if callback_query.message.reply_to_message:
|
| 416 |
+
try:
|
| 417 |
+
ctx = callback_query.message.reply_to_message
|
| 418 |
+
await ctx.delete()
|
| 419 |
+
if ctx.reply_to_message:
|
| 420 |
+
await ctx.reply_to_message.delete()
|
| 421 |
+
except Exception as e:
|
| 422 |
+
logger.warning(f"Error deleting command messages: {e}")
|
| 423 |
+
except Exception as e:
|
| 424 |
+
await handle_callback_error(callback_query, e, "close_panel_callback")
|
| 425 |
+
finally:
|
| 426 |
+
raise StopPropagation
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Thunder/server/stream_routes.py
CHANGED
|
@@ -1,21 +1,10 @@
|
|
| 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 |
-
|
| 19 |
from Thunder import StartTime, __version__
|
| 20 |
from Thunder.bot import multi_clients, StreamBot, work_loads
|
| 21 |
from Thunder.server.exceptions import FileNotFound, InvalidHash
|
|
@@ -23,149 +12,96 @@ from Thunder.utils.custom_dl import ByteStreamer
|
|
| 23 |
from Thunder.utils.logger import logger
|
| 24 |
from Thunder.utils.render_template import render_page
|
| 25 |
from Thunder.utils.time_format import get_readable_time
|
| 26 |
-
from Thunder.vars import Var
|
| 27 |
|
| 28 |
routes = web.RouteTableDef()
|
| 29 |
|
| 30 |
-
# Configuration constants.
|
| 31 |
SECURE_HASH_LENGTH = 6
|
| 32 |
-
CHUNK_SIZE =
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
# Updated regex: Correctly capture start and end values with named groups.
|
| 37 |
PATTERN_HASH_FIRST = re.compile(rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$")
|
| 38 |
PATTERN_ID_FIRST = re.compile(r"^(\d+)(?:/.*)?$")
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
# Global LRU cache for ByteStreamer instances.
|
| 42 |
-
class_cache = LRUCache(maxsize=Var.CACHE_SIZE)
|
| 43 |
-
cache_lock = asyncio.Lock()
|
| 44 |
|
| 45 |
-
|
| 46 |
|
| 47 |
-
def
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
@wraps(func)
|
| 52 |
-
async def wrapper(request):
|
| 53 |
-
try:
|
| 54 |
-
return await func(request)
|
| 55 |
-
except InvalidHash as e:
|
| 56 |
-
logger.debug(f"InvalidHash exception: {e}")
|
| 57 |
-
raise web.HTTPForbidden(
|
| 58 |
-
text=json_error(403, "Invalid security credentials"),
|
| 59 |
-
content_type="application/json"
|
| 60 |
-
)
|
| 61 |
-
except FileNotFound as e:
|
| 62 |
-
logger.debug(f"FileNotFound exception: {e}")
|
| 63 |
-
raise web.HTTPNotFound(
|
| 64 |
-
text=json_error(404, "File not found"),
|
| 65 |
-
content_type="application/json"
|
| 66 |
-
)
|
| 67 |
-
except (ClientConnectionError, asyncio.CancelledError):
|
| 68 |
-
return web.Response(status=499)
|
| 69 |
-
except web.HTTPException:
|
| 70 |
-
raise
|
| 71 |
-
except Exception as e:
|
| 72 |
-
error_id = secrets.token_hex(6)
|
| 73 |
-
logger.error(f"Unhandled exception (ID: {error_id}): {str(e)}")
|
| 74 |
-
logger.error(f"Stack trace for error {error_id}:\n{traceback.format_exc()}")
|
| 75 |
-
|
| 76 |
-
raise web.HTTPInternalServerError(
|
| 77 |
-
text=json_error(500, f"Internal server error (Reference ID: {error_id})"),
|
| 78 |
-
content_type="application/json"
|
| 79 |
-
)
|
| 80 |
-
return wrapper
|
| 81 |
|
| 82 |
def parse_media_request(path: str, query: dict) -> tuple[int, str]:
|
| 83 |
clean_path = unquote(path).strip('/')
|
|
|
|
| 84 |
match = PATTERN_HASH_FIRST.match(clean_path)
|
| 85 |
if match:
|
| 86 |
try:
|
| 87 |
message_id = int(match.group(2))
|
| 88 |
secure_hash = match.group(1)
|
| 89 |
-
if
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
match = PATTERN_ID_FIRST.match(clean_path)
|
| 95 |
if match:
|
| 96 |
try:
|
| 97 |
message_id = int(match.group(1))
|
| 98 |
secure_hash = query.get("hash", "").strip()
|
| 99 |
-
if len(secure_hash)
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
except ValueError:
|
| 105 |
-
raise InvalidHash("Invalid message ID format")
|
| 106 |
raise InvalidHash("Invalid URL structure")
|
| 107 |
|
| 108 |
-
def
|
| 109 |
if not work_loads:
|
| 110 |
-
raise web.HTTPInternalServerError(
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
async with cache_lock:
|
| 121 |
-
streamer = class_cache.get(client)
|
| 122 |
-
if streamer is None:
|
| 123 |
-
streamer = ByteStreamer(client)
|
| 124 |
-
class_cache[client] = streamer
|
| 125 |
-
return streamer
|
| 126 |
-
|
| 127 |
-
def sanitize_filename(filename: str) -> str:
|
| 128 |
-
return re.sub(r'[\n\r";]', '', filename)
|
| 129 |
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
@routes.get("/", allow_head=True)
|
| 139 |
-
@exception_handler
|
| 140 |
async def root_redirect(request):
|
| 141 |
raise web.HTTPFound("https://github.com/fyaz05/FileToLink")
|
| 142 |
|
| 143 |
@routes.get("/status", allow_head=True)
|
| 144 |
async def status_endpoint(request):
|
| 145 |
-
|
| 146 |
-
uptime_formatted = get_readable_time(uptime_seconds)
|
| 147 |
-
|
| 148 |
-
sorted_workloads = dict(sorted(
|
| 149 |
-
{f"client_{k}": v for k, v in work_loads.items()}.items(),
|
| 150 |
-
key=lambda item: item[1]
|
| 151 |
-
))
|
| 152 |
-
|
| 153 |
total_load = sum(work_loads.values())
|
| 154 |
-
if total_load < MAX_CLIENTS * 0.5:
|
| 155 |
-
status_level = "optimal"
|
| 156 |
-
elif total_load < MAX_CLIENTS * 0.8:
|
| 157 |
-
status_level = "high"
|
| 158 |
-
else:
|
| 159 |
-
status_level = "critical"
|
| 160 |
|
| 161 |
-
|
|
|
|
|
|
|
| 162 |
"server": {
|
| 163 |
"status": "operational",
|
| 164 |
-
"status_level": status_level,
|
| 165 |
"version": __version__,
|
| 166 |
-
"uptime":
|
| 167 |
-
"uptime_seconds": int(uptime_seconds),
|
| 168 |
-
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime())
|
| 169 |
},
|
| 170 |
"telegram_bot": {
|
| 171 |
"username": f"@{StreamBot.username}",
|
|
@@ -173,131 +109,115 @@ async def status_endpoint(request):
|
|
| 173 |
},
|
| 174 |
"resources": {
|
| 175 |
"total_workload": total_load,
|
| 176 |
-
"
|
| 177 |
-
"workload_distribution": sorted_workloads,
|
| 178 |
-
"cache": {
|
| 179 |
-
"current": len(class_cache),
|
| 180 |
-
"maximum": Var.CACHE_SIZE,
|
| 181 |
-
"utilization_percent": round((len(class_cache) / Var.CACHE_SIZE) * 100, 2)
|
| 182 |
-
}
|
| 183 |
-
},
|
| 184 |
-
"system": {
|
| 185 |
-
"chunk_size": f"{CHUNK_SIZE / (1024 * 1024):.1f} MB",
|
| 186 |
-
"thread_pool_workers": THREADPOOL_MAX_WORKERS
|
| 187 |
}
|
| 188 |
-
}
|
| 189 |
-
|
| 190 |
-
return web.json_response(
|
| 191 |
-
status_data,
|
| 192 |
-
dumps=lambda obj: json.dumps(obj, indent=2, sort_keys=False)
|
| 193 |
-
)
|
| 194 |
|
| 195 |
@routes.get(r"/watch/{path:.+}", allow_head=True)
|
| 196 |
-
@exception_handler
|
| 197 |
async def media_preview(request: web.Request):
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
content_type='text/html'
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
)
|
| 213 |
|
| 214 |
@routes.get(r"/{path:.+}", allow_head=True)
|
| 215 |
-
@exception_handler
|
| 216 |
async def media_delivery(request: web.Request):
|
| 217 |
-
|
| 218 |
-
async with track_workload(client_id):
|
| 219 |
path = request.match_info["path"]
|
| 220 |
message_id, secure_hash = parse_media_request(path, request.query)
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
if file_meta.unique_id[:SECURE_HASH_LENGTH] != secure_hash:
|
| 227 |
-
raise InvalidHash("Security token mismatch")
|
| 228 |
-
file_size = file_meta.file_size
|
| 229 |
-
range_header = request.headers.get("Range", "")
|
| 230 |
-
if range_header:
|
| 231 |
-
range_match = RANGE_REGEX.fullmatch(range_header)
|
| 232 |
-
if not range_match:
|
| 233 |
-
logger.debug(f"Malformed range header received: {range_header}")
|
| 234 |
-
raise web.HTTPBadRequest(
|
| 235 |
-
text=json_error(400, "Malformed range header"),
|
| 236 |
-
content_type="application/json"
|
| 237 |
-
)
|
| 238 |
-
start = int(range_match.group("start")) if range_match.group("start") else 0
|
| 239 |
-
end = int(range_match.group("end")) if range_match.group("end") else file_size - 1
|
| 240 |
-
else:
|
| 241 |
try:
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
except Exception as e:
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
except Exception:
|
| 303 |
-
pass
|
|
|
|
| 1 |
# Thunder/server/stream_routes.py
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
import re
|
| 4 |
import secrets
|
| 5 |
import time
|
|
|
|
|
|
|
|
|
|
| 6 |
from aiohttp import web
|
|
|
|
|
|
|
|
|
|
| 7 |
from urllib.parse import unquote, quote
|
|
|
|
|
|
|
| 8 |
from Thunder import StartTime, __version__
|
| 9 |
from Thunder.bot import multi_clients, StreamBot, work_loads
|
| 10 |
from Thunder.server.exceptions import FileNotFound, InvalidHash
|
|
|
|
| 12 |
from Thunder.utils.logger import logger
|
| 13 |
from Thunder.utils.render_template import render_page
|
| 14 |
from Thunder.utils.time_format import get_readable_time
|
|
|
|
| 15 |
|
| 16 |
routes = web.RouteTableDef()
|
| 17 |
|
|
|
|
| 18 |
SECURE_HASH_LENGTH = 6
|
| 19 |
+
CHUNK_SIZE = 1024 * 1024
|
| 20 |
+
MAX_CONCURRENT_PER_CLIENT = 8
|
| 21 |
+
RANGE_REGEX = re.compile(r"bytes=(?P<start>\d*)-(?P<end>\d*)")
|
|
|
|
|
|
|
| 22 |
PATTERN_HASH_FIRST = re.compile(rf"^([a-zA-Z0-9_-]{{{SECURE_HASH_LENGTH}}})(\d+)(?:/.*)?$")
|
| 23 |
PATTERN_ID_FIRST = re.compile(r"^(\d+)(?:/.*)?$")
|
| 24 |
+
VALID_HASH_REGEX = re.compile(r'^[a-zA-Z0-9_-]+$')
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
+
streamers = {}
|
| 27 |
|
| 28 |
+
def get_streamer(client_id: int) -> ByteStreamer:
|
| 29 |
+
if client_id not in streamers:
|
| 30 |
+
streamers[client_id] = ByteStreamer(multi_clients[client_id])
|
| 31 |
+
return streamers[client_id]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
def parse_media_request(path: str, query: dict) -> tuple[int, str]:
|
| 34 |
clean_path = unquote(path).strip('/')
|
| 35 |
+
|
| 36 |
match = PATTERN_HASH_FIRST.match(clean_path)
|
| 37 |
if match:
|
| 38 |
try:
|
| 39 |
message_id = int(match.group(2))
|
| 40 |
secure_hash = match.group(1)
|
| 41 |
+
if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash):
|
| 42 |
+
return message_id, secure_hash
|
| 43 |
+
except ValueError as e:
|
| 44 |
+
raise InvalidHash("Invalid message ID format") from e
|
| 45 |
+
|
| 46 |
match = PATTERN_ID_FIRST.match(clean_path)
|
| 47 |
if match:
|
| 48 |
try:
|
| 49 |
message_id = int(match.group(1))
|
| 50 |
secure_hash = query.get("hash", "").strip()
|
| 51 |
+
if len(secure_hash) == SECURE_HASH_LENGTH and VALID_HASH_REGEX.match(secure_hash):
|
| 52 |
+
return message_id, secure_hash
|
| 53 |
+
except ValueError as e:
|
| 54 |
+
raise InvalidHash("Invalid message ID format") from e
|
| 55 |
+
|
|
|
|
|
|
|
| 56 |
raise InvalidHash("Invalid URL structure")
|
| 57 |
|
| 58 |
+
def select_optimal_client() -> tuple[int, ByteStreamer]:
|
| 59 |
if not work_loads:
|
| 60 |
+
raise web.HTTPInternalServerError(text="No available clients")
|
| 61 |
+
|
| 62 |
+
available_clients = [(cid, load) for cid, load in work_loads.items() if load < MAX_CONCURRENT_PER_CLIENT]
|
| 63 |
+
|
| 64 |
+
if available_clients:
|
| 65 |
+
client_id = min(available_clients, key=lambda x: x[1])[0]
|
| 66 |
+
else:
|
| 67 |
+
client_id = min(work_loads, key=work_loads.get)
|
| 68 |
+
|
| 69 |
+
return client_id, get_streamer(client_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
| 71 |
+
def parse_range_header(range_header: str, file_size: int) -> tuple[int, int]:
|
| 72 |
+
if not range_header:
|
| 73 |
+
return 0, file_size - 1
|
| 74 |
+
|
| 75 |
+
match = RANGE_REGEX.match(range_header)
|
| 76 |
+
if not match:
|
| 77 |
+
raise web.HTTPBadRequest(text="Invalid range")
|
| 78 |
+
|
| 79 |
+
start = int(match.group("start")) if match.group("start") else 0
|
| 80 |
+
end = int(match.group("end")) if match.group("end") else file_size - 1
|
| 81 |
+
|
| 82 |
+
if start < 0 or end >= file_size or start > end:
|
| 83 |
+
raise web.HTTPRequestRangeNotSatisfiable(
|
| 84 |
+
headers={"Content-Range": f"bytes */{file_size}"}
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
return start, end
|
| 88 |
|
| 89 |
@routes.get("/", allow_head=True)
|
|
|
|
| 90 |
async def root_redirect(request):
|
| 91 |
raise web.HTTPFound("https://github.com/fyaz05/FileToLink")
|
| 92 |
|
| 93 |
@routes.get("/status", allow_head=True)
|
| 94 |
async def status_endpoint(request):
|
| 95 |
+
uptime = time.time() - StartTime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
total_load = sum(work_loads.values())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
+
workload_distribution = {str(k): v for k, v in sorted(work_loads.items())}
|
| 99 |
+
|
| 100 |
+
return web.json_response({
|
| 101 |
"server": {
|
| 102 |
"status": "operational",
|
|
|
|
| 103 |
"version": __version__,
|
| 104 |
+
"uptime": get_readable_time(uptime)
|
|
|
|
|
|
|
| 105 |
},
|
| 106 |
"telegram_bot": {
|
| 107 |
"username": f"@{StreamBot.username}",
|
|
|
|
| 109 |
},
|
| 110 |
"resources": {
|
| 111 |
"total_workload": total_load,
|
| 112 |
+
"workload_distribution": workload_distribution
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
|
| 116 |
@routes.get(r"/watch/{path:.+}", allow_head=True)
|
|
|
|
| 117 |
async def media_preview(request: web.Request):
|
| 118 |
+
try:
|
| 119 |
+
path = request.match_info["path"]
|
| 120 |
+
message_id, secure_hash = parse_media_request(path, request.query)
|
| 121 |
+
|
| 122 |
+
rendered_page = await render_page(message_id, secure_hash, requested_action='stream')
|
| 123 |
+
return web.Response(text=rendered_page, content_type='text/html')
|
| 124 |
+
|
| 125 |
+
except (InvalidHash, FileNotFound) as e:
|
| 126 |
+
logger.debug(f"Client error in preview: {type(e).__name__}")
|
| 127 |
+
raise web.HTTPNotFound(text="Resource not found")
|
| 128 |
+
except Exception as e:
|
| 129 |
+
error_id = secrets.token_hex(6)
|
| 130 |
+
logger.error(f"Preview error {error_id}: {e}")
|
| 131 |
+
raise web.HTTPInternalServerError(text="Server error") from e
|
|
|
|
| 132 |
|
| 133 |
@routes.get(r"/{path:.+}", allow_head=True)
|
|
|
|
| 134 |
async def media_delivery(request: web.Request):
|
| 135 |
+
try:
|
|
|
|
| 136 |
path = request.match_info["path"]
|
| 137 |
message_id, secure_hash = parse_media_request(path, request.query)
|
| 138 |
+
|
| 139 |
+
client_id, streamer = select_optimal_client()
|
| 140 |
+
|
| 141 |
+
work_loads[client_id] += 1
|
| 142 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
try:
|
| 144 |
+
file_info = await streamer.get_file_info(message_id)
|
| 145 |
+
if not file_info.get('unique_id'):
|
| 146 |
+
raise FileNotFound("File not found")
|
| 147 |
+
|
| 148 |
+
if file_info['unique_id'][:SECURE_HASH_LENGTH] != secure_hash:
|
| 149 |
+
raise InvalidHash("Invalid hash")
|
| 150 |
+
|
| 151 |
+
file_size = file_info.get('file_size', 0)
|
| 152 |
+
if file_size == 0:
|
| 153 |
+
raise FileNotFound("File size unavailable")
|
| 154 |
+
|
| 155 |
+
range_header = request.headers.get("Range", "")
|
| 156 |
+
start, end = parse_range_header(range_header, file_size)
|
| 157 |
+
content_length = end - start + 1
|
| 158 |
+
|
| 159 |
+
if start == 0 and end == file_size - 1:
|
| 160 |
+
range_header = ""
|
| 161 |
+
|
| 162 |
+
mime_type = file_info.get('mime_type') or 'application/octet-stream'
|
| 163 |
+
filename = file_info.get('file_name') or f"file_{secrets.token_hex(4)}"
|
| 164 |
+
|
| 165 |
+
headers = {
|
| 166 |
+
"Content-Type": mime_type,
|
| 167 |
+
"Content-Length": str(content_length),
|
| 168 |
+
"Content-Disposition": f"inline; filename*=UTF-8''{quote(filename)}",
|
| 169 |
+
"Accept-Ranges": "bytes",
|
| 170 |
+
"Cache-Control": "public, max-age=31536000",
|
| 171 |
+
"Connection": "keep-alive"
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
if range_header:
|
| 175 |
+
headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
|
| 176 |
+
|
| 177 |
+
async def stream_generator():
|
| 178 |
+
try:
|
| 179 |
+
bytes_sent = 0
|
| 180 |
+
bytes_to_skip = start % CHUNK_SIZE
|
| 181 |
+
|
| 182 |
+
async for chunk in streamer.stream_file(message_id, offset=start, limit=content_length):
|
| 183 |
+
if bytes_to_skip > 0:
|
| 184 |
+
if len(chunk) <= bytes_to_skip:
|
| 185 |
+
bytes_to_skip -= len(chunk)
|
| 186 |
+
continue
|
| 187 |
+
chunk = chunk[bytes_to_skip:]
|
| 188 |
+
bytes_to_skip = 0
|
| 189 |
+
|
| 190 |
+
remaining = content_length - bytes_sent
|
| 191 |
+
if len(chunk) > remaining:
|
| 192 |
+
chunk = chunk[:remaining]
|
| 193 |
+
|
| 194 |
+
if chunk:
|
| 195 |
+
yield chunk
|
| 196 |
+
bytes_sent += len(chunk)
|
| 197 |
+
|
| 198 |
+
if bytes_sent >= content_length:
|
| 199 |
+
break
|
| 200 |
+
finally:
|
| 201 |
+
work_loads[client_id] -= 1
|
| 202 |
+
return web.Response(
|
| 203 |
+
status=206 if range_header else 200,
|
| 204 |
+
body=stream_generator(),
|
| 205 |
+
headers=headers
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
except (FileNotFound, InvalidHash):
|
| 209 |
+
work_loads[client_id] -= 1
|
| 210 |
+
raise
|
| 211 |
+
except Exception as e:
|
| 212 |
+
work_loads[client_id] -= 1
|
| 213 |
+
error_id = secrets.token_hex(6)
|
| 214 |
+
logger.error(f"Stream error {error_id}: {e}")
|
| 215 |
+
raise web.HTTPInternalServerError(text="Server error") from e
|
| 216 |
+
|
| 217 |
+
except (InvalidHash, FileNotFound) as e:
|
| 218 |
+
logger.debug(f"Client error: {type(e).__name__}")
|
| 219 |
+
raise web.HTTPNotFound(text="Resource not found")
|
| 220 |
except Exception as e:
|
| 221 |
+
error_id = secrets.token_hex(6)
|
| 222 |
+
logger.error(f"Server error {error_id}: {e}")
|
| 223 |
+
raise web.HTTPInternalServerError(text="Server error") from e
|
|
|
|
|
|
Thunder/utils/custom_dl.py
CHANGED
|
@@ -1,371 +1,60 @@
|
|
| 1 |
# Thunder/utils/custom_dl.py
|
| 2 |
|
| 3 |
-
import
|
| 4 |
-
import
|
| 5 |
-
import
|
| 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 |
|
| 19 |
def __init__(self, client: Client):
|
| 20 |
self.client = client
|
| 21 |
-
self.
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
self.cache_lock = asyncio.Lock()
|
| 25 |
-
self.media_sessions_lock = asyncio.Lock()
|
| 26 |
-
|
| 27 |
-
self.cache_cleaner_task = asyncio.create_task(self.clean_cache())
|
| 28 |
-
self.session_cleaner_task = asyncio.create_task(self.cleanup_media_sessions())
|
| 29 |
-
|
| 30 |
-
async def shutdown(self):
|
| 31 |
-
if hasattr(self, 'cache_cleaner_task'):
|
| 32 |
-
self.cache_cleaner_task.cancel()
|
| 33 |
-
try:
|
| 34 |
-
await self.cache_cleaner_task
|
| 35 |
-
except asyncio.CancelledError:
|
| 36 |
-
pass
|
| 37 |
-
|
| 38 |
-
if hasattr(self, 'session_cleaner_task'):
|
| 39 |
-
self.session_cleaner_task.cancel()
|
| 40 |
-
try:
|
| 41 |
-
await self.session_cleaner_task
|
| 42 |
-
except asyncio.CancelledError:
|
| 43 |
-
pass
|
| 44 |
-
|
| 45 |
-
for dc_id, session in list(self.client.media_sessions.items()):
|
| 46 |
-
try:
|
| 47 |
-
await session.stop()
|
| 48 |
-
except Exception as e:
|
| 49 |
-
logger.debug(f"Error stopping media session for DC {dc_id}: {e}")
|
| 50 |
-
|
| 51 |
-
self.client.media_sessions.clear()
|
| 52 |
-
|
| 53 |
-
async def get_file_properties(self, message_id: int) -> FileId:
|
| 54 |
-
async with self.cache_lock:
|
| 55 |
-
file_id = self.cached_file_ids.get(message_id)
|
| 56 |
-
|
| 57 |
-
if not file_id:
|
| 58 |
-
file_id = await self.generate_file_properties(message_id)
|
| 59 |
-
async with self.cache_lock:
|
| 60 |
-
self.cached_file_ids[message_id] = file_id
|
| 61 |
-
|
| 62 |
-
return file_id
|
| 63 |
-
|
| 64 |
-
async def generate_file_properties(self, message_id: int) -> FileId:
|
| 65 |
-
file_id = await get_file_ids(self.client, Var.BIN_CHANNEL, message_id)
|
| 66 |
-
|
| 67 |
-
if not file_id:
|
| 68 |
-
logger.debug(f"Message ID {message_id} not found in the channel.")
|
| 69 |
-
raise FileNotFound(f"File with message ID {message_id} not found.")
|
| 70 |
-
|
| 71 |
-
async with self.cache_lock:
|
| 72 |
-
self.cached_file_ids[message_id] = file_id
|
| 73 |
-
|
| 74 |
-
return file_id
|
| 75 |
-
|
| 76 |
-
async def refresh_file_reference(self, file_id: FileId) -> Optional[FileId]:
|
| 77 |
-
logger.debug(f"Refreshing file reference for message ID {file_id.message_id}")
|
| 78 |
-
|
| 79 |
-
async with self.cache_lock:
|
| 80 |
-
cached_ref = self.file_references_cache.get(file_id.message_id)
|
| 81 |
-
if cached_ref and time.time() - cached_ref["timestamp"] < 3600:
|
| 82 |
-
return cached_ref["file_id"]
|
| 83 |
-
|
| 84 |
try:
|
| 85 |
-
message = await self.client.get_messages(
|
| 86 |
-
chat_id=Var.BIN_CHANNEL,
|
| 87 |
-
message_ids=file_id.message_id
|
| 88 |
-
)
|
| 89 |
-
|
| 90 |
if not message or not message.media:
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
new_file_id = await get_file_ids(self.client, Var.BIN_CHANNEL, file_id.message_id)
|
| 95 |
-
if not new_file_id:
|
| 96 |
-
logger.error(f"Failed to extract file ID from message {file_id.message_id}")
|
| 97 |
-
return None
|
| 98 |
-
|
| 99 |
-
async with self.cache_lock:
|
| 100 |
-
self.file_references_cache[file_id.message_id] = {
|
| 101 |
-
"file_id": new_file_id,
|
| 102 |
-
"timestamp": time.time()
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
return new_file_id
|
| 106 |
except Exception as e:
|
| 107 |
-
logger.
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
async def
|
| 111 |
-
|
| 112 |
-
media_session = client.media_sessions.get(file_id.dc_id, None)
|
| 113 |
-
|
| 114 |
-
if media_session is None:
|
| 115 |
-
if file_id.dc_id != await client.storage.dc_id():
|
| 116 |
-
media_session = Session(
|
| 117 |
-
client,
|
| 118 |
-
file_id.dc_id,
|
| 119 |
-
await Auth(
|
| 120 |
-
client, file_id.dc_id, await client.storage.test_mode()
|
| 121 |
-
).create(),
|
| 122 |
-
await client.storage.test_mode(),
|
| 123 |
-
is_media=True,
|
| 124 |
-
)
|
| 125 |
-
await media_session.start()
|
| 126 |
-
|
| 127 |
-
for attempt in range(6):
|
| 128 |
-
if attempt > 0:
|
| 129 |
-
await asyncio.sleep(1 * (2 ** (attempt - 1)))
|
| 130 |
-
|
| 131 |
-
try:
|
| 132 |
-
exported_auth = await client.invoke(
|
| 133 |
-
raw.functions.auth.ExportAuthorization(dc_id=file_id.dc_id)
|
| 134 |
-
)
|
| 135 |
-
|
| 136 |
-
try:
|
| 137 |
-
await media_session.send(
|
| 138 |
-
raw.functions.auth.ImportAuthorization(
|
| 139 |
-
id=exported_auth.id, bytes=exported_auth.bytes
|
| 140 |
-
)
|
| 141 |
-
)
|
| 142 |
-
break
|
| 143 |
-
except AuthBytesInvalid:
|
| 144 |
-
continue
|
| 145 |
-
except FloodWait as e:
|
| 146 |
-
logger.warning(f"FloodWait during auth export: {e.value} seconds")
|
| 147 |
-
await asyncio.sleep(e.value + 1)
|
| 148 |
-
continue
|
| 149 |
-
else:
|
| 150 |
-
await media_session.stop()
|
| 151 |
-
raise AuthBytesInvalid
|
| 152 |
-
else:
|
| 153 |
-
media_session = Session(
|
| 154 |
-
client,
|
| 155 |
-
file_id.dc_id,
|
| 156 |
-
await client.storage.auth_key(),
|
| 157 |
-
await client.storage.test_mode(),
|
| 158 |
-
is_media=True,
|
| 159 |
-
)
|
| 160 |
-
await media_session.start()
|
| 161 |
-
|
| 162 |
-
setattr(media_session, 'last_used', time.time())
|
| 163 |
-
client.media_sessions[file_id.dc_id] = media_session
|
| 164 |
-
else:
|
| 165 |
-
setattr(media_session, 'last_used', time.time())
|
| 166 |
-
|
| 167 |
-
return media_session
|
| 168 |
-
|
| 169 |
-
@staticmethod
|
| 170 |
-
async def get_location(file_id: FileId) -> Union[
|
| 171 |
-
raw.types.InputPhotoFileLocation,
|
| 172 |
-
raw.types.InputDocumentFileLocation,
|
| 173 |
-
raw.types.InputPeerPhotoFileLocation,
|
| 174 |
-
None
|
| 175 |
-
]:
|
| 176 |
-
file_type = file_id.file_type
|
| 177 |
-
|
| 178 |
-
if file_type == FileType.CHAT_PHOTO:
|
| 179 |
-
if file_id.chat_id > 0:
|
| 180 |
-
peer = raw.types.InputPeerUser(
|
| 181 |
-
user_id=file_id.chat_id, access_hash=file_id.chat_access_hash
|
| 182 |
-
)
|
| 183 |
-
else:
|
| 184 |
-
if file_id.chat_access_hash == 0:
|
| 185 |
-
peer = raw.types.InputPeerChat(chat_id=-file_id.chat_id)
|
| 186 |
-
else:
|
| 187 |
-
peer = raw.types.InputPeerChannel(
|
| 188 |
-
channel_id=utils.get_channel_id(file_id.chat_id),
|
| 189 |
-
access_hash=file_id.chat_access_hash,
|
| 190 |
-
)
|
| 191 |
-
location = raw.types.InputPeerPhotoFileLocation(
|
| 192 |
-
peer=peer,
|
| 193 |
-
volume_id=file_id.volume_id,
|
| 194 |
-
local_id=file_id.local_id,
|
| 195 |
-
big=file_id.thumbnail_source == ThumbnailSource.CHAT_PHOTO_BIG,
|
| 196 |
-
)
|
| 197 |
-
elif file_type == FileType.PHOTO:
|
| 198 |
-
if not file_id.file_reference:
|
| 199 |
-
logger.debug("Missing file_reference for PHOTO type")
|
| 200 |
-
return None
|
| 201 |
-
location = raw.types.InputPhotoFileLocation(
|
| 202 |
-
id=file_id.media_id,
|
| 203 |
-
access_hash=file_id.access_hash,
|
| 204 |
-
file_reference=file_id.file_reference,
|
| 205 |
-
thumb_size=file_id.thumbnail_size,
|
| 206 |
-
)
|
| 207 |
-
else:
|
| 208 |
-
if not file_id.file_reference:
|
| 209 |
-
logger.debug("Missing file_reference for DOCUMENT type")
|
| 210 |
-
return None
|
| 211 |
-
location = raw.types.InputDocumentFileLocation(
|
| 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 |
-
return location
|
| 218 |
-
|
| 219 |
-
async def yield_file(
|
| 220 |
-
self,
|
| 221 |
-
file_id: FileId,
|
| 222 |
-
index: int,
|
| 223 |
-
offset: int,
|
| 224 |
-
first_part_cut: int,
|
| 225 |
-
last_part_cut: int,
|
| 226 |
-
part_count: int,
|
| 227 |
-
chunk_size: int,
|
| 228 |
-
) -> AsyncGenerator[bytes, None]:
|
| 229 |
-
client = self.client
|
| 230 |
-
work_loads[index] += 1
|
| 231 |
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
# Get file location
|
| 237 |
-
location = await self.get_location(file_id)
|
| 238 |
-
|
| 239 |
-
# Handle missing file reference
|
| 240 |
-
if location is None:
|
| 241 |
-
logger.warning(f"Missing file reference, attempting to refresh")
|
| 242 |
-
refreshed_file_id = await self.refresh_file_reference(file_id)
|
| 243 |
-
if refreshed_file_id:
|
| 244 |
-
location = await self.get_location(refreshed_file_id)
|
| 245 |
-
file_id = refreshed_file_id
|
| 246 |
-
|
| 247 |
-
if location is None:
|
| 248 |
-
logger.error("Failed to get location even after refreshing file reference")
|
| 249 |
-
return
|
| 250 |
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
r = await media_session.send(
|
| 257 |
-
raw.functions.upload.GetFile(
|
| 258 |
-
location=location, offset=offset, limit=chunk_size
|
| 259 |
-
),
|
| 260 |
-
)
|
| 261 |
-
except FloodWait as e:
|
| 262 |
-
logger.warning(f"FloodWait encountered: waiting for {e.value} seconds.")
|
| 263 |
-
await asyncio.sleep(e.value + 1)
|
| 264 |
-
continue
|
| 265 |
-
except (FileReferenceExpired, FileReferenceInvalid) as e:
|
| 266 |
-
logger.debug(f"File reference expired: {e}")
|
| 267 |
-
refreshed_file_id = await self.refresh_file_reference(file_id)
|
| 268 |
-
if refreshed_file_id:
|
| 269 |
-
file_id = refreshed_file_id
|
| 270 |
-
location = await self.get_location(file_id)
|
| 271 |
-
if location is None:
|
| 272 |
-
logger.error("Failed to get location after refresh")
|
| 273 |
-
return
|
| 274 |
-
retry_count = 0
|
| 275 |
-
continue
|
| 276 |
-
else:
|
| 277 |
-
logger.error("Failed to refresh file reference")
|
| 278 |
-
return
|
| 279 |
-
except RPCError as e:
|
| 280 |
-
retry_count += 1
|
| 281 |
-
logger.warning(f"RPC Error during file fetch: {e}")
|
| 282 |
-
if retry_count >= max_retries:
|
| 283 |
-
return
|
| 284 |
-
await asyncio.sleep(1 * (2 ** (retry_count - 1)))
|
| 285 |
-
continue
|
| 286 |
-
except ConnectionError as e:
|
| 287 |
-
retry_count += 1
|
| 288 |
-
logger.error(f"Connection error during file fetch: {e}")
|
| 289 |
-
if retry_count >= max_retries:
|
| 290 |
-
return
|
| 291 |
-
await asyncio.sleep(1)
|
| 292 |
-
continue
|
| 293 |
-
except Exception as e:
|
| 294 |
-
logger.error(f"Unexpected error during file fetch: {str(e)}", exc_info=True)
|
| 295 |
-
return
|
| 296 |
-
|
| 297 |
-
if isinstance(r, raw.types.upload.File):
|
| 298 |
-
chunk = r.bytes
|
| 299 |
-
if not chunk:
|
| 300 |
-
break
|
| 301 |
-
elif part_count == 1:
|
| 302 |
-
yield chunk[first_part_cut:last_part_cut]
|
| 303 |
-
elif current_part == 1:
|
| 304 |
-
yield chunk[first_part_cut:]
|
| 305 |
-
elif current_part == part_count:
|
| 306 |
-
yield chunk[:last_part_cut]
|
| 307 |
-
else:
|
| 308 |
-
yield chunk
|
| 309 |
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 313 |
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
async def clean_cache(self) -> None:
|
| 322 |
-
while True:
|
| 323 |
-
try:
|
| 324 |
-
await asyncio.sleep(self.clean_timer)
|
| 325 |
-
|
| 326 |
-
async with self.cache_lock:
|
| 327 |
-
self.cached_file_ids.clear()
|
| 328 |
-
|
| 329 |
-
now = time.time()
|
| 330 |
-
expired_refs = [
|
| 331 |
-
key for key, value in self.file_references_cache.items()
|
| 332 |
-
if now - value["timestamp"] > 3600
|
| 333 |
-
]
|
| 334 |
-
for key in expired_refs:
|
| 335 |
-
self.file_references_cache.pop(key, None)
|
| 336 |
-
|
| 337 |
-
if expired_refs:
|
| 338 |
-
logger.debug(f"Cache cleaned. Removed {len(expired_refs)} expired file references.")
|
| 339 |
-
except asyncio.CancelledError:
|
| 340 |
-
break
|
| 341 |
-
except Exception as e:
|
| 342 |
-
logger.error(f"Error in cache cleaning task: {e}")
|
| 343 |
-
await asyncio.sleep(60)
|
| 344 |
-
|
| 345 |
-
async def cleanup_media_sessions(self) -> None:
|
| 346 |
-
while True:
|
| 347 |
-
try:
|
| 348 |
-
await asyncio.sleep(1800)
|
| 349 |
-
|
| 350 |
-
async with self.media_sessions_lock:
|
| 351 |
-
now = time.time()
|
| 352 |
-
sessions_to_remove = []
|
| 353 |
-
|
| 354 |
-
for dc_id, session in list(self.client.media_sessions.items()):
|
| 355 |
-
if hasattr(session, 'last_used') and now - session.last_used > 1800: # 30 minutes
|
| 356 |
-
sessions_to_remove.append(dc_id)
|
| 357 |
-
|
| 358 |
-
for dc_id in sessions_to_remove:
|
| 359 |
-
try:
|
| 360 |
-
await self.client.media_sessions[dc_id].stop()
|
| 361 |
-
self.client.media_sessions.pop(dc_id, None)
|
| 362 |
-
except Exception as e:
|
| 363 |
-
logger.error(f"Error stopping media session for DC {dc_id}: {e}")
|
| 364 |
-
|
| 365 |
-
if sessions_to_remove:
|
| 366 |
-
logger.debug(f"Cleaned up {len(sessions_to_remove)} inactive media sessions")
|
| 367 |
-
except asyncio.CancelledError:
|
| 368 |
-
break
|
| 369 |
-
except Exception as e:
|
| 370 |
-
logger.error(f"Error in media session cleaning task: {e}")
|
| 371 |
-
await asyncio.sleep(60)
|
|
|
|
| 1 |
# Thunder/utils/custom_dl.py
|
| 2 |
|
| 3 |
+
from typing import Dict, Any, AsyncGenerator
|
| 4 |
+
from pyrogram import Client
|
| 5 |
+
from pyrogram.types import Message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from Thunder.vars import Var
|
|
|
|
| 7 |
from Thunder.server.exceptions import FileNotFound
|
|
|
|
| 8 |
from Thunder.utils.logger import logger
|
| 9 |
|
| 10 |
class ByteStreamer:
|
| 11 |
+
__slots__ = ('client', 'chat_id')
|
| 12 |
|
| 13 |
def __init__(self, client: Client):
|
| 14 |
self.client = client
|
| 15 |
+
self.chat_id = int(Var.BIN_CHANNEL)
|
| 16 |
+
|
| 17 |
+
async def get_message(self, message_id: int) -> Message:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
try:
|
| 19 |
+
message = await self.client.get_messages(self.chat_id, message_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
if not message or not message.media:
|
| 21 |
+
raise FileNotFound(f"Message {message_id} not found")
|
| 22 |
+
return message
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
except Exception as e:
|
| 24 |
+
logger.debug(f"Error fetching message {message_id}: {e}")
|
| 25 |
+
raise FileNotFound(f"Message {message_id} not found") from e
|
| 26 |
+
|
| 27 |
+
async def stream_file(self, message_id: int, offset: int = 0, limit: int = 0) -> AsyncGenerator[bytes, None]:
|
| 28 |
+
message = await self.get_message(message_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
if limit > 0:
|
| 31 |
+
chunk_offset = offset // (1024 * 1024)
|
| 32 |
+
chunk_limit = (limit + 1024 * 1024 - 1) // (1024 * 1024)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
async for chunk in self.client.stream_media(message, offset=chunk_offset, limit=chunk_limit):
|
| 35 |
+
yield chunk
|
| 36 |
+
else:
|
| 37 |
+
async for chunk in self.client.stream_media(message):
|
| 38 |
+
yield chunk
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
+
def get_file_info_sync(self, message: Message) -> Dict[str, Any]:
|
| 41 |
+
media = message.document or message.video or message.audio or message.photo
|
| 42 |
+
if not media:
|
| 43 |
+
return {"message_id": message.id, "error": "No media"}
|
| 44 |
+
|
| 45 |
+
return {
|
| 46 |
+
"message_id": message.id,
|
| 47 |
+
"file_size": getattr(media, 'file_size', 0) or 0,
|
| 48 |
+
"file_name": getattr(media, 'file_name', None),
|
| 49 |
+
"mime_type": getattr(media, 'mime_type', None),
|
| 50 |
+
"unique_id": getattr(media, 'file_unique_id', None),
|
| 51 |
+
"media_type": type(media).__name__.lower()
|
| 52 |
+
}
|
| 53 |
|
| 54 |
+
async def get_file_info(self, message_id: int) -> Dict[str, Any]:
|
| 55 |
+
try:
|
| 56 |
+
message = await self.get_message(message_id)
|
| 57 |
+
return self.get_file_info_sync(message)
|
| 58 |
+
except Exception as e:
|
| 59 |
+
logger.debug(f"Error getting file info for {message_id}: {e}")
|
| 60 |
+
return {"message_id": message_id, "error": str(e)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Thunder/utils/database.py
CHANGED
|
@@ -1,190 +1,183 @@
|
|
| 1 |
-
# Thunder/utils/database.py
|
| 2 |
-
|
| 3 |
-
import datetime
|
| 4 |
-
from typing import Optional, Dict, Any
|
| 5 |
-
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
|
| 6 |
-
from Thunder.vars import Var
|
| 7 |
-
from Thunder.utils.logger import logger
|
| 8 |
-
from Thunder.utils.error_handling import log_errors
|
| 9 |
-
|
| 10 |
-
class Database:
|
| 11 |
-
def __init__(self, uri: str, database_name: str, *args, **kwargs):
|
| 12 |
-
self._client = AsyncIOMotorClient(uri, *args, **kwargs)
|
| 13 |
-
self.db = self._client[database_name]
|
| 14 |
-
self.col: AsyncIOMotorCollection = self.db.users
|
| 15 |
-
self.banned_users_col: AsyncIOMotorCollection = self.db.banned_users
|
| 16 |
-
self.token_col: AsyncIOMotorCollection = self.db.tokens
|
| 17 |
-
self.authorized_users_col: AsyncIOMotorCollection = self.db.authorized_users
|
| 18 |
-
self.restart_message_col: AsyncIOMotorCollection = self.db.restart_message
|
| 19 |
-
|
| 20 |
-
@log_errors
|
| 21 |
-
async def ensure_indexes(self):
|
| 22 |
-
await self.banned_users_col.create_index("user_id", unique=True)
|
| 23 |
-
await self.token_col.create_index("token", unique=True)
|
| 24 |
-
await self.authorized_users_col.create_index("user_id", unique=True)
|
| 25 |
-
await self.col.create_index("id", unique=True)
|
| 26 |
-
await self.token_col.create_index("expires_at", expireAfterSeconds=0)
|
| 27 |
-
await self.
|
| 28 |
-
await self.restart_message_col.create_index("
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
"
|
| 92 |
-
"
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
logger.
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
except Exception as e:
|
| 168 |
-
logger.error(f"Error
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
logger.
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
async def
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
logger.error(f"Error deleting restart message {message_id}: {e}")
|
| 185 |
-
|
| 186 |
-
async def close(self):
|
| 187 |
-
if self._client:
|
| 188 |
-
self._client.close()
|
| 189 |
-
|
| 190 |
-
db = Database(Var.DATABASE_URL, Var.NAME)
|
|
|
|
| 1 |
+
# Thunder/utils/database.py
|
| 2 |
+
|
| 3 |
+
import datetime
|
| 4 |
+
from typing import Optional, Dict, Any
|
| 5 |
+
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
|
| 6 |
+
from Thunder.vars import Var
|
| 7 |
+
from Thunder.utils.logger import logger
|
| 8 |
+
from Thunder.utils.error_handling import log_errors
|
| 9 |
+
|
| 10 |
+
class Database:
|
| 11 |
+
def __init__(self, uri: str, database_name: str, *args, **kwargs):
|
| 12 |
+
self._client = AsyncIOMotorClient(uri, *args, **kwargs)
|
| 13 |
+
self.db = self._client[database_name]
|
| 14 |
+
self.col: AsyncIOMotorCollection = self.db.users
|
| 15 |
+
self.banned_users_col: AsyncIOMotorCollection = self.db.banned_users
|
| 16 |
+
self.token_col: AsyncIOMotorCollection = self.db.tokens
|
| 17 |
+
self.authorized_users_col: AsyncIOMotorCollection = self.db.authorized_users
|
| 18 |
+
self.restart_message_col: AsyncIOMotorCollection = self.db.restart_message
|
| 19 |
+
|
| 20 |
+
@log_errors
|
| 21 |
+
async def ensure_indexes(self):
|
| 22 |
+
await self.banned_users_col.create_index("user_id", unique=True)
|
| 23 |
+
await self.token_col.create_index("token", unique=True)
|
| 24 |
+
await self.authorized_users_col.create_index("user_id", unique=True)
|
| 25 |
+
await self.col.create_index("id", unique=True)
|
| 26 |
+
await self.token_col.create_index("expires_at", expireAfterSeconds=0)
|
| 27 |
+
await self.token_col.create_index("activated")
|
| 28 |
+
await self.restart_message_col.create_index("message_id", unique=True)
|
| 29 |
+
await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600)
|
| 30 |
+
|
| 31 |
+
logger.info("Database indexes ensured.")
|
| 32 |
+
|
| 33 |
+
@log_errors
|
| 34 |
+
def new_user(self, user_id: int) -> dict:
|
| 35 |
+
return {
|
| 36 |
+
'id': user_id,
|
| 37 |
+
'join_date': datetime.datetime.utcnow()
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
@log_errors
|
| 41 |
+
async def add_user(self, user_id: int):
|
| 42 |
+
if not await self.is_user_exist(user_id):
|
| 43 |
+
await self.col.insert_one(self.new_user(user_id))
|
| 44 |
+
logger.debug(f"Added new user {user_id} to database.")
|
| 45 |
+
|
| 46 |
+
@log_errors
|
| 47 |
+
async def add_user_pass(self, user_id: int, ag_pass: str):
|
| 48 |
+
await self.add_user(user_id)
|
| 49 |
+
await self.col.update_one({'id': user_id}, {'$set': {'ag_p': ag_pass}})
|
| 50 |
+
logger.debug(f"Updated password for user {user_id}.")
|
| 51 |
+
|
| 52 |
+
@log_errors
|
| 53 |
+
async def get_user_pass(self, user_id: int) -> Optional[str]:
|
| 54 |
+
user_data = await self.col.find_one({'id': user_id}, {'ag_p': 1})
|
| 55 |
+
return user_data.get('ag_p') if user_data else None
|
| 56 |
+
|
| 57 |
+
@log_errors
|
| 58 |
+
async def is_user_exist(self, user_id: int) -> bool:
|
| 59 |
+
user = await self.col.find_one({'id': user_id}, {'_id': 1})
|
| 60 |
+
return bool(user)
|
| 61 |
+
|
| 62 |
+
@log_errors
|
| 63 |
+
async def total_users_count(self) -> int:
|
| 64 |
+
return await self.col.count_documents({})
|
| 65 |
+
|
| 66 |
+
@log_errors
|
| 67 |
+
async def get_all_users(self):
|
| 68 |
+
return self.col.find({})
|
| 69 |
+
|
| 70 |
+
@log_errors
|
| 71 |
+
async def delete_user(self, user_id: int):
|
| 72 |
+
await self.col.delete_one({'id': user_id})
|
| 73 |
+
logger.info(f"Deleted user {user_id}.")
|
| 74 |
+
|
| 75 |
+
@log_errors
|
| 76 |
+
async def create_index(self):
|
| 77 |
+
await self.col.create_index("id", unique=True)
|
| 78 |
+
logger.info("Created index for 'id' on users collection.")
|
| 79 |
+
|
| 80 |
+
@log_errors
|
| 81 |
+
async def get_active_users(self, days: int = 7):
|
| 82 |
+
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=days)
|
| 83 |
+
return self.col.find({'join_date': {'$gte': cutoff}})
|
| 84 |
+
|
| 85 |
+
@log_errors
|
| 86 |
+
async def add_banned_user(
|
| 87 |
+
self, user_id: int, banned_by: Optional[int] = None,
|
| 88 |
+
reason: Optional[str] = None, ban_time: Optional[str] = None
|
| 89 |
+
):
|
| 90 |
+
ban_data = {
|
| 91 |
+
"user_id": user_id,
|
| 92 |
+
"banned_at": datetime.datetime.utcnow(),
|
| 93 |
+
"banned_by": banned_by,
|
| 94 |
+
"reason": reason
|
| 95 |
+
}
|
| 96 |
+
await self.banned_users_col.update_one(
|
| 97 |
+
{"user_id": user_id},
|
| 98 |
+
{"$set": ban_data},
|
| 99 |
+
upsert=True
|
| 100 |
+
)
|
| 101 |
+
logger.info(f"Added/Updated banned user {user_id}. Reason: {reason}")
|
| 102 |
+
|
| 103 |
+
@log_errors
|
| 104 |
+
async def remove_banned_user(self, user_id: int) -> bool:
|
| 105 |
+
result = await self.banned_users_col.delete_one({"user_id": user_id})
|
| 106 |
+
if result.deleted_count > 0:
|
| 107 |
+
logger.info(f"Removed banned user {user_id}.")
|
| 108 |
+
return True
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
@log_errors
|
| 112 |
+
async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
|
| 113 |
+
return await self.banned_users_col.find_one({"user_id": user_id})
|
| 114 |
+
|
| 115 |
+
@log_errors
|
| 116 |
+
async def save_main_token(self, user_id: int, token_value: str, expires_at: datetime.datetime, created_at: datetime.datetime, activated: bool) -> None:
|
| 117 |
+
try:
|
| 118 |
+
await self.token_col.update_one(
|
| 119 |
+
{"user_id": user_id, "token": token_value},
|
| 120 |
+
{"$set": {
|
| 121 |
+
"expires_at": expires_at,
|
| 122 |
+
"created_at": created_at,
|
| 123 |
+
"activated": activated
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
upsert=True
|
| 127 |
+
)
|
| 128 |
+
logger.info(f"Saved main token {token_value} for user {user_id} with activated status {activated}.")
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.error(f"Error saving main token for user {user_id}: {e}")
|
| 131 |
+
raise
|
| 132 |
+
|
| 133 |
+
@log_errors
|
| 134 |
+
async def save_broadcast_state(self, broadcast_id, state_data):
|
| 135 |
+
await self.db.broadcasts.update_one(
|
| 136 |
+
{"_id": broadcast_id},
|
| 137 |
+
{"$set": state_data},
|
| 138 |
+
upsert=True
|
| 139 |
+
)
|
| 140 |
+
logger.debug(f"Saved broadcast state for ID {broadcast_id}.")
|
| 141 |
+
|
| 142 |
+
@log_errors
|
| 143 |
+
async def get_broadcast_state(self, broadcast_id):
|
| 144 |
+
return await self.db.broadcasts.find_one({"_id": broadcast_id})
|
| 145 |
+
|
| 146 |
+
@log_errors
|
| 147 |
+
async def list_active_broadcasts(self):
|
| 148 |
+
cursor = self.db.broadcasts.find({"is_cancelled": False})
|
| 149 |
+
return await cursor.to_list(length=None)
|
| 150 |
+
|
| 151 |
+
@log_errors
|
| 152 |
+
async def add_restart_message(self, message_id: int, chat_id: int) -> None:
|
| 153 |
+
try:
|
| 154 |
+
await self.restart_message_col.insert_one({
|
| 155 |
+
"message_id": message_id,
|
| 156 |
+
"chat_id": chat_id,
|
| 157 |
+
"timestamp": datetime.datetime.utcnow()
|
| 158 |
+
})
|
| 159 |
+
logger.info(f"Added restart message {message_id} for chat {chat_id}.")
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error(f"Error adding restart message {message_id}: {e}")
|
| 162 |
+
|
| 163 |
+
@log_errors
|
| 164 |
+
async def get_restart_message(self) -> Optional[Dict[str, Any]]:
|
| 165 |
+
try:
|
| 166 |
+
return await self.restart_message_col.find_one(sort=[("timestamp", -1)])
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error(f"Error getting restart message: {e}")
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
@log_errors
|
| 172 |
+
async def delete_restart_message(self, message_id: int) -> None:
|
| 173 |
+
try:
|
| 174 |
+
await self.restart_message_col.delete_one({"message_id": message_id})
|
| 175 |
+
logger.info(f"Deleted restart message {message_id}.")
|
| 176 |
+
except Exception as e:
|
| 177 |
+
logger.error(f"Error deleting restart message {message_id}: {e}")
|
| 178 |
+
|
| 179 |
+
async def close(self):
|
| 180 |
+
if self._client:
|
| 181 |
+
self._client.close()
|
| 182 |
+
|
| 183 |
+
db = Database(Var.DATABASE_URL, Var.NAME)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Thunder/utils/decorators.py
CHANGED
|
@@ -1,147 +1,162 @@
|
|
| 1 |
-
# Thunder/utils/decorators.py
|
| 2 |
-
|
| 3 |
-
from functools import wraps
|
| 4 |
-
from pyrogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
| 5 |
-
from Thunder.vars import Var
|
| 6 |
-
from Thunder.utils.logger import logger
|
| 7 |
-
from Thunder.utils.messages import (
|
| 8 |
-
MSG_DECORATOR_BANNED,
|
| 9 |
-
MSG_TOKEN_INVALID,
|
| 10 |
-
MSG_ERROR_UNAUTHORIZED
|
| 11 |
-
)
|
| 12 |
-
from Thunder.utils.database import db
|
| 13 |
-
from Thunder.utils.tokens import check, allowed, generate
|
| 14 |
-
from Thunder.utils.shortener import shorten
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
def check_banned(func):
|
| 18 |
-
@wraps(func)
|
| 19 |
-
async def wrapper(client, message: Message, *args, **kwargs):
|
| 20 |
-
try:
|
| 21 |
-
if not message.from_user:
|
| 22 |
-
return await func(client, message, *args, **kwargs)
|
| 23 |
-
user_id = message.from_user.id
|
| 24 |
-
if isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID:
|
| 25 |
-
return await func(client, message, *args, **kwargs)
|
| 26 |
-
elif isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID:
|
| 27 |
-
return await func(client, message, *args, **kwargs)
|
| 28 |
-
|
| 29 |
-
ban_details = await db.is_user_banned(user_id)
|
| 30 |
-
if ban_details:
|
| 31 |
-
banned_at = ban_details.get('banned_at')
|
| 32 |
-
ban_time = (
|
| 33 |
-
banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
|
| 34 |
-
if banned_at and hasattr(banned_at, 'strftime')
|
| 35 |
-
else str(banned_at) if banned_at else 'N/A'
|
| 36 |
-
)
|
| 37 |
-
await message.reply_text(
|
| 38 |
-
MSG_DECORATOR_BANNED.format(
|
| 39 |
-
reason=ban_details.get('reason', 'Not specified'),
|
| 40 |
-
ban_time=ban_time
|
| 41 |
-
),
|
| 42 |
-
quote=True
|
| 43 |
-
)
|
| 44 |
-
logger.info(f"Blocked banned user {user_id} from accessing {func.__name__}.")
|
| 45 |
-
return
|
| 46 |
-
return await func(client, message, *args, **kwargs)
|
| 47 |
-
except Exception as e:
|
| 48 |
-
logger.error(f"Error in check_banned decorator for {func.__name__}: {e}")
|
| 49 |
-
return await func(client, message, *args, **kwargs)
|
| 50 |
-
return wrapper
|
| 51 |
-
|
| 52 |
-
def require_token(func):
|
| 53 |
-
@wraps(func)
|
| 54 |
-
async def wrapper(client, message: Message, *args, **kwargs):
|
| 55 |
-
try:
|
| 56 |
-
if not message.from_user:
|
| 57 |
-
return await func(client, message, *args, **kwargs)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Thunder/utils/decorators.py
|
| 2 |
+
|
| 3 |
+
from functools import wraps
|
| 4 |
+
from pyrogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
| 5 |
+
from Thunder.vars import Var
|
| 6 |
+
from Thunder.utils.logger import logger
|
| 7 |
+
from Thunder.utils.messages import (
|
| 8 |
+
MSG_DECORATOR_BANNED,
|
| 9 |
+
MSG_TOKEN_INVALID,
|
| 10 |
+
MSG_ERROR_UNAUTHORIZED
|
| 11 |
+
)
|
| 12 |
+
from Thunder.utils.database import db
|
| 13 |
+
from Thunder.utils.tokens import check, allowed, generate
|
| 14 |
+
from Thunder.utils.shortener import shorten
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def check_banned(func):
|
| 18 |
+
@wraps(func)
|
| 19 |
+
async def wrapper(client, message: Message, *args, **kwargs):
|
| 20 |
+
try:
|
| 21 |
+
if not message.from_user:
|
| 22 |
+
return await func(client, message, *args, **kwargs)
|
| 23 |
+
user_id = message.from_user.id
|
| 24 |
+
if isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID:
|
| 25 |
+
return await func(client, message, *args, **kwargs)
|
| 26 |
+
elif isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID:
|
| 27 |
+
return await func(client, message, *args, **kwargs)
|
| 28 |
+
|
| 29 |
+
ban_details = await db.is_user_banned(user_id)
|
| 30 |
+
if ban_details:
|
| 31 |
+
banned_at = ban_details.get('banned_at')
|
| 32 |
+
ban_time = (
|
| 33 |
+
banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
|
| 34 |
+
if banned_at and hasattr(banned_at, 'strftime')
|
| 35 |
+
else str(banned_at) if banned_at else 'N/A'
|
| 36 |
+
)
|
| 37 |
+
await message.reply_text(
|
| 38 |
+
MSG_DECORATOR_BANNED.format(
|
| 39 |
+
reason=ban_details.get('reason', 'Not specified'),
|
| 40 |
+
ban_time=ban_time
|
| 41 |
+
),
|
| 42 |
+
quote=True
|
| 43 |
+
)
|
| 44 |
+
logger.info(f"Blocked banned user {user_id} from accessing {func.__name__}.")
|
| 45 |
+
return
|
| 46 |
+
return await func(client, message, *args, **kwargs)
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error in check_banned decorator for {func.__name__}: {e}")
|
| 49 |
+
return await func(client, message, *args, **kwargs)
|
| 50 |
+
return wrapper
|
| 51 |
+
|
| 52 |
+
def require_token(func):
|
| 53 |
+
@wraps(func)
|
| 54 |
+
async def wrapper(client, message: Message, *args, **kwargs):
|
| 55 |
+
try:
|
| 56 |
+
if not message.from_user:
|
| 57 |
+
return await func(client, message, *args, **kwargs)
|
| 58 |
+
|
| 59 |
+
if not getattr(Var, "TOKEN_ENABLED", False):
|
| 60 |
+
return await func(client, message, *args, **kwargs)
|
| 61 |
+
|
| 62 |
+
user_id = message.from_user.id
|
| 63 |
+
is_owner = (isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID) or \
|
| 64 |
+
(isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID)
|
| 65 |
+
|
| 66 |
+
if is_owner or await allowed(user_id) or await check(user_id):
|
| 67 |
+
return await func(client, message, *args, **kwargs)
|
| 68 |
+
|
| 69 |
+
temp_token_string = None
|
| 70 |
+
try:
|
| 71 |
+
temp_token_string = await generate(user_id)
|
| 72 |
+
except Exception as e:
|
| 73 |
+
logger.error(f"Failed to generate temporary token for user {user_id} in require_token: {e}")
|
| 74 |
+
await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
|
| 75 |
+
return
|
| 76 |
+
|
| 77 |
+
if not temp_token_string:
|
| 78 |
+
logger.error(f"Temporary token generation returned empty for user {user_id} in require_token.")
|
| 79 |
+
await message.reply_text("Sorry, could not generate an access token link. Please try again later.", quote=True)
|
| 80 |
+
return
|
| 81 |
+
|
| 82 |
+
me = await client.get_me()
|
| 83 |
+
deep_link = f"https://t.me/{me.username}?start={temp_token_string}"
|
| 84 |
+
short_url = deep_link
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
short_url_result = await shorten(deep_link)
|
| 88 |
+
if short_url_result:
|
| 89 |
+
short_url = short_url_result
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.warning(f"Failed to shorten token link for user {user_id}: {e}. Using full link.")
|
| 92 |
+
|
| 93 |
+
await message.reply_text(
|
| 94 |
+
MSG_TOKEN_INVALID,
|
| 95 |
+
reply_markup=InlineKeyboardMarkup([
|
| 96 |
+
[InlineKeyboardButton("Activate Access", url=short_url)]
|
| 97 |
+
]),
|
| 98 |
+
quote=True
|
| 99 |
+
)
|
| 100 |
+
logger.debug(f"Sent temporary token activation link to user {user_id} for {func.__name__}.")
|
| 101 |
+
return
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"Error in require_token decorator for {func.__name__}: {e}")
|
| 104 |
+
try:
|
| 105 |
+
await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
|
| 106 |
+
except Exception as inner_e:
|
| 107 |
+
logger.error(f"Failed to send error message to user in require_token: {inner_e}")
|
| 108 |
+
return
|
| 109 |
+
return wrapper
|
| 110 |
+
|
| 111 |
+
def shorten_link(func):
|
| 112 |
+
@wraps(func)
|
| 113 |
+
async def wrapper(client, message: Message, *args, **kwargs):
|
| 114 |
+
try:
|
| 115 |
+
user_id = message.from_user.id if message.from_user else None
|
| 116 |
+
use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
|
| 117 |
+
if user_id:
|
| 118 |
+
try:
|
| 119 |
+
is_owner = (isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID) or \
|
| 120 |
+
(isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID)
|
| 121 |
+
if is_owner or await allowed(user_id):
|
| 122 |
+
use_shortener = False
|
| 123 |
+
except Exception as e:
|
| 124 |
+
logger.warning(f"Error checking allowed status for user {user_id} in shorten_link: {e}. Defaulting shortener behavior.")
|
| 125 |
+
|
| 126 |
+
kwargs['shortener'] = use_shortener
|
| 127 |
+
return await func(client, message, *args, **kwargs)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
logger.error(f"Error in shorten_link decorator for {func.__name__}: {e}")
|
| 130 |
+
kwargs['shortener'] = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
|
| 131 |
+
return await func(client, message, *args, **kwargs)
|
| 132 |
+
return wrapper
|
| 133 |
+
|
| 134 |
+
def owner_only(func):
|
| 135 |
+
_cached_owner_ids = None
|
| 136 |
+
@wraps(func)
|
| 137 |
+
async def wrapper(client, callback_query: Message):
|
| 138 |
+
nonlocal _cached_owner_ids
|
| 139 |
+
try:
|
| 140 |
+
if _cached_owner_ids is None:
|
| 141 |
+
owner_ids_config = getattr(Var, 'OWNER_ID', [])
|
| 142 |
+
if isinstance(owner_ids_config, int):
|
| 143 |
+
_cached_owner_ids = {owner_ids_config}
|
| 144 |
+
elif isinstance(owner_ids_config, (list, tuple, set)):
|
| 145 |
+
_cached_owner_ids = set(owner_ids_config)
|
| 146 |
+
else:
|
| 147 |
+
_cached_owner_ids = set()
|
| 148 |
+
|
| 149 |
+
if callback_query.from_user.id not in _cached_owner_ids:
|
| 150 |
+
await callback_query.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True)
|
| 151 |
+
logger.warning(f"Unauthorized access attempt by {callback_query.from_user.id} to owner_only function {func.__name__}.")
|
| 152 |
+
return
|
| 153 |
+
|
| 154 |
+
return await func(client, callback_query)
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"Error in owner_only decorator for {func.__name__}: {e}")
|
| 157 |
+
try:
|
| 158 |
+
await callback_query.answer("An error occurred. Please try again.", show_alert=True)
|
| 159 |
+
except Exception as inner_e:
|
| 160 |
+
logger.error(f"Failed to send error answer in owner_only: {inner_e}")
|
| 161 |
+
return
|
| 162 |
+
return wrapper
|
Thunder/utils/messages.py
CHANGED
|
@@ -48,6 +48,7 @@ MSG_TOKEN_FAILED = (
|
|
| 48 |
"🔑 Please check your token or contact support."
|
| 49 |
)
|
| 50 |
MSG_TOKEN_ERROR = "⚙️ **Token Activation Error:** Something went wrong. Please try again."
|
|
|
|
| 51 |
MSG_SHELL_ERROR = """**❌ Shell Command Error ❌**
|
| 52 |
<pre>{error}</pre>"""
|
| 53 |
MSG_SHELL_LARGE_OUTPUT = """Output is too large, sending as a file.
|
|
@@ -101,14 +102,9 @@ MSG_DEAUTHORIZE_SUCCESS = (
|
|
| 101 |
"> 👤 User ID: `{user_id}`\n"
|
| 102 |
"> 🔒 Access: Revoked"
|
| 103 |
)
|
| 104 |
-
MSG_TOKEN_ACTIVATED =
|
| 105 |
-
"✅ **Token Activated Successfully!**\n\n"
|
| 106 |
-
"> 🗓️ Access granted until: **{expiry_date}**\n"
|
| 107 |
-
"> 📝 Details: _{description}_\n\n"
|
| 108 |
-
"✨ Enjoy all the features!"
|
| 109 |
-
)
|
| 110 |
MSG_TOKEN_VERIFIED = "🎉 **Token Verified!** You're all set to use the bot's features."
|
| 111 |
-
MSG_TOKEN_INVALID = "
|
| 112 |
MSG_NO_AUTH_USERS = "ℹ️ **No Authorized Users Found:** The list is currently empty."
|
| 113 |
MSG_AUTH_USER_INFO = """{i}. 👤 User ID: `{user_id}`
|
| 114 |
• Authorized by: `{authorized_by}`
|
|
|
|
| 48 |
"🔑 Please check your token or contact support."
|
| 49 |
)
|
| 50 |
MSG_TOKEN_ERROR = "⚙️ **Token Activation Error:** Something went wrong. Please try again."
|
| 51 |
+
MSG_START_INVALID_PAYLOAD = "Invalid command format or expired/invalid link. Please use a valid command or activation link. Error ID: {error_id}"
|
| 52 |
MSG_SHELL_ERROR = """**❌ Shell Command Error ❌**
|
| 53 |
<pre>{error}</pre>"""
|
| 54 |
MSG_SHELL_LARGE_OUTPUT = """Output is too large, sending as a file.
|
|
|
|
| 102 |
"> 👤 User ID: `{user_id}`\n"
|
| 103 |
"> 🔒 Access: Revoked"
|
| 104 |
)
|
| 105 |
+
MSG_TOKEN_ACTIVATED = "✅ Token successfully activated!\n\n⏳ This token is valid for {duration_hours} hours."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
MSG_TOKEN_VERIFIED = "🎉 **Token Verified!** You're all set to use the bot's features."
|
| 107 |
+
MSG_TOKEN_INVALID = "Access to this feature requires an active token. Please click the button below to activate your access token."
|
| 108 |
MSG_NO_AUTH_USERS = "ℹ️ **No Authorized Users Found:** The list is currently empty."
|
| 109 |
MSG_AUTH_USER_INFO = """{i}. 👤 User ID: `{user_id}`
|
| 110 |
• Authorized by: `{authorized_by}`
|
Thunder/utils/tokens.py
CHANGED
|
@@ -1,139 +1,112 @@
|
|
| 1 |
-
import secrets
|
| 2 |
-
from datetime import datetime, timedelta
|
| 3 |
-
from typing import Optional, Dict, Any, List
|
| 4 |
-
from Thunder.utils.database import db
|
| 5 |
-
from Thunder.vars import Var
|
| 6 |
-
from Thunder.utils.error_handling import log_errors
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
{"
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
{"
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
"
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
token_record = await db.token_col.find_one({"token": clean_token})
|
| 114 |
-
if not token_record:
|
| 115 |
-
return {
|
| 116 |
-
"valid": False,
|
| 117 |
-
"reason": "Token does not exist or has been revoked",
|
| 118 |
-
"token": clean_token
|
| 119 |
-
}
|
| 120 |
-
if token_record["expires_at"] <= current_time:
|
| 121 |
-
return {
|
| 122 |
-
"valid": False,
|
| 123 |
-
"reason": "Token has expired",
|
| 124 |
-
"token": clean_token,
|
| 125 |
-
"expiry_date": token_record["expires_at"].strftime("%Y-%m-%d %H:%M:%S UTC")
|
| 126 |
-
}
|
| 127 |
-
return {
|
| 128 |
-
"valid": True,
|
| 129 |
-
"token": clean_token,
|
| 130 |
-
"user_id": token_record["user_id"],
|
| 131 |
-
"expiry_date": token_record["expires_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
|
| 132 |
-
"created_at": token_record["created_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
|
| 133 |
-
"description": "Access token"
|
| 134 |
-
}
|
| 135 |
-
|
| 136 |
-
@log_errors
|
| 137 |
-
async def cleanup_expired_tokens() -> int:
|
| 138 |
-
result = await db.token_col.delete_many({"expires_at": {"$lte": datetime.utcnow()}})
|
| 139 |
-
return result.deleted_count
|
|
|
|
| 1 |
+
import secrets
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from typing import Optional, Dict, Any, List
|
| 4 |
+
from Thunder.utils.database import db
|
| 5 |
+
from Thunder.vars import Var
|
| 6 |
+
from Thunder.utils.error_handling import log_errors
|
| 7 |
+
from Thunder.utils.logger import logger
|
| 8 |
+
|
| 9 |
+
_OWNER_IDS_CACHE = None
|
| 10 |
+
|
| 11 |
+
def _get_owner_ids():
|
| 12 |
+
global _OWNER_IDS_CACHE
|
| 13 |
+
if _OWNER_IDS_CACHE is None:
|
| 14 |
+
owner_id = Var.OWNER_ID
|
| 15 |
+
_OWNER_IDS_CACHE = set(owner_id if isinstance(owner_id, (list, tuple, set)) else [owner_id])
|
| 16 |
+
return _OWNER_IDS_CACHE
|
| 17 |
+
|
| 18 |
+
@log_errors
|
| 19 |
+
async def check(user_id: int) -> bool:
|
| 20 |
+
if not getattr(Var, "TOKEN_ENABLED", False):
|
| 21 |
+
return True
|
| 22 |
+
if user_id in _get_owner_ids():
|
| 23 |
+
return True
|
| 24 |
+
current_time = datetime.utcnow()
|
| 25 |
+
auth_result = await db.authorized_users_col.find_one(
|
| 26 |
+
{"user_id": user_id},
|
| 27 |
+
{"_id": 1}
|
| 28 |
+
)
|
| 29 |
+
if auth_result:
|
| 30 |
+
return True
|
| 31 |
+
token_result = await db.token_col.find_one(
|
| 32 |
+
{"user_id": user_id, "expires_at": {"$gt": current_time}, "activated": True},
|
| 33 |
+
{"_id": 1}
|
| 34 |
+
)
|
| 35 |
+
return bool(token_result)
|
| 36 |
+
|
| 37 |
+
@log_errors
|
| 38 |
+
async def generate(user_id: int) -> str:
|
| 39 |
+
token_str = secrets.token_urlsafe(32)
|
| 40 |
+
ttl_hours = getattr(Var, "TOKEN_TTL_HOURS", 24)
|
| 41 |
+
created_at = datetime.utcnow()
|
| 42 |
+
expires_at = created_at + timedelta(hours=ttl_hours)
|
| 43 |
+
|
| 44 |
+
try:
|
| 45 |
+
await db.save_main_token(
|
| 46 |
+
user_id=user_id,
|
| 47 |
+
token_value=token_str,
|
| 48 |
+
created_at=created_at,
|
| 49 |
+
expires_at=expires_at,
|
| 50 |
+
activated=False
|
| 51 |
+
)
|
| 52 |
+
return token_str
|
| 53 |
+
except Exception as e:
|
| 54 |
+
logger.error(f"Failed to generate and save unactivated token for user {user_id}: {e}")
|
| 55 |
+
raise
|
| 56 |
+
|
| 57 |
+
@log_errors
|
| 58 |
+
async def allowed(user_id: int) -> bool:
|
| 59 |
+
result = await db.authorized_users_col.find_one(
|
| 60 |
+
{"user_id": user_id},
|
| 61 |
+
{"_id": 1}
|
| 62 |
+
)
|
| 63 |
+
return bool(result)
|
| 64 |
+
|
| 65 |
+
@log_errors
|
| 66 |
+
async def authorize(user_id: int, authorized_by: int) -> bool:
|
| 67 |
+
auth_data = {
|
| 68 |
+
"user_id": user_id,
|
| 69 |
+
"authorized_by": authorized_by,
|
| 70 |
+
"authorized_at": datetime.utcnow()
|
| 71 |
+
}
|
| 72 |
+
await db.authorized_users_col.update_one(
|
| 73 |
+
{"user_id": user_id},
|
| 74 |
+
{"$set": auth_data},
|
| 75 |
+
upsert=True
|
| 76 |
+
)
|
| 77 |
+
return True
|
| 78 |
+
|
| 79 |
+
@log_errors
|
| 80 |
+
async def deauthorize(user_id: int) -> bool:
|
| 81 |
+
result = await db.authorized_users_col.delete_one({"user_id": user_id})
|
| 82 |
+
return result.deleted_count > 0
|
| 83 |
+
|
| 84 |
+
@log_errors
|
| 85 |
+
async def get_user(user_id: int) -> Optional[Dict[str, Any]]:
|
| 86 |
+
return await db.token_col.find_one({"user_id": user_id})
|
| 87 |
+
|
| 88 |
+
@log_errors
|
| 89 |
+
async def get(token: str) -> Optional[Dict[str, Any]]:
|
| 90 |
+
return await db.token_col.find_one({"token": token})
|
| 91 |
+
|
| 92 |
+
@log_errors
|
| 93 |
+
async def list_allowed() -> List[Dict[str, Any]]:
|
| 94 |
+
cursor = db.authorized_users_col.find(
|
| 95 |
+
{},
|
| 96 |
+
{"user_id": 1, "authorized_by": 1, "authorized_at": 1}
|
| 97 |
+
)
|
| 98 |
+
return await cursor.to_list(length=None)
|
| 99 |
+
|
| 100 |
+
@log_errors
|
| 101 |
+
async def list_tokens() -> List[Dict[str, Any]]:
|
| 102 |
+
current_time = datetime.utcnow()
|
| 103 |
+
cursor = db.token_col.find(
|
| 104 |
+
{"expires_at": {"$gt": current_time}},
|
| 105 |
+
{"user_id": 1, "expires_at": 1, "created_at": 1, "activated": 1}
|
| 106 |
+
)
|
| 107 |
+
return await cursor.to_list(length=None)
|
| 108 |
+
|
| 109 |
+
@log_errors
|
| 110 |
+
async def cleanup_expired_tokens() -> int:
|
| 111 |
+
result = await db.token_col.delete_many({"expires_at": {"$lte": datetime.utcnow()}})
|
| 112 |
+
return result.deleted_count
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|