Spaces:
Running
Running
Fyaz Mohammed commited on
Fixed Timeout
Browse files- Thunder/bot/plugins/admin.py +234 -189
- Thunder/bot/plugins/callbacks.py +6 -41
- Thunder/bot/plugins/common.py +24 -51
- Thunder/bot/plugins/stream.py +66 -213
- Thunder/utils/bot_utils.py +93 -136
- Thunder/utils/broadcast_helper.py +8 -25
- Thunder/utils/config_parser.py +14 -22
- Thunder/utils/custom_dl.py +26 -51
- Thunder/utils/database.py +95 -133
- Thunder/utils/decorators.py +103 -75
- Thunder/utils/error_handling.py +14 -0
- Thunder/utils/file_properties.py +38 -54
- Thunder/utils/force_channel.py +18 -83
- Thunder/utils/human_readable.py +7 -9
- Thunder/utils/keepalive.py +10 -14
- Thunder/utils/logger.py +22 -26
- Thunder/utils/messages.py +3 -6
- Thunder/utils/render_template.py +15 -20
- Thunder/utils/retry_api.py +91 -0
- Thunder/utils/shortener.py +122 -219
- Thunder/utils/time_format.py +6 -12
- Thunder/utils/tokens.py +110 -236
- Thunder/vars.py +14 -35
Thunder/bot/plugins/admin.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
Thunder/bot/plugins/admin.py - Administrative commands and functionality
|
| 3 |
-
"""
|
| 4 |
|
| 5 |
import os
|
| 6 |
import sys
|
|
@@ -12,10 +10,7 @@ import random
|
|
| 12 |
import string
|
| 13 |
import html
|
| 14 |
import uuid
|
| 15 |
-
|
| 16 |
from datetime import datetime
|
| 17 |
-
from typing import List, Dict
|
| 18 |
-
|
| 19 |
from pyrogram.client import Client
|
| 20 |
from pyrogram import filters, StopPropagation
|
| 21 |
from pyrogram.enums import ParseMode
|
|
@@ -32,7 +27,8 @@ from pyrogram.errors import (
|
|
| 32 |
UserIsBlocked,
|
| 33 |
PeerIdInvalid,
|
| 34 |
FileReferenceExpired,
|
| 35 |
-
FileReferenceInvalid
|
|
|
|
| 36 |
)
|
| 37 |
|
| 38 |
from Thunder.bot import StreamBot, multi_clients, work_loads
|
|
@@ -44,9 +40,36 @@ from Thunder.utils.logger import logger, LOG_FILE
|
|
| 44 |
from Thunder.utils.tokens import authorize, deauthorize, list_allowed
|
| 45 |
from Thunder.utils.database import db
|
| 46 |
from Thunder.utils.messages import *
|
|
|
|
| 47 |
|
| 48 |
broadcast_ids = {}
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
def generate_unique_id(length=6):
|
| 52 |
while True:
|
|
@@ -54,7 +77,7 @@ def generate_unique_id(length=6):
|
|
| 54 |
if random_id not in broadcast_ids:
|
| 55 |
return random_id
|
| 56 |
|
| 57 |
-
async def get_users_in_batches(batch_size=
|
| 58 |
users_cursor = await db.get_all_users()
|
| 59 |
current_batch = []
|
| 60 |
async for user in users_cursor:
|
|
@@ -65,28 +88,10 @@ async def get_users_in_batches(batch_size=100):
|
|
| 65 |
if current_batch:
|
| 66 |
yield current_batch
|
| 67 |
|
| 68 |
-
async def
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
except:
|
| 73 |
-
pass
|
| 74 |
-
message_text = MSG_BROADCAST_COMPLETE.format(
|
| 75 |
-
elapsed_time=elapsed_time,
|
| 76 |
-
total_users=total_users,
|
| 77 |
-
successes=successes,
|
| 78 |
-
failures=failures,
|
| 79 |
-
deleted_accounts=broadcast_ids[broadcast_id]['deleted']
|
| 80 |
-
)
|
| 81 |
-
reply_markup = InlineKeyboardMarkup([
|
| 82 |
-
[InlineKeyboardButton(MSG_ADMIN_RESTART_BROADCAST, callback_data="restart_broadcast")]
|
| 83 |
-
])
|
| 84 |
-
await message.reply_text(
|
| 85 |
-
message_text,
|
| 86 |
-
parse_mode=ParseMode.MARKDOWN,
|
| 87 |
-
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 88 |
-
reply_markup=reply_markup
|
| 89 |
-
)
|
| 90 |
|
| 91 |
@StreamBot.on_message(filters.command("users") & filters.private & filters.user(Var.OWNER_ID))
|
| 92 |
async def get_total_users(client, message):
|
|
@@ -101,7 +106,7 @@ async def get_total_users(client, message):
|
|
| 101 |
parse_mode=ParseMode.MARKDOWN,
|
| 102 |
reply_markup=reply_markup
|
| 103 |
)
|
| 104 |
-
except:
|
| 105 |
await message.reply_text(MSG_DB_ERROR)
|
| 106 |
|
| 107 |
@StreamBot.on_message(filters.command("broadcast") & filters.private & filters.user(Var.OWNER_ID))
|
|
@@ -109,109 +114,119 @@ async def broadcast_message(client, message):
|
|
| 109 |
if not message.reply_to_message:
|
| 110 |
await message.reply_text(MSG_INVALID_BROADCAST_CMD, quote=True)
|
| 111 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
try:
|
| 113 |
-
broadcast_id = generate_unique_id()
|
| 114 |
-
broadcast_ids[broadcast_id] = {
|
| 115 |
-
"total": 0,
|
| 116 |
-
"current": 0,
|
| 117 |
-
"success": 0,
|
| 118 |
-
"failed": 0,
|
| 119 |
-
"deleted": 0,
|
| 120 |
-
"start_time": time.time(),
|
| 121 |
-
"is_cancelled": False
|
| 122 |
-
}
|
| 123 |
-
reply_markup = InlineKeyboardMarkup([
|
| 124 |
-
[InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_broadcast_{broadcast_id}")]
|
| 125 |
-
])
|
| 126 |
-
output = await message.reply_text(MSG_BROADCAST_START, reply_markup=reply_markup)
|
| 127 |
-
self_id = client.me.id
|
| 128 |
start_time = time.time()
|
| 129 |
total_users = await db.total_users_count()
|
| 130 |
-
processed = 0
|
| 131 |
-
successes = 0
|
| 132 |
-
failures = 0
|
| 133 |
broadcast_ids[broadcast_id]["total"] = total_users
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
|
| 138 |
async def update_progress():
|
| 139 |
-
while processed <
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
| 148 |
)
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
progress_task = asyncio.create_task(update_progress())
|
| 155 |
|
| 156 |
-
async def
|
| 157 |
-
|
| 158 |
-
if not isinstance(user_id, int) or user_id == self_id:
|
| 159 |
return
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
try:
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
chat_id=user_id,
|
| 169 |
-
text=message.reply_to_message.text or message.reply_to_message.caption,
|
| 170 |
-
parse_mode=ParseMode.MARKDOWN,
|
| 171 |
-
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 172 |
-
)
|
| 173 |
-
elif message.reply_to_message.media:
|
| 174 |
-
await message.reply_to_message.copy(chat_id=user_id)
|
| 175 |
-
async with broadcast_lock:
|
| 176 |
-
successes += 1
|
| 177 |
-
async with processed_lock:
|
| 178 |
-
processed += 1
|
| 179 |
-
broadcast_ids[broadcast_id]["current"] = processed
|
| 180 |
-
break
|
| 181 |
-
except FloodWait as e:
|
| 182 |
-
await asyncio.sleep(e.value + 1)
|
| 183 |
-
continue
|
| 184 |
-
except (FileReferenceExpired, FileReferenceInvalid):
|
| 185 |
-
retries += 1
|
| 186 |
-
if retries >= 3:
|
| 187 |
-
break
|
| 188 |
-
except (UserDeactivated, ChatWriteForbidden, UserIsBlocked, PeerIdInvalid):
|
| 189 |
-
try:
|
| 190 |
-
await db.delete_user(user_id)
|
| 191 |
-
async with broadcast_lock:
|
| 192 |
-
failures += 1
|
| 193 |
-
broadcast_ids[broadcast_id]["deleted"] += 1
|
| 194 |
-
async with processed_lock:
|
| 195 |
-
processed += 1
|
| 196 |
-
broadcast_ids[broadcast_id]["current"] = processed
|
| 197 |
-
except:
|
| 198 |
-
pass
|
| 199 |
-
break
|
| 200 |
except Exception:
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
async for user_batch in get_users_in_batches():
|
| 211 |
-
if
|
| 212 |
break
|
| 213 |
-
|
| 214 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
|
| 216 |
progress_task.cancel()
|
| 217 |
try:
|
|
@@ -219,40 +234,61 @@ async def broadcast_message(client, message):
|
|
| 219 |
except asyncio.CancelledError:
|
| 220 |
pass
|
| 221 |
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
|
|
|
|
|
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
except Exception as e:
|
| 228 |
await message.reply_text(
|
| 229 |
MSG_BROADCAST_FAILED.format(error=str(e), error_id=uuid.uuid4().hex[:8])
|
| 230 |
)
|
|
|
|
|
|
|
| 231 |
|
| 232 |
@StreamBot.on_message(filters.command("cancel_broadcast") & filters.private & filters.user(Var.OWNER_ID))
|
| 233 |
async def cancel_broadcast(client, message):
|
| 234 |
if not broadcast_ids:
|
| 235 |
await message.reply_text(MSG_NO_ACTIVE_BROADCASTS)
|
| 236 |
return
|
|
|
|
| 237 |
if len(broadcast_ids) == 1:
|
| 238 |
broadcast_id = list(broadcast_ids.keys())[0]
|
| 239 |
broadcast_ids[broadcast_id]["is_cancelled"] = True
|
| 240 |
await message.reply_text(MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id))
|
| 241 |
return
|
|
|
|
| 242 |
keyboard = []
|
| 243 |
for broadcast_id, info in broadcast_ids.items():
|
| 244 |
-
progress = f"{info['
|
| 245 |
elapsed = get_readable_time(time.time() - info['start_time'])
|
| 246 |
keyboard.append([
|
| 247 |
InlineKeyboardButton(
|
| 248 |
-
|
| 249 |
-
broadcast_id=broadcast_id,
|
| 250 |
-
progress=progress,
|
| 251 |
-
elapsed=elapsed
|
| 252 |
-
),
|
| 253 |
callback_data=f"cancel_broadcast_{broadcast_id}"
|
| 254 |
)
|
| 255 |
])
|
|
|
|
| 256 |
await message.reply_text(
|
| 257 |
MSG_MULTIPLE_BROADCASTS,
|
| 258 |
reply_markup=InlineKeyboardMarkup(keyboard)
|
|
@@ -280,19 +316,22 @@ async def show_status(client, message):
|
|
| 280 |
sorted(work_loads.items(), key=lambda x: x[1], reverse=True)
|
| 281 |
)
|
| 282 |
}
|
|
|
|
| 283 |
for bot_name, load in workloads.items():
|
| 284 |
workloads_text += MSG_ADMIN_BOT_WORKLOAD_ITEM.format(bot_name=bot_name, load=load)
|
|
|
|
| 285 |
stats_text = MSG_SYSTEM_STATUS.format(
|
| 286 |
uptime=uptime,
|
| 287 |
active_bots=len(multi_clients),
|
| 288 |
workloads=workloads_text,
|
| 289 |
version=__version__
|
| 290 |
)
|
|
|
|
| 291 |
reply_markup = InlineKeyboardMarkup([
|
| 292 |
[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 293 |
])
|
| 294 |
await message.reply_text(stats_text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup)
|
| 295 |
-
except:
|
| 296 |
await message.reply_text(MSG_STATUS_ERROR)
|
| 297 |
|
| 298 |
@StreamBot.on_message(filters.command("stats") & filters.private & filters.user(Var.OWNER_ID))
|
|
@@ -300,6 +339,7 @@ async def show_stats(client, message):
|
|
| 300 |
try:
|
| 301 |
current_time = get_readable_time(int(time.time() - StartTime))
|
| 302 |
total, used, free = shutil.disk_usage('.')
|
|
|
|
| 303 |
stats_text = MSG_SYSTEM_STATS.format(
|
| 304 |
uptime=current_time,
|
| 305 |
total=humanbytes(total),
|
|
@@ -308,16 +348,18 @@ async def show_stats(client, message):
|
|
| 308 |
upload=humanbytes(psutil.net_io_counters().bytes_sent),
|
| 309 |
download=humanbytes(psutil.net_io_counters().bytes_recv)
|
| 310 |
)
|
|
|
|
| 311 |
stats_text += MSG_PERFORMANCE_STATS.format(
|
| 312 |
cpu_percent=psutil.cpu_percent(interval=0.5),
|
| 313 |
ram_percent=psutil.virtual_memory().percent,
|
| 314 |
disk_percent=psutil.disk_usage('.').percent
|
| 315 |
)
|
|
|
|
| 316 |
reply_markup = InlineKeyboardMarkup([
|
| 317 |
[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 318 |
])
|
| 319 |
await message.reply_text(stats_text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup)
|
| 320 |
-
except:
|
| 321 |
await message.reply_text(MSG_STATUS_ERROR)
|
| 322 |
|
| 323 |
@StreamBot.on_message(filters.command("restart") & filters.private & filters.user(Var.OWNER_ID))
|
|
@@ -326,20 +368,16 @@ async def restart_bot(client, message):
|
|
| 326 |
sent_message = await message.reply_text(MSG_RESTARTING)
|
| 327 |
await db.add_restart_message(sent_message.id, message.chat.id)
|
| 328 |
os.execv(sys.executable, [sys.executable, "-m", "Thunder"])
|
| 329 |
-
except Exception
|
| 330 |
-
logger.error(f"Error during restart: {e}")
|
| 331 |
await message.reply_text(MSG_RESTART_FAILED)
|
| 332 |
|
| 333 |
@StreamBot.on_message(filters.command("log") & filters.private & filters.user(Var.OWNER_ID))
|
| 334 |
async def send_logs(client, message):
|
| 335 |
try:
|
| 336 |
-
if not os.path.exists(LOG_FILE):
|
| 337 |
-
await message.reply_text(MSG_LOG_FILE_MISSING)
|
| 338 |
-
return
|
| 339 |
-
file_size = os.path.getsize(LOG_FILE)
|
| 340 |
-
if file_size == 0:
|
| 341 |
-
await message.reply_text(MSG_LOG_FILE_EMPTY)
|
| 342 |
return
|
|
|
|
| 343 |
await client.send_document(
|
| 344 |
chat_id=message.chat.id,
|
| 345 |
document=LOG_FILE,
|
|
@@ -354,16 +392,17 @@ async def authorize_command(client, message):
|
|
| 354 |
if len(message.command) < 2:
|
| 355 |
await message.reply_text(MSG_AUTHORIZE_USAGE)
|
| 356 |
return
|
|
|
|
| 357 |
try:
|
| 358 |
user_id_to_authorize = int(message.command[1])
|
| 359 |
authorized = await authorize(user_id_to_authorize, authorized_by=message.from_user.id)
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
except ValueError:
|
| 365 |
await message.reply_text(MSG_INVALID_USER_ID)
|
| 366 |
-
except:
|
| 367 |
await message.reply_text(MSG_ERROR_GENERIC)
|
| 368 |
|
| 369 |
@StreamBot.on_message(filters.command("deauthorize") & filters.private & filters.user(Var.OWNER_ID))
|
|
@@ -371,16 +410,17 @@ async def deauthorize_command(client, message):
|
|
| 371 |
if len(message.command) < 2:
|
| 372 |
await message.reply_text(MSG_DEAUTHORIZE_USAGE)
|
| 373 |
return
|
|
|
|
| 374 |
try:
|
| 375 |
user_id_to_deauthorize = int(message.command[1])
|
| 376 |
deauthorized = await deauthorize(user_id_to_deauthorize)
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
except ValueError:
|
| 382 |
await message.reply_text(MSG_INVALID_USER_ID)
|
| 383 |
-
except:
|
| 384 |
await message.reply_text(MSG_ERROR_GENERIC)
|
| 385 |
|
| 386 |
@StreamBot.on_message(filters.command("listauth") & filters.private & filters.user(Var.OWNER_ID))
|
|
@@ -390,6 +430,7 @@ async def list_authorized_command(client, message):
|
|
| 390 |
if not authorized_users:
|
| 391 |
await message.reply_text(MSG_NO_AUTH_USERS)
|
| 392 |
return
|
|
|
|
| 393 |
message_text = MSG_ADMIN_AUTH_LIST_HEADER
|
| 394 |
for i, user in enumerate(authorized_users, 1):
|
| 395 |
message_text += MSG_AUTH_USER_INFO.format(
|
|
@@ -398,6 +439,7 @@ async def list_authorized_command(client, message):
|
|
| 398 |
authorized_by=user['authorized_by'],
|
| 399 |
auth_time=user['authorized_at']
|
| 400 |
)
|
|
|
|
| 401 |
reply_markup = InlineKeyboardMarkup([
|
| 402 |
[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 403 |
])
|
|
@@ -407,68 +449,67 @@ async def list_authorized_command(client, message):
|
|
| 407 |
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 408 |
reply_markup=reply_markup
|
| 409 |
)
|
| 410 |
-
except:
|
| 411 |
await message.reply_text(MSG_ERROR_GENERIC)
|
| 412 |
|
| 413 |
@StreamBot.on_message(filters.command("ban") & filters.private & filters.user(Var.OWNER_ID))
|
| 414 |
async def ban_user_command(client, message):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 415 |
try:
|
| 416 |
-
|
| 417 |
-
await message.reply_text(MSG_BAN_USAGE)
|
| 418 |
-
return
|
| 419 |
-
try:
|
| 420 |
-
user_id_to_ban = int(message.command[1])
|
| 421 |
-
except ValueError:
|
| 422 |
-
await message.reply_text(MSG_INVALID_USER_ID)
|
| 423 |
-
return
|
| 424 |
if user_id_to_ban in (Var.OWNER_ID if isinstance(Var.OWNER_ID, list) else [Var.OWNER_ID]):
|
| 425 |
await message.reply_text(MSG_CANNOT_BAN_OWNER)
|
| 426 |
return
|
|
|
|
| 427 |
reason = " ".join(message.command[2:]) if len(message.command) > 2 else MSG_ADMIN_NO_BAN_REASON
|
| 428 |
ban_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
| 429 |
await db.add_banned_user(
|
| 430 |
user_id=user_id_to_ban,
|
| 431 |
reason=reason,
|
| 432 |
ban_time=ban_time,
|
| 433 |
banned_by=message.from_user.id
|
| 434 |
)
|
|
|
|
| 435 |
reply_text = MSG_ADMIN_USER_BANNED.format(user_id=user_id_to_ban)
|
| 436 |
if reason != MSG_ADMIN_NO_BAN_REASON:
|
| 437 |
reply_text += MSG_BAN_REASON_SUFFIX.format(reason=reason)
|
| 438 |
await message.reply_text(reply_text)
|
|
|
|
| 439 |
try:
|
| 440 |
-
await
|
| 441 |
-
|
| 442 |
-
text=MSG_USER_BANNED_NOTIFICATION
|
| 443 |
-
)
|
| 444 |
-
except:
|
| 445 |
pass
|
|
|
|
|
|
|
|
|
|
| 446 |
except Exception as e:
|
| 447 |
await message.reply_text(MSG_BAN_ERROR.format(error=str(e)))
|
| 448 |
|
| 449 |
@StreamBot.on_message(filters.command("unban") & filters.private & filters.user(Var.OWNER_ID))
|
| 450 |
async def unban_user_command(client, message):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
try:
|
| 452 |
-
|
| 453 |
-
await message.reply_text(MSG_UNBAN_USAGE)
|
| 454 |
-
return
|
| 455 |
-
try:
|
| 456 |
-
user_id_to_unban = int(message.command[1])
|
| 457 |
-
except ValueError:
|
| 458 |
-
await message.reply_text(MSG_INVALID_USER_ID)
|
| 459 |
-
return
|
| 460 |
removed = await db.remove_banned_user(user_id=user_id_to_unban)
|
|
|
|
| 461 |
if removed:
|
| 462 |
await message.reply_text(MSG_ADMIN_USER_UNBANNED.format(user_id=user_id_to_unban))
|
| 463 |
try:
|
| 464 |
-
await
|
| 465 |
-
|
| 466 |
-
text=MSG_USER_UNBANNED_NOTIFICATION
|
| 467 |
-
)
|
| 468 |
-
except:
|
| 469 |
pass
|
| 470 |
else:
|
| 471 |
await message.reply_text(MSG_USER_NOT_IN_BAN_LIST.format(user_id=user_id_to_unban))
|
|
|
|
|
|
|
|
|
|
| 472 |
except Exception as e:
|
| 473 |
await message.reply_text(MSG_UNBAN_ERROR.format(error=str(e)))
|
| 474 |
|
|
@@ -477,12 +518,14 @@ async def run_shell_command(client: Client, message: Message):
|
|
| 477 |
if len(message.command) < 2:
|
| 478 |
await message.reply_text(MSG_SHELL_USAGE, parse_mode=ParseMode.HTML)
|
| 479 |
return
|
|
|
|
| 480 |
command_to_run = " ".join(message.command[1:])
|
| 481 |
reply_msg = await message.reply_text(
|
| 482 |
MSG_SHELL_EXECUTING.format(command=html.escape(command_to_run)),
|
| 483 |
parse_mode=ParseMode.HTML,
|
| 484 |
quote=True
|
| 485 |
)
|
|
|
|
| 486 |
try:
|
| 487 |
process = await asyncio.create_subprocess_shell(
|
| 488 |
command_to_run,
|
|
@@ -490,6 +533,7 @@ async def run_shell_command(client: Client, message: Message):
|
|
| 490 |
stderr=asyncio.subprocess.PIPE
|
| 491 |
)
|
| 492 |
stdout, stderr = await process.communicate()
|
|
|
|
| 493 |
output = ""
|
| 494 |
if stdout:
|
| 495 |
output += MSG_SHELL_OUTPUT_STDOUT.format(output=html.escape(stdout.decode().strip()))
|
|
@@ -497,8 +541,10 @@ async def run_shell_command(client: Client, message: Message):
|
|
| 497 |
output += MSG_SHELL_OUTPUT_STDERR.format(error=html.escape(stderr.decode().strip()))
|
| 498 |
if not output:
|
| 499 |
output = MSG_SHELL_NO_OUTPUT
|
|
|
|
| 500 |
except Exception as e:
|
| 501 |
output = MSG_SHELL_ERROR.format(error=html.escape(str(e)))
|
|
|
|
| 502 |
if len(output) > 4096:
|
| 503 |
try:
|
| 504 |
filename = f"shell_output_{int(time.time())}.txt"
|
|
@@ -507,6 +553,7 @@ async def run_shell_command(client: Client, message: Message):
|
|
| 507 |
file.write(f"STDOUT:\n{stdout.decode()}\n\n")
|
| 508 |
if stderr:
|
| 509 |
file.write(f"STDERR:\n{stderr.decode()}")
|
|
|
|
| 510 |
await client.send_document(
|
| 511 |
chat_id=message.chat.id,
|
| 512 |
document=filename,
|
|
@@ -514,35 +561,33 @@ async def run_shell_command(client: Client, message: Message):
|
|
| 514 |
parse_mode=ParseMode.HTML
|
| 515 |
)
|
| 516 |
os.remove(filename)
|
| 517 |
-
except Exception
|
| 518 |
-
await message.reply_text(
|
| 519 |
-
MSG_SHELL_LARGE_OUTPUT.format(error=html.escape(str(e))),
|
| 520 |
-
parse_mode=ParseMode.HTML
|
| 521 |
-
)
|
| 522 |
else:
|
| 523 |
await message.reply_text(output, parse_mode=ParseMode.HTML)
|
|
|
|
| 524 |
try:
|
| 525 |
await reply_msg.delete()
|
| 526 |
-
except:
|
| 527 |
pass
|
| 528 |
|
| 529 |
@StreamBot.on_callback_query(filters.regex("close_panel"))
|
| 530 |
async def close_panel(client, callback_query):
|
| 531 |
-
"""Handler for Close button - deletes current panel and command message."""
|
| 532 |
try:
|
| 533 |
await callback_query.answer()
|
| 534 |
await callback_query.message.delete()
|
| 535 |
-
|
| 536 |
if callback_query.message.reply_to_message:
|
| 537 |
try:
|
| 538 |
ctx = callback_query.message.reply_to_message
|
| 539 |
await ctx.delete()
|
| 540 |
if ctx.reply_to_message:
|
| 541 |
await ctx.reply_to_message.delete()
|
| 542 |
-
except Exception
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
|
|
|
| 546 |
finally:
|
| 547 |
raise StopPropagation
|
| 548 |
|
|
@@ -553,5 +598,5 @@ async def restart_broadcast(client, callback_query):
|
|
| 553 |
MSG_ERROR_BROADCAST_INSTRUCTION,
|
| 554 |
parse_mode=ParseMode.MARKDOWN
|
| 555 |
)
|
| 556 |
-
except:
|
| 557 |
pass
|
|
|
|
| 1 |
+
# Thunder/bot/plugins/admin.py
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
import sys
|
|
|
|
| 10 |
import string
|
| 11 |
import html
|
| 12 |
import uuid
|
|
|
|
| 13 |
from datetime import datetime
|
|
|
|
|
|
|
| 14 |
from pyrogram.client import Client
|
| 15 |
from pyrogram import filters, StopPropagation
|
| 16 |
from pyrogram.enums import ParseMode
|
|
|
|
| 27 |
UserIsBlocked,
|
| 28 |
PeerIdInvalid,
|
| 29 |
FileReferenceExpired,
|
| 30 |
+
FileReferenceInvalid,
|
| 31 |
+
BadRequest
|
| 32 |
)
|
| 33 |
|
| 34 |
from Thunder.bot import StreamBot, multi_clients, work_loads
|
|
|
|
| 40 |
from Thunder.utils.tokens import authorize, deauthorize, list_allowed
|
| 41 |
from Thunder.utils.database import db
|
| 42 |
from Thunder.utils.messages import *
|
| 43 |
+
from Thunder.utils.retry_api import retry_send_message
|
| 44 |
|
| 45 |
broadcast_ids = {}
|
| 46 |
+
RATE_LIMIT = 25
|
| 47 |
+
BATCH_SIZE = 30
|
| 48 |
+
MAX_CONCURRENT = 10
|
| 49 |
+
|
| 50 |
+
class RateLimiter:
|
| 51 |
+
def __init__(self, rate=RATE_LIMIT):
|
| 52 |
+
self.rate = rate
|
| 53 |
+
self.tokens = rate
|
| 54 |
+
self.last_update = time.time()
|
| 55 |
+
self.lock = asyncio.Lock()
|
| 56 |
+
|
| 57 |
+
async def acquire(self):
|
| 58 |
+
async with self.lock:
|
| 59 |
+
now = time.time()
|
| 60 |
+
elapsed = now - self.last_update
|
| 61 |
+
self.tokens = min(self.rate, self.tokens + elapsed * self.rate)
|
| 62 |
+
self.last_update = now
|
| 63 |
+
|
| 64 |
+
if self.tokens >= 1:
|
| 65 |
+
self.tokens -= 1
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
wait_time = (1 - self.tokens) / self.rate
|
| 69 |
+
await asyncio.sleep(wait_time)
|
| 70 |
+
self.tokens = 0
|
| 71 |
+
|
| 72 |
+
rate_limiter = RateLimiter()
|
| 73 |
|
| 74 |
def generate_unique_id(length=6):
|
| 75 |
while True:
|
|
|
|
| 77 |
if random_id not in broadcast_ids:
|
| 78 |
return random_id
|
| 79 |
|
| 80 |
+
async def get_users_in_batches(batch_size=BATCH_SIZE):
|
| 81 |
users_cursor = await db.get_all_users()
|
| 82 |
current_batch = []
|
| 83 |
async for user in users_cursor:
|
|
|
|
| 88 |
if current_batch:
|
| 89 |
yield current_batch
|
| 90 |
|
| 91 |
+
async def handle_flood_wait(e, attempt=0):
|
| 92 |
+
wait_time = max(e.value, 1) * (1.5 ** attempt)
|
| 93 |
+
wait_time = min(wait_time, 300)
|
| 94 |
+
await asyncio.sleep(wait_time + random.uniform(0.1, 0.5))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
@StreamBot.on_message(filters.command("users") & filters.private & filters.user(Var.OWNER_ID))
|
| 97 |
async def get_total_users(client, message):
|
|
|
|
| 106 |
parse_mode=ParseMode.MARKDOWN,
|
| 107 |
reply_markup=reply_markup
|
| 108 |
)
|
| 109 |
+
except Exception:
|
| 110 |
await message.reply_text(MSG_DB_ERROR)
|
| 111 |
|
| 112 |
@StreamBot.on_message(filters.command("broadcast") & filters.private & filters.user(Var.OWNER_ID))
|
|
|
|
| 114 |
if not message.reply_to_message:
|
| 115 |
await message.reply_text(MSG_INVALID_BROADCAST_CMD, quote=True)
|
| 116 |
return
|
| 117 |
+
|
| 118 |
+
broadcast_id = generate_unique_id()
|
| 119 |
+
broadcast_ids[broadcast_id] = {
|
| 120 |
+
"total": 0,
|
| 121 |
+
"processed": 0,
|
| 122 |
+
"success": 0,
|
| 123 |
+
"failed": 0,
|
| 124 |
+
"deleted": 0,
|
| 125 |
+
"start_time": time.time(),
|
| 126 |
+
"is_cancelled": False,
|
| 127 |
+
"last_update": 0
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
reply_markup = InlineKeyboardMarkup([
|
| 131 |
+
[InlineKeyboardButton(MSG_BUTTON_CANCEL_BROADCAST, callback_data=f"cancel_broadcast_{broadcast_id}")]
|
| 132 |
+
])
|
| 133 |
+
output = await message.reply_text(MSG_BROADCAST_START, reply_markup=reply_markup)
|
| 134 |
+
|
| 135 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
start_time = time.time()
|
| 137 |
total_users = await db.total_users_count()
|
|
|
|
|
|
|
|
|
|
| 138 |
broadcast_ids[broadcast_id]["total"] = total_users
|
| 139 |
+
|
| 140 |
+
stats = broadcast_ids[broadcast_id]
|
| 141 |
+
self_id = client.me.id
|
| 142 |
|
| 143 |
async def update_progress():
|
| 144 |
+
while stats["processed"] < stats["total"] and not stats["is_cancelled"]:
|
| 145 |
+
current_time = time.time()
|
| 146 |
+
if current_time - stats["last_update"] >= 5:
|
| 147 |
+
try:
|
| 148 |
+
progress_text = MSG_BROADCAST_PROGRESS.format(
|
| 149 |
+
total_users=stats["total"],
|
| 150 |
+
processed=stats["processed"],
|
| 151 |
+
elapsed_time=get_readable_time(int(current_time - start_time)),
|
| 152 |
+
successes=stats["success"],
|
| 153 |
+
failures=stats["failed"]
|
| 154 |
)
|
| 155 |
+
await output.edit_text(progress_text)
|
| 156 |
+
stats["last_update"] = current_time
|
| 157 |
+
except BadRequest as e:
|
| 158 |
+
if "Message is not modified" not in str(e):
|
| 159 |
+
break
|
| 160 |
+
except Exception:
|
| 161 |
+
break
|
| 162 |
+
await asyncio.sleep(2)
|
| 163 |
|
| 164 |
progress_task = asyncio.create_task(update_progress())
|
| 165 |
|
| 166 |
+
async def send_to_user(user_id: int):
|
| 167 |
+
if stats["is_cancelled"] or user_id == self_id:
|
|
|
|
| 168 |
return
|
| 169 |
+
|
| 170 |
+
await rate_limiter.acquire()
|
| 171 |
+
|
| 172 |
+
for attempt in range(3):
|
| 173 |
+
if stats["is_cancelled"]:
|
| 174 |
+
return
|
| 175 |
+
|
| 176 |
+
try:
|
| 177 |
+
if message.reply_to_message.text or message.reply_to_message.caption:
|
| 178 |
+
await client.send_message(
|
| 179 |
+
chat_id=user_id,
|
| 180 |
+
text=message.reply_to_message.text or message.reply_to_message.caption,
|
| 181 |
+
parse_mode=ParseMode.MARKDOWN,
|
| 182 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 183 |
+
)
|
| 184 |
+
elif message.reply_to_message.media:
|
| 185 |
+
await message.reply_to_message.copy(chat_id=user_id)
|
| 186 |
+
|
| 187 |
+
stats["success"] += 1
|
| 188 |
+
break
|
| 189 |
+
|
| 190 |
+
except FloodWait as e:
|
| 191 |
+
await handle_flood_wait(e, attempt)
|
| 192 |
+
continue
|
| 193 |
+
|
| 194 |
+
except (UserDeactivated, ChatWriteForbidden, UserIsBlocked, PeerIdInvalid):
|
| 195 |
try:
|
| 196 |
+
await db.delete_user(user_id)
|
| 197 |
+
stats["deleted"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
except Exception:
|
| 199 |
+
pass
|
| 200 |
+
stats["failed"] += 1
|
| 201 |
+
break
|
| 202 |
+
|
| 203 |
+
except (FileReferenceExpired, FileReferenceInvalid):
|
| 204 |
+
if attempt == 2:
|
| 205 |
+
stats["failed"] += 1
|
| 206 |
+
break
|
| 207 |
+
await asyncio.sleep(1)
|
| 208 |
+
|
| 209 |
+
except Exception:
|
| 210 |
+
stats["failed"] += 1
|
| 211 |
+
break
|
| 212 |
+
|
| 213 |
+
stats["processed"] += 1
|
| 214 |
+
|
| 215 |
+
semaphore = asyncio.Semaphore(MAX_CONCURRENT)
|
| 216 |
+
|
| 217 |
+
async def process_user(user_id):
|
| 218 |
+
async with semaphore:
|
| 219 |
+
await send_to_user(int(user_id))
|
| 220 |
|
| 221 |
async for user_batch in get_users_in_batches():
|
| 222 |
+
if stats["is_cancelled"]:
|
| 223 |
break
|
| 224 |
+
|
| 225 |
+
batch_tasks = [process_user(user['id']) for user in user_batch]
|
| 226 |
+
await asyncio.gather(*batch_tasks, return_exceptions=True)
|
| 227 |
+
|
| 228 |
+
if not stats["is_cancelled"]:
|
| 229 |
+
await asyncio.sleep(1)
|
| 230 |
|
| 231 |
progress_task.cancel()
|
| 232 |
try:
|
|
|
|
| 234 |
except asyncio.CancelledError:
|
| 235 |
pass
|
| 236 |
|
| 237 |
+
elapsed_time = get_readable_time(time.time() - start_time)
|
| 238 |
+
|
| 239 |
+
try:
|
| 240 |
+
await output.delete()
|
| 241 |
+
except Exception:
|
| 242 |
+
pass
|
| 243 |
|
| 244 |
+
completion_text = MSG_BROADCAST_COMPLETE.format(
|
| 245 |
+
elapsed_time=elapsed_time,
|
| 246 |
+
total_users=stats["total"],
|
| 247 |
+
successes=stats["success"],
|
| 248 |
+
failures=stats["failed"],
|
| 249 |
+
deleted_accounts=stats["deleted"]
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
reply_markup = InlineKeyboardMarkup([
|
| 253 |
+
[InlineKeyboardButton(MSG_ADMIN_RESTART_BROADCAST, callback_data="restart_broadcast")]
|
| 254 |
+
])
|
| 255 |
+
await message.reply_text(
|
| 256 |
+
completion_text,
|
| 257 |
+
parse_mode=ParseMode.MARKDOWN,
|
| 258 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 259 |
+
reply_markup=reply_markup
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
except Exception as e:
|
| 263 |
await message.reply_text(
|
| 264 |
MSG_BROADCAST_FAILED.format(error=str(e), error_id=uuid.uuid4().hex[:8])
|
| 265 |
)
|
| 266 |
+
finally:
|
| 267 |
+
broadcast_ids.pop(broadcast_id, None)
|
| 268 |
|
| 269 |
@StreamBot.on_message(filters.command("cancel_broadcast") & filters.private & filters.user(Var.OWNER_ID))
|
| 270 |
async def cancel_broadcast(client, message):
|
| 271 |
if not broadcast_ids:
|
| 272 |
await message.reply_text(MSG_NO_ACTIVE_BROADCASTS)
|
| 273 |
return
|
| 274 |
+
|
| 275 |
if len(broadcast_ids) == 1:
|
| 276 |
broadcast_id = list(broadcast_ids.keys())[0]
|
| 277 |
broadcast_ids[broadcast_id]["is_cancelled"] = True
|
| 278 |
await message.reply_text(MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id))
|
| 279 |
return
|
| 280 |
+
|
| 281 |
keyboard = []
|
| 282 |
for broadcast_id, info in broadcast_ids.items():
|
| 283 |
+
progress = f"{info['processed']}/{info['total']}" if info['total'] else "Unknown"
|
| 284 |
elapsed = get_readable_time(time.time() - info['start_time'])
|
| 285 |
keyboard.append([
|
| 286 |
InlineKeyboardButton(
|
| 287 |
+
f"Cancel {broadcast_id} ({progress}) - {elapsed}",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
callback_data=f"cancel_broadcast_{broadcast_id}"
|
| 289 |
)
|
| 290 |
])
|
| 291 |
+
|
| 292 |
await message.reply_text(
|
| 293 |
MSG_MULTIPLE_BROADCASTS,
|
| 294 |
reply_markup=InlineKeyboardMarkup(keyboard)
|
|
|
|
| 316 |
sorted(work_loads.items(), key=lambda x: x[1], reverse=True)
|
| 317 |
)
|
| 318 |
}
|
| 319 |
+
|
| 320 |
for bot_name, load in workloads.items():
|
| 321 |
workloads_text += MSG_ADMIN_BOT_WORKLOAD_ITEM.format(bot_name=bot_name, load=load)
|
| 322 |
+
|
| 323 |
stats_text = MSG_SYSTEM_STATUS.format(
|
| 324 |
uptime=uptime,
|
| 325 |
active_bots=len(multi_clients),
|
| 326 |
workloads=workloads_text,
|
| 327 |
version=__version__
|
| 328 |
)
|
| 329 |
+
|
| 330 |
reply_markup = InlineKeyboardMarkup([
|
| 331 |
[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 332 |
])
|
| 333 |
await message.reply_text(stats_text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup)
|
| 334 |
+
except Exception:
|
| 335 |
await message.reply_text(MSG_STATUS_ERROR)
|
| 336 |
|
| 337 |
@StreamBot.on_message(filters.command("stats") & filters.private & filters.user(Var.OWNER_ID))
|
|
|
|
| 339 |
try:
|
| 340 |
current_time = get_readable_time(int(time.time() - StartTime))
|
| 341 |
total, used, free = shutil.disk_usage('.')
|
| 342 |
+
|
| 343 |
stats_text = MSG_SYSTEM_STATS.format(
|
| 344 |
uptime=current_time,
|
| 345 |
total=humanbytes(total),
|
|
|
|
| 348 |
upload=humanbytes(psutil.net_io_counters().bytes_sent),
|
| 349 |
download=humanbytes(psutil.net_io_counters().bytes_recv)
|
| 350 |
)
|
| 351 |
+
|
| 352 |
stats_text += MSG_PERFORMANCE_STATS.format(
|
| 353 |
cpu_percent=psutil.cpu_percent(interval=0.5),
|
| 354 |
ram_percent=psutil.virtual_memory().percent,
|
| 355 |
disk_percent=psutil.disk_usage('.').percent
|
| 356 |
)
|
| 357 |
+
|
| 358 |
reply_markup = InlineKeyboardMarkup([
|
| 359 |
[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 360 |
])
|
| 361 |
await message.reply_text(stats_text, parse_mode=ParseMode.MARKDOWN, reply_markup=reply_markup)
|
| 362 |
+
except Exception:
|
| 363 |
await message.reply_text(MSG_STATUS_ERROR)
|
| 364 |
|
| 365 |
@StreamBot.on_message(filters.command("restart") & filters.private & filters.user(Var.OWNER_ID))
|
|
|
|
| 368 |
sent_message = await message.reply_text(MSG_RESTARTING)
|
| 369 |
await db.add_restart_message(sent_message.id, message.chat.id)
|
| 370 |
os.execv(sys.executable, [sys.executable, "-m", "Thunder"])
|
| 371 |
+
except Exception:
|
|
|
|
| 372 |
await message.reply_text(MSG_RESTART_FAILED)
|
| 373 |
|
| 374 |
@StreamBot.on_message(filters.command("log") & filters.private & filters.user(Var.OWNER_ID))
|
| 375 |
async def send_logs(client, message):
|
| 376 |
try:
|
| 377 |
+
if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) == 0:
|
| 378 |
+
await message.reply_text(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
return
|
| 380 |
+
|
| 381 |
await client.send_document(
|
| 382 |
chat_id=message.chat.id,
|
| 383 |
document=LOG_FILE,
|
|
|
|
| 392 |
if len(message.command) < 2:
|
| 393 |
await message.reply_text(MSG_AUTHORIZE_USAGE)
|
| 394 |
return
|
| 395 |
+
|
| 396 |
try:
|
| 397 |
user_id_to_authorize = int(message.command[1])
|
| 398 |
authorized = await authorize(user_id_to_authorize, authorized_by=message.from_user.id)
|
| 399 |
+
await message.reply_text(
|
| 400 |
+
MSG_AUTHORIZE_SUCCESS.format(user_id=user_id_to_authorize) if authorized
|
| 401 |
+
else MSG_AUTHORIZE_FAILED.format(user_id=user_id_to_authorize)
|
| 402 |
+
)
|
| 403 |
except ValueError:
|
| 404 |
await message.reply_text(MSG_INVALID_USER_ID)
|
| 405 |
+
except Exception:
|
| 406 |
await message.reply_text(MSG_ERROR_GENERIC)
|
| 407 |
|
| 408 |
@StreamBot.on_message(filters.command("deauthorize") & filters.private & filters.user(Var.OWNER_ID))
|
|
|
|
| 410 |
if len(message.command) < 2:
|
| 411 |
await message.reply_text(MSG_DEAUTHORIZE_USAGE)
|
| 412 |
return
|
| 413 |
+
|
| 414 |
try:
|
| 415 |
user_id_to_deauthorize = int(message.command[1])
|
| 416 |
deauthorized = await deauthorize(user_id_to_deauthorize)
|
| 417 |
+
await message.reply_text(
|
| 418 |
+
MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id_to_deauthorize) if deauthorized
|
| 419 |
+
else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id_to_deauthorize)
|
| 420 |
+
)
|
| 421 |
except ValueError:
|
| 422 |
await message.reply_text(MSG_INVALID_USER_ID)
|
| 423 |
+
except Exception:
|
| 424 |
await message.reply_text(MSG_ERROR_GENERIC)
|
| 425 |
|
| 426 |
@StreamBot.on_message(filters.command("listauth") & filters.private & filters.user(Var.OWNER_ID))
|
|
|
|
| 430 |
if not authorized_users:
|
| 431 |
await message.reply_text(MSG_NO_AUTH_USERS)
|
| 432 |
return
|
| 433 |
+
|
| 434 |
message_text = MSG_ADMIN_AUTH_LIST_HEADER
|
| 435 |
for i, user in enumerate(authorized_users, 1):
|
| 436 |
message_text += MSG_AUTH_USER_INFO.format(
|
|
|
|
| 439 |
authorized_by=user['authorized_by'],
|
| 440 |
auth_time=user['authorized_at']
|
| 441 |
)
|
| 442 |
+
|
| 443 |
reply_markup = InlineKeyboardMarkup([
|
| 444 |
[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 445 |
])
|
|
|
|
| 449 |
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 450 |
reply_markup=reply_markup
|
| 451 |
)
|
| 452 |
+
except Exception:
|
| 453 |
await message.reply_text(MSG_ERROR_GENERIC)
|
| 454 |
|
| 455 |
@StreamBot.on_message(filters.command("ban") & filters.private & filters.user(Var.OWNER_ID))
|
| 456 |
async def ban_user_command(client, message):
|
| 457 |
+
if len(message.command) < 2:
|
| 458 |
+
await message.reply_text(MSG_BAN_USAGE)
|
| 459 |
+
return
|
| 460 |
+
|
| 461 |
try:
|
| 462 |
+
user_id_to_ban = int(message.command[1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
if user_id_to_ban in (Var.OWNER_ID if isinstance(Var.OWNER_ID, list) else [Var.OWNER_ID]):
|
| 464 |
await message.reply_text(MSG_CANNOT_BAN_OWNER)
|
| 465 |
return
|
| 466 |
+
|
| 467 |
reason = " ".join(message.command[2:]) if len(message.command) > 2 else MSG_ADMIN_NO_BAN_REASON
|
| 468 |
ban_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 469 |
+
|
| 470 |
await db.add_banned_user(
|
| 471 |
user_id=user_id_to_ban,
|
| 472 |
reason=reason,
|
| 473 |
ban_time=ban_time,
|
| 474 |
banned_by=message.from_user.id
|
| 475 |
)
|
| 476 |
+
|
| 477 |
reply_text = MSG_ADMIN_USER_BANNED.format(user_id=user_id_to_ban)
|
| 478 |
if reason != MSG_ADMIN_NO_BAN_REASON:
|
| 479 |
reply_text += MSG_BAN_REASON_SUFFIX.format(reason=reason)
|
| 480 |
await message.reply_text(reply_text)
|
| 481 |
+
|
| 482 |
try:
|
| 483 |
+
await retry_send_message(client, chat_id=user_id_to_ban, text=MSG_USER_BANNED_NOTIFICATION)
|
| 484 |
+
except Exception:
|
|
|
|
|
|
|
|
|
|
| 485 |
pass
|
| 486 |
+
|
| 487 |
+
except ValueError:
|
| 488 |
+
await message.reply_text(MSG_INVALID_USER_ID)
|
| 489 |
except Exception as e:
|
| 490 |
await message.reply_text(MSG_BAN_ERROR.format(error=str(e)))
|
| 491 |
|
| 492 |
@StreamBot.on_message(filters.command("unban") & filters.private & filters.user(Var.OWNER_ID))
|
| 493 |
async def unban_user_command(client, message):
|
| 494 |
+
if len(message.command) < 2:
|
| 495 |
+
await message.reply_text(MSG_UNBAN_USAGE)
|
| 496 |
+
return
|
| 497 |
+
|
| 498 |
try:
|
| 499 |
+
user_id_to_unban = int(message.command[1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 500 |
removed = await db.remove_banned_user(user_id=user_id_to_unban)
|
| 501 |
+
|
| 502 |
if removed:
|
| 503 |
await message.reply_text(MSG_ADMIN_USER_UNBANNED.format(user_id=user_id_to_unban))
|
| 504 |
try:
|
| 505 |
+
await retry_send_message(client, chat_id=user_id_to_unban, text=MSG_USER_UNBANNED_NOTIFICATION)
|
| 506 |
+
except Exception:
|
|
|
|
|
|
|
|
|
|
| 507 |
pass
|
| 508 |
else:
|
| 509 |
await message.reply_text(MSG_USER_NOT_IN_BAN_LIST.format(user_id=user_id_to_unban))
|
| 510 |
+
|
| 511 |
+
except ValueError:
|
| 512 |
+
await message.reply_text(MSG_INVALID_USER_ID)
|
| 513 |
except Exception as e:
|
| 514 |
await message.reply_text(MSG_UNBAN_ERROR.format(error=str(e)))
|
| 515 |
|
|
|
|
| 518 |
if len(message.command) < 2:
|
| 519 |
await message.reply_text(MSG_SHELL_USAGE, parse_mode=ParseMode.HTML)
|
| 520 |
return
|
| 521 |
+
|
| 522 |
command_to_run = " ".join(message.command[1:])
|
| 523 |
reply_msg = await message.reply_text(
|
| 524 |
MSG_SHELL_EXECUTING.format(command=html.escape(command_to_run)),
|
| 525 |
parse_mode=ParseMode.HTML,
|
| 526 |
quote=True
|
| 527 |
)
|
| 528 |
+
|
| 529 |
try:
|
| 530 |
process = await asyncio.create_subprocess_shell(
|
| 531 |
command_to_run,
|
|
|
|
| 533 |
stderr=asyncio.subprocess.PIPE
|
| 534 |
)
|
| 535 |
stdout, stderr = await process.communicate()
|
| 536 |
+
|
| 537 |
output = ""
|
| 538 |
if stdout:
|
| 539 |
output += MSG_SHELL_OUTPUT_STDOUT.format(output=html.escape(stdout.decode().strip()))
|
|
|
|
| 541 |
output += MSG_SHELL_OUTPUT_STDERR.format(error=html.escape(stderr.decode().strip()))
|
| 542 |
if not output:
|
| 543 |
output = MSG_SHELL_NO_OUTPUT
|
| 544 |
+
|
| 545 |
except Exception as e:
|
| 546 |
output = MSG_SHELL_ERROR.format(error=html.escape(str(e)))
|
| 547 |
+
|
| 548 |
if len(output) > 4096:
|
| 549 |
try:
|
| 550 |
filename = f"shell_output_{int(time.time())}.txt"
|
|
|
|
| 553 |
file.write(f"STDOUT:\n{stdout.decode()}\n\n")
|
| 554 |
if stderr:
|
| 555 |
file.write(f"STDERR:\n{stderr.decode()}")
|
| 556 |
+
|
| 557 |
await client.send_document(
|
| 558 |
chat_id=message.chat.id,
|
| 559 |
document=filename,
|
|
|
|
| 561 |
parse_mode=ParseMode.HTML
|
| 562 |
)
|
| 563 |
os.remove(filename)
|
| 564 |
+
except Exception:
|
| 565 |
+
await message.reply_text("Output too large and file creation failed", parse_mode=ParseMode.HTML)
|
|
|
|
|
|
|
|
|
|
| 566 |
else:
|
| 567 |
await message.reply_text(output, parse_mode=ParseMode.HTML)
|
| 568 |
+
|
| 569 |
try:
|
| 570 |
await reply_msg.delete()
|
| 571 |
+
except Exception:
|
| 572 |
pass
|
| 573 |
|
| 574 |
@StreamBot.on_callback_query(filters.regex("close_panel"))
|
| 575 |
async def close_panel(client, callback_query):
|
|
|
|
| 576 |
try:
|
| 577 |
await callback_query.answer()
|
| 578 |
await callback_query.message.delete()
|
| 579 |
+
|
| 580 |
if callback_query.message.reply_to_message:
|
| 581 |
try:
|
| 582 |
ctx = callback_query.message.reply_to_message
|
| 583 |
await ctx.delete()
|
| 584 |
if ctx.reply_to_message:
|
| 585 |
await ctx.reply_to_message.delete()
|
| 586 |
+
except Exception:
|
| 587 |
+
pass
|
| 588 |
+
|
| 589 |
+
except Exception:
|
| 590 |
+
pass
|
| 591 |
finally:
|
| 592 |
raise StopPropagation
|
| 593 |
|
|
|
|
| 598 |
MSG_ERROR_BROADCAST_INSTRUCTION,
|
| 599 |
parse_mode=ParseMode.MARKDOWN
|
| 600 |
)
|
| 601 |
+
except Exception:
|
| 602 |
pass
|
Thunder/bot/plugins/callbacks.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
-
|
| 2 |
-
Thunder/bot/plugins/callbacks.py - Callback query handlers for Thunder bot.
|
| 3 |
-
"""
|
| 4 |
|
| 5 |
import uuid
|
|
|
|
| 6 |
from pyrogram import Client, filters, StopPropagation
|
| 7 |
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, LinkPreviewOptions
|
| 8 |
from pyrogram.errors import MessageNotModified
|
|
@@ -11,29 +10,25 @@ from Thunder.vars import Var
|
|
| 11 |
from Thunder.utils.logger import logger
|
| 12 |
from Thunder.utils.messages import *
|
| 13 |
from Thunder.utils.decorators import owner_only
|
|
|
|
| 14 |
|
| 15 |
-
# Helper Functions
|
| 16 |
async def handle_callback_error(callback_query, error, operation="callback"):
|
| 17 |
-
"""Standardized error handling for callback queries."""
|
| 18 |
error_id = uuid.uuid4().hex[:8]
|
| 19 |
logger.error(f"Error in {operation}: {error}")
|
| 20 |
await callback_query.answer(
|
| 21 |
-
MSG_ERROR_GENERIC_CALLBACK.format(error_id=error_id),
|
| 22 |
show_alert=True
|
| 23 |
)
|
| 24 |
|
| 25 |
async def get_force_channel_button(client):
|
| 26 |
-
"""Get force channel button if configured."""
|
| 27 |
if not Var.FORCE_CHANNEL_ID:
|
| 28 |
return None
|
| 29 |
-
|
| 30 |
try:
|
| 31 |
-
chat = await
|
| 32 |
invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
|
| 33 |
-
|
| 34 |
if invite_link:
|
| 35 |
return [InlineKeyboardButton(
|
| 36 |
-
MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title),
|
| 37 |
url=invite_link
|
| 38 |
)]
|
| 39 |
else:
|
|
@@ -43,32 +38,21 @@ async def get_force_channel_button(client):
|
|
| 43 |
logger.error(f"Error creating force channel button: {e}")
|
| 44 |
return None
|
| 45 |
|
| 46 |
-
# Callback Handlers
|
| 47 |
@StreamBot.on_callback_query(filters.regex(r"^help_command$"))
|
| 48 |
async def help_callback(client: Client, callback_query: CallbackQuery):
|
| 49 |
-
"""Handler for Help button."""
|
| 50 |
try:
|
| 51 |
await callback_query.answer()
|
| 52 |
-
|
| 53 |
-
# Build buttons list - About button first
|
| 54 |
buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]]
|
| 55 |
-
|
| 56 |
-
# Add force channel button if configured
|
| 57 |
force_button = await get_force_channel_button(client)
|
| 58 |
if force_button:
|
| 59 |
buttons.append(force_button)
|
| 60 |
-
|
| 61 |
-
# Add close button at the end
|
| 62 |
buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")])
|
| 63 |
-
|
| 64 |
await callback_query.message.edit_text(
|
| 65 |
text=MSG_HELP,
|
| 66 |
reply_markup=InlineKeyboardMarkup(buttons),
|
| 67 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 68 |
)
|
| 69 |
-
|
| 70 |
logger.debug(f"User {callback_query.from_user.id} accessed help panel.")
|
| 71 |
-
|
| 72 |
except MessageNotModified:
|
| 73 |
logger.debug(f"Help panel already displayed for user {callback_query.from_user.id}")
|
| 74 |
except Exception as e:
|
|
@@ -78,24 +62,19 @@ async def help_callback(client: Client, callback_query: CallbackQuery):
|
|
| 78 |
|
| 79 |
@StreamBot.on_callback_query(filters.regex(r"^about_command$"))
|
| 80 |
async def about_callback(client: Client, callback_query: CallbackQuery):
|
| 81 |
-
"""Handler for About button."""
|
| 82 |
try:
|
| 83 |
await callback_query.answer()
|
| 84 |
-
|
| 85 |
buttons = [
|
| 86 |
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")],
|
| 87 |
[InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"),
|
| 88 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 89 |
]
|
| 90 |
-
|
| 91 |
await callback_query.message.edit_text(
|
| 92 |
text=MSG_ABOUT,
|
| 93 |
reply_markup=InlineKeyboardMarkup(buttons),
|
| 94 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 95 |
)
|
| 96 |
-
|
| 97 |
logger.debug(f"User {callback_query.from_user.id} accessed about panel.")
|
| 98 |
-
|
| 99 |
except MessageNotModified:
|
| 100 |
logger.debug(f"About panel already displayed for user {callback_query.from_user.id}")
|
| 101 |
except Exception as e:
|
|
@@ -106,24 +85,18 @@ async def about_callback(client: Client, callback_query: CallbackQuery):
|
|
| 106 |
@StreamBot.on_callback_query(filters.regex(r"^restart_broadcast$"))
|
| 107 |
@owner_only
|
| 108 |
async def restart_broadcast_callback(client: Client, callback_query: CallbackQuery):
|
| 109 |
-
"""Handler for Restart Broadcast button."""
|
| 110 |
try:
|
| 111 |
await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True)
|
| 112 |
-
|
| 113 |
-
# Create back and close buttons
|
| 114 |
buttons = [
|
| 115 |
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
|
| 116 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 117 |
]
|
| 118 |
-
|
| 119 |
await callback_query.message.edit_text(
|
| 120 |
MSG_ERROR_BROADCAST_INSTRUCTION,
|
| 121 |
reply_markup=InlineKeyboardMarkup(buttons),
|
| 122 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 123 |
)
|
| 124 |
-
|
| 125 |
logger.debug(f"User {callback_query.from_user.id} viewed broadcast restart instruction.")
|
| 126 |
-
|
| 127 |
except Exception as e:
|
| 128 |
await handle_callback_error(callback_query, e, "restart_broadcast_callback")
|
| 129 |
finally:
|
|
@@ -131,15 +104,10 @@ async def restart_broadcast_callback(client: Client, callback_query: CallbackQue
|
|
| 131 |
|
| 132 |
@StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
|
| 133 |
async def close_panel_callback(client: Client, callback_query: CallbackQuery):
|
| 134 |
-
"""Handler for Close button - deletes current panel and command message."""
|
| 135 |
try:
|
| 136 |
await callback_query.answer()
|
| 137 |
-
|
| 138 |
-
# Delete the current panel message
|
| 139 |
await callback_query.message.delete()
|
| 140 |
logger.debug(f"User {callback_query.from_user.id} closed panel")
|
| 141 |
-
|
| 142 |
-
# Clean up original command message and replies
|
| 143 |
if callback_query.message.reply_to_message:
|
| 144 |
try:
|
| 145 |
ctx = callback_query.message.reply_to_message
|
|
@@ -148,16 +116,13 @@ async def close_panel_callback(client: Client, callback_query: CallbackQuery):
|
|
| 148 |
await ctx.reply_to_message.delete()
|
| 149 |
except Exception as e:
|
| 150 |
logger.warning(f"Error deleting command messages: {e}")
|
| 151 |
-
|
| 152 |
except Exception as e:
|
| 153 |
await handle_callback_error(callback_query, e, "close_panel_callback")
|
| 154 |
finally:
|
| 155 |
raise StopPropagation
|
| 156 |
|
| 157 |
-
# Fallback handler for unmatched callback queries
|
| 158 |
@StreamBot.on_callback_query(group=999)
|
| 159 |
async def fallback_callback(client: Client, callback_query: CallbackQuery):
|
| 160 |
-
"""Fallback handler for any unmatched callback query."""
|
| 161 |
try:
|
| 162 |
logger.debug(f"Unhandled callback query: {callback_query.data} from user {callback_query.from_user.id}")
|
| 163 |
await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True)
|
|
|
|
| 1 |
+
# Thunder/bot/plugins/callbacks.py
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import uuid
|
| 4 |
+
|
| 5 |
from pyrogram import Client, filters, StopPropagation
|
| 6 |
from pyrogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, LinkPreviewOptions
|
| 7 |
from pyrogram.errors import MessageNotModified
|
|
|
|
| 10 |
from Thunder.utils.logger import logger
|
| 11 |
from Thunder.utils.messages import *
|
| 12 |
from Thunder.utils.decorators import owner_only
|
| 13 |
+
from Thunder.utils.retry_api import retry_get_chat
|
| 14 |
|
|
|
|
| 15 |
async def handle_callback_error(callback_query, error, operation="callback"):
|
|
|
|
| 16 |
error_id = uuid.uuid4().hex[:8]
|
| 17 |
logger.error(f"Error in {operation}: {error}")
|
| 18 |
await callback_query.answer(
|
| 19 |
+
MSG_ERROR_GENERIC_CALLBACK.format(error_id=error_id),
|
| 20 |
show_alert=True
|
| 21 |
)
|
| 22 |
|
| 23 |
async def get_force_channel_button(client):
|
|
|
|
| 24 |
if not Var.FORCE_CHANNEL_ID:
|
| 25 |
return None
|
|
|
|
| 26 |
try:
|
| 27 |
+
chat = await retry_get_chat(client, Var.FORCE_CHANNEL_ID)
|
| 28 |
invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
|
|
|
|
| 29 |
if invite_link:
|
| 30 |
return [InlineKeyboardButton(
|
| 31 |
+
MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title),
|
| 32 |
url=invite_link
|
| 33 |
)]
|
| 34 |
else:
|
|
|
|
| 38 |
logger.error(f"Error creating force channel button: {e}")
|
| 39 |
return None
|
| 40 |
|
|
|
|
| 41 |
@StreamBot.on_callback_query(filters.regex(r"^help_command$"))
|
| 42 |
async def help_callback(client: Client, callback_query: CallbackQuery):
|
|
|
|
| 43 |
try:
|
| 44 |
await callback_query.answer()
|
|
|
|
|
|
|
| 45 |
buttons = [[InlineKeyboardButton(MSG_BUTTON_ABOUT, callback_data="about_command")]]
|
|
|
|
|
|
|
| 46 |
force_button = await get_force_channel_button(client)
|
| 47 |
if force_button:
|
| 48 |
buttons.append(force_button)
|
|
|
|
|
|
|
| 49 |
buttons.append([InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")])
|
|
|
|
| 50 |
await callback_query.message.edit_text(
|
| 51 |
text=MSG_HELP,
|
| 52 |
reply_markup=InlineKeyboardMarkup(buttons),
|
| 53 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 54 |
)
|
|
|
|
| 55 |
logger.debug(f"User {callback_query.from_user.id} accessed help panel.")
|
|
|
|
| 56 |
except MessageNotModified:
|
| 57 |
logger.debug(f"Help panel already displayed for user {callback_query.from_user.id}")
|
| 58 |
except Exception as e:
|
|
|
|
| 62 |
|
| 63 |
@StreamBot.on_callback_query(filters.regex(r"^about_command$"))
|
| 64 |
async def about_callback(client: Client, callback_query: CallbackQuery):
|
|
|
|
| 65 |
try:
|
| 66 |
await callback_query.answer()
|
|
|
|
| 67 |
buttons = [
|
| 68 |
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")],
|
| 69 |
[InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"),
|
| 70 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 71 |
]
|
|
|
|
| 72 |
await callback_query.message.edit_text(
|
| 73 |
text=MSG_ABOUT,
|
| 74 |
reply_markup=InlineKeyboardMarkup(buttons),
|
| 75 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 76 |
)
|
|
|
|
| 77 |
logger.debug(f"User {callback_query.from_user.id} accessed about panel.")
|
|
|
|
| 78 |
except MessageNotModified:
|
| 79 |
logger.debug(f"About panel already displayed for user {callback_query.from_user.id}")
|
| 80 |
except Exception as e:
|
|
|
|
| 85 |
@StreamBot.on_callback_query(filters.regex(r"^restart_broadcast$"))
|
| 86 |
@owner_only
|
| 87 |
async def restart_broadcast_callback(client: Client, callback_query: CallbackQuery):
|
|
|
|
| 88 |
try:
|
| 89 |
await callback_query.answer(MSG_ERROR_BROADCAST_RESTART, show_alert=True)
|
|
|
|
|
|
|
| 90 |
buttons = [
|
| 91 |
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
|
| 92 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 93 |
]
|
|
|
|
| 94 |
await callback_query.message.edit_text(
|
| 95 |
MSG_ERROR_BROADCAST_INSTRUCTION,
|
| 96 |
reply_markup=InlineKeyboardMarkup(buttons),
|
| 97 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 98 |
)
|
|
|
|
| 99 |
logger.debug(f"User {callback_query.from_user.id} viewed broadcast restart instruction.")
|
|
|
|
| 100 |
except Exception as e:
|
| 101 |
await handle_callback_error(callback_query, e, "restart_broadcast_callback")
|
| 102 |
finally:
|
|
|
|
| 104 |
|
| 105 |
@StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
|
| 106 |
async def close_panel_callback(client: Client, callback_query: CallbackQuery):
|
|
|
|
| 107 |
try:
|
| 108 |
await callback_query.answer()
|
|
|
|
|
|
|
| 109 |
await callback_query.message.delete()
|
| 110 |
logger.debug(f"User {callback_query.from_user.id} closed panel")
|
|
|
|
|
|
|
| 111 |
if callback_query.message.reply_to_message:
|
| 112 |
try:
|
| 113 |
ctx = callback_query.message.reply_to_message
|
|
|
|
| 116 |
await ctx.reply_to_message.delete()
|
| 117 |
except Exception as e:
|
| 118 |
logger.warning(f"Error deleting command messages: {e}")
|
|
|
|
| 119 |
except Exception as e:
|
| 120 |
await handle_callback_error(callback_query, e, "close_panel_callback")
|
| 121 |
finally:
|
| 122 |
raise StopPropagation
|
| 123 |
|
|
|
|
| 124 |
@StreamBot.on_callback_query(group=999)
|
| 125 |
async def fallback_callback(client: Client, callback_query: CallbackQuery):
|
|
|
|
| 126 |
try:
|
| 127 |
logger.debug(f"Unhandled callback query: {callback_query.data} from user {callback_query.from_user.id}")
|
| 128 |
await callback_query.answer(MSG_ERROR_CALLBACK_UNSUPPORTED, show_alert=True)
|
Thunder/bot/plugins/common.py
CHANGED
|
@@ -1,25 +1,21 @@
|
|
| 1 |
-
|
| 2 |
-
Thunder/bot/plugins/common.py - Common plugin handlers and helpers for Thunder bot.
|
| 3 |
-
"""
|
| 4 |
|
| 5 |
import time
|
| 6 |
import asyncio
|
| 7 |
import uuid
|
| 8 |
from typing import Tuple, Optional
|
| 9 |
from urllib.parse import quote_plus
|
| 10 |
-
|
| 11 |
from pyrogram import filters, StopPropagation
|
| 12 |
from pyrogram.client import Client
|
| 13 |
from pyrogram.errors import RPCError, MessageNotModified
|
| 14 |
from pyrogram.types import (
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
)
|
| 22 |
-
|
| 23 |
from Thunder.bot import StreamBot
|
| 24 |
from Thunder.vars import Var
|
| 25 |
from Thunder.utils.database import db
|
|
@@ -31,33 +27,29 @@ from Thunder.utils.logger import logger
|
|
| 31 |
from Thunder.utils.tokens import get, check, validate_activation_token
|
| 32 |
from Thunder.utils.decorators import check_banned
|
| 33 |
from Thunder.utils.bot_utils import (
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
)
|
| 41 |
|
| 42 |
def has_media(message):
|
| 43 |
-
"""Check if message contains media"""
|
| 44 |
return any(
|
| 45 |
hasattr(message, attr) and getattr(message, attr)
|
| 46 |
for attr in ["document", "photo", "video", "audio", "voice",
|
| 47 |
"sticker", "animation", "video_note"]
|
| 48 |
)
|
| 49 |
|
| 50 |
-
# ====== COMMAND HANDLERS ======
|
| 51 |
-
|
| 52 |
@check_banned
|
| 53 |
@StreamBot.on_message(filters.command("start") & filters.private)
|
| 54 |
async def start_command(bot: Client, message: Message):
|
| 55 |
try:
|
| 56 |
user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
|
| 57 |
-
|
| 58 |
if message.from_user:
|
| 59 |
await log_new_user(bot, message.from_user.id, message.from_user.first_name)
|
| 60 |
-
|
| 61 |
if len(message.command) == 2:
|
| 62 |
token = message.command[1]
|
| 63 |
try:
|
|
@@ -117,7 +109,6 @@ async def start_command(bot: Client, message: Message):
|
|
| 117 |
else:
|
| 118 |
logger.warning(f"(ID: {error_id_context}) Could not retrieve invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /start message text (Channel: {chat_title}, User: {user_id_str}).")
|
| 119 |
welcome_text += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=chat_title)}"
|
| 120 |
-
|
| 121 |
except asyncio.TimeoutError as e_timeout:
|
| 122 |
logger.error(f"(ID: {error_id_context}) Timeout fetching Force Channel details for /start text (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_timeout}")
|
| 123 |
welcome_text += f"\n\n{MSG_DEFAULT_WELCOME}"
|
|
@@ -154,7 +145,6 @@ async def start_command(bot: Client, message: Message):
|
|
| 154 |
reply_markup.inline_keyboard.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat_title), url=invite_link)])
|
| 155 |
else:
|
| 156 |
logger.warning(f"(ID: {error_id_context_button}) Could not retrieve invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /start message button (Channel: {chat_title}, User: {user_id_str}).")
|
| 157 |
-
|
| 158 |
except asyncio.TimeoutError as e_timeout_btn:
|
| 159 |
logger.error(f"(ID: {error_id_context_button}) Timeout fetching Force Channel details for /start button (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_timeout_btn}")
|
| 160 |
except RPCError as e_rpc_btn:
|
|
@@ -168,22 +158,17 @@ async def start_command(bot: Client, message: Message):
|
|
| 168 |
payload = parts[1]
|
| 169 |
try:
|
| 170 |
msg_id = int(payload)
|
| 171 |
-
|
| 172 |
-
# Ensure file_msg is a single Message object
|
| 173 |
retrieved_messages = await bot.get_messages(chat_id=Var.BIN_CHANNEL, message_ids=msg_id)
|
| 174 |
if not retrieved_messages:
|
| 175 |
await handle_user_error(message, MSG_ERROR_FILE_INVALID)
|
| 176 |
return
|
| 177 |
|
| 178 |
-
# If get_messages returns a list, take the first one.
|
| 179 |
file_msg = retrieved_messages[0] if isinstance(retrieved_messages, list) else retrieved_messages
|
| 180 |
-
|
| 181 |
if not file_msg:
|
| 182 |
await handle_user_error(message, MSG_ERROR_FILE_INVALID)
|
| 183 |
return
|
| 184 |
|
| 185 |
stream_link, download_link, file_name, file_size = await generate_media_links(file_msg)
|
| 186 |
-
|
| 187 |
reply_text = MSG_LINKS.format(
|
| 188 |
file_name=file_name,
|
| 189 |
file_size=file_size,
|
|
@@ -202,13 +187,11 @@ async def start_command(bot: Client, message: Message):
|
|
| 202 |
quote=True,
|
| 203 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 204 |
)
|
| 205 |
-
|
| 206 |
except ValueError:
|
| 207 |
await handle_user_error(message, MSG_ERROR_FILE_INVALID_ID)
|
| 208 |
except Exception as e:
|
| 209 |
logger.error(f"Error processing /start with payload: {e}")
|
| 210 |
await handle_user_error(message, MSG_FILE_ACCESS_ERROR)
|
| 211 |
-
|
| 212 |
except Exception as e:
|
| 213 |
logger.error(f"Error in start_command: {e}")
|
| 214 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
|
@@ -218,7 +201,6 @@ async def start_command(bot: Client, message: Message):
|
|
| 218 |
async def help_command(bot: Client, message: Message):
|
| 219 |
try:
|
| 220 |
user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
|
| 221 |
-
|
| 222 |
if message.from_user:
|
| 223 |
await log_new_user(bot, message.from_user.id, message.from_user.first_name)
|
| 224 |
|
|
@@ -244,7 +226,6 @@ async def help_command(bot: Client, message: Message):
|
|
| 244 |
])
|
| 245 |
else:
|
| 246 |
logger.warning(f"(ID: {error_id_context_help}) Could not retrieve or construct invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /help command (User: {user_id_str}).")
|
| 247 |
-
|
| 248 |
except Exception as e_help:
|
| 249 |
logger.error(f"(ID: {error_id_context_help}) Error adding force channel button to /help command (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_help}")
|
| 250 |
|
|
@@ -257,7 +238,6 @@ async def help_command(bot: Client, message: Message):
|
|
| 257 |
quote=True,
|
| 258 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 259 |
)
|
| 260 |
-
|
| 261 |
except Exception as e:
|
| 262 |
logger.error(f"Error in help_command: {e}")
|
| 263 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
|
@@ -275,7 +255,6 @@ async def about_command(bot: Client, message: Message):
|
|
| 275 |
[InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"),
|
| 276 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 277 |
]
|
| 278 |
-
|
| 279 |
reply_markup = InlineKeyboardMarkup(buttons)
|
| 280 |
|
| 281 |
await message.reply_text(
|
|
@@ -284,7 +263,6 @@ async def about_command(bot: Client, message: Message):
|
|
| 284 |
quote=True,
|
| 285 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 286 |
)
|
| 287 |
-
|
| 288 |
except Exception as e:
|
| 289 |
logger.error(f"Error in about_command: {e}")
|
| 290 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
|
@@ -294,7 +272,6 @@ async def about_command(bot: Client, message: Message):
|
|
| 294 |
@StreamBot.on_message(filters.command("dc"))
|
| 295 |
async def dc_command(bot: Client, message: Message):
|
| 296 |
try:
|
| 297 |
-
# Early exit conditions
|
| 298 |
if not message.from_user and not message.reply_to_message:
|
| 299 |
await handle_user_error(message, MSG_DC_ANON_ERROR)
|
| 300 |
return
|
|
@@ -302,18 +279,20 @@ async def dc_command(bot: Client, message: Message):
|
|
| 302 |
async def process_dc_info(user: User):
|
| 303 |
dc_text = await generate_dc_text(user)
|
| 304 |
buttons = []
|
|
|
|
| 305 |
if user.username:
|
| 306 |
profile_url = f"https://t.me/{user.username}"
|
| 307 |
else:
|
| 308 |
profile_url = f"tg://user?id={user.id}"
|
|
|
|
| 309 |
buttons.append([
|
| 310 |
InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=profile_url)
|
| 311 |
])
|
| 312 |
buttons.append([
|
| 313 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")
|
| 314 |
])
|
|
|
|
| 315 |
dc_keyboard = InlineKeyboardMarkup(buttons)
|
| 316 |
-
|
| 317 |
await message.reply_text(
|
| 318 |
dc_text,
|
| 319 |
reply_markup=dc_keyboard,
|
|
@@ -325,6 +304,7 @@ async def dc_command(bot: Client, message: Message):
|
|
| 325 |
try:
|
| 326 |
file_name = get_name(file_msg) or "Untitled File"
|
| 327 |
file_size = humanbytes(get_media_file_size(file_msg))
|
|
|
|
| 328 |
file_type_map = {
|
| 329 |
"document": MSG_FILE_TYPE_DOCUMENT,
|
| 330 |
"photo": MSG_FILE_TYPE_PHOTO,
|
|
@@ -335,10 +315,10 @@ async def dc_command(bot: Client, message: Message):
|
|
| 335 |
"animation": MSG_FILE_TYPE_ANIMATION,
|
| 336 |
"video_note": MSG_FILE_TYPE_VIDEO_NOTE
|
| 337 |
}
|
| 338 |
-
|
| 339 |
file_type_attr = next((attr for attr in file_type_map if getattr(file_msg, attr, None)), "unknown")
|
| 340 |
file_type_display = file_type_map.get(file_type_attr, MSG_FILE_TYPE_UNKNOWN)
|
| 341 |
-
|
| 342 |
actual_media = None
|
| 343 |
if file_type_attr == "photo" and file_msg.photo and file_msg.photo.sizes:
|
| 344 |
actual_media = file_msg.photo.sizes[-1]
|
|
@@ -346,18 +326,19 @@ async def dc_command(bot: Client, message: Message):
|
|
| 346 |
potential = getattr(file_msg, file_type_attr, None)
|
| 347 |
if potential:
|
| 348 |
actual_media = potential
|
| 349 |
-
|
| 350 |
dc_id = MSG_DC_UNKNOWN
|
| 351 |
if hasattr(file_msg, 'raw') and hasattr(file_msg.raw, 'media'):
|
| 352 |
if hasattr(file_msg.raw.media, 'document') and hasattr(file_msg.raw.media.document, 'dc_id'):
|
| 353 |
dc_id = file_msg.raw.media.document.dc_id
|
| 354 |
-
|
| 355 |
dc_text = MSG_DC_FILE_INFO.format(
|
| 356 |
file_name=file_name,
|
| 357 |
file_size=file_size,
|
| 358 |
file_type=file_type_display,
|
| 359 |
dc_id=dc_id
|
| 360 |
)
|
|
|
|
| 361 |
await message.reply_text(
|
| 362 |
dc_text,
|
| 363 |
quote=True,
|
|
@@ -367,7 +348,6 @@ async def dc_command(bot: Client, message: Message):
|
|
| 367 |
logger.error(f"Error processing file info for DC command: {e}")
|
| 368 |
await handle_user_error(message, MSG_DC_FILE_ERROR)
|
| 369 |
|
| 370 |
-
# If DC command has an argument, treat it as user lookup
|
| 371 |
args = message.text.strip().split(maxsplit=1)
|
| 372 |
if len(args) > 1:
|
| 373 |
query = args[1].strip()
|
|
@@ -378,7 +358,6 @@ async def dc_command(bot: Client, message: Message):
|
|
| 378 |
await handle_user_error(message, MSG_ERROR_USER_INFO)
|
| 379 |
return
|
| 380 |
|
| 381 |
-
# Handle replied message cases
|
| 382 |
if message.reply_to_message:
|
| 383 |
if has_media(message.reply_to_message):
|
| 384 |
await process_file_dc_info(message.reply_to_message)
|
|
@@ -390,12 +369,10 @@ async def dc_command(bot: Client, message: Message):
|
|
| 390 |
await handle_user_error(message, MSG_DC_INVALID_USAGE)
|
| 391 |
return
|
| 392 |
|
| 393 |
-
# Otherwise, show info for the command sender
|
| 394 |
if message.from_user:
|
| 395 |
await process_dc_info(message.from_user)
|
| 396 |
else:
|
| 397 |
await handle_user_error(message, MSG_DC_ANON_ERROR)
|
| 398 |
-
|
| 399 |
except Exception as e:
|
| 400 |
logger.error(f"Error in dc_command: {e}")
|
| 401 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
|
@@ -409,6 +386,7 @@ async def ping_command(bot: Client, message: Message):
|
|
| 409 |
sent_msg = await message.reply_text(MSG_PING_START, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
|
| 410 |
end_time = time.time()
|
| 411 |
time_taken_ms = (end_time - start_time) * 1000
|
|
|
|
| 412 |
buttons = [
|
| 413 |
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
|
| 414 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
|
@@ -423,10 +401,7 @@ async def ping_command(bot: Client, message: Message):
|
|
| 423 |
logger.error(f"Error in ping_command: {e}")
|
| 424 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
| 425 |
|
| 426 |
-
# ====== CALLBACK HANDLERS ======
|
| 427 |
-
|
| 428 |
async def handle_callback_error(callback_query, error, operation="callback"):
|
| 429 |
-
"""Standardized error handling for callback queries."""
|
| 430 |
error_id = uuid.uuid4().hex[:8]
|
| 431 |
logger.error(f"Error in {operation}: {error}")
|
| 432 |
try:
|
|
@@ -439,7 +414,6 @@ async def handle_callback_error(callback_query, error, operation="callback"):
|
|
| 439 |
|
| 440 |
@StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
|
| 441 |
async def close_panel_callback(client: Client, callback_query: CallbackQuery):
|
| 442 |
-
"""Handler for Close button - deletes current panel and command message."""
|
| 443 |
try:
|
| 444 |
await callback_query.answer()
|
| 445 |
await callback_query.message.delete()
|
|
@@ -451,7 +425,6 @@ async def close_panel_callback(client: Client, callback_query: CallbackQuery):
|
|
| 451 |
await ctx.reply_to_message.delete()
|
| 452 |
except Exception as e:
|
| 453 |
logger.warning(f"Error deleting command messages: {e}")
|
| 454 |
-
|
| 455 |
except Exception as e:
|
| 456 |
await handle_callback_error(callback_query, e, "close_panel_callback")
|
| 457 |
finally:
|
|
|
|
| 1 |
+
# Thunder/bot/plugins/common.py
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import time
|
| 4 |
import asyncio
|
| 5 |
import uuid
|
| 6 |
from typing import Tuple, Optional
|
| 7 |
from urllib.parse import quote_plus
|
|
|
|
| 8 |
from pyrogram import filters, StopPropagation
|
| 9 |
from pyrogram.client import Client
|
| 10 |
from pyrogram.errors import RPCError, MessageNotModified
|
| 11 |
from pyrogram.types import (
|
| 12 |
+
InlineKeyboardButton,
|
| 13 |
+
InlineKeyboardMarkup,
|
| 14 |
+
Message,
|
| 15 |
+
User,
|
| 16 |
+
CallbackQuery,
|
| 17 |
+
LinkPreviewOptions
|
| 18 |
)
|
|
|
|
| 19 |
from Thunder.bot import StreamBot
|
| 20 |
from Thunder.vars import Var
|
| 21 |
from Thunder.utils.database import db
|
|
|
|
| 27 |
from Thunder.utils.tokens import get, check, validate_activation_token
|
| 28 |
from Thunder.utils.decorators import check_banned
|
| 29 |
from Thunder.utils.bot_utils import (
|
| 30 |
+
notify_channel,
|
| 31 |
+
log_new_user,
|
| 32 |
+
generate_media_links,
|
| 33 |
+
handle_user_error,
|
| 34 |
+
generate_dc_text,
|
| 35 |
+
get_user_safely
|
| 36 |
)
|
| 37 |
|
| 38 |
def has_media(message):
|
|
|
|
| 39 |
return any(
|
| 40 |
hasattr(message, attr) and getattr(message, attr)
|
| 41 |
for attr in ["document", "photo", "video", "audio", "voice",
|
| 42 |
"sticker", "animation", "video_note"]
|
| 43 |
)
|
| 44 |
|
|
|
|
|
|
|
| 45 |
@check_banned
|
| 46 |
@StreamBot.on_message(filters.command("start") & filters.private)
|
| 47 |
async def start_command(bot: Client, message: Message):
|
| 48 |
try:
|
| 49 |
user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
|
|
|
|
| 50 |
if message.from_user:
|
| 51 |
await log_new_user(bot, message.from_user.id, message.from_user.first_name)
|
| 52 |
+
|
| 53 |
if len(message.command) == 2:
|
| 54 |
token = message.command[1]
|
| 55 |
try:
|
|
|
|
| 109 |
else:
|
| 110 |
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}).")
|
| 111 |
welcome_text += f"\n\n{MSG_COMMUNITY_CHANNEL.format(channel_title=chat_title)}"
|
|
|
|
| 112 |
except asyncio.TimeoutError as e_timeout:
|
| 113 |
logger.error(f"(ID: {error_id_context}) Timeout fetching Force Channel details for /start text (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_timeout}")
|
| 114 |
welcome_text += f"\n\n{MSG_DEFAULT_WELCOME}"
|
|
|
|
| 145 |
reply_markup.inline_keyboard.append([InlineKeyboardButton(MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat_title), url=invite_link)])
|
| 146 |
else:
|
| 147 |
logger.warning(f"(ID: {error_id_context_button}) Could not retrieve invite link for FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID} for /start message button (Channel: {chat_title}, User: {user_id_str}).")
|
|
|
|
| 148 |
except asyncio.TimeoutError as e_timeout_btn:
|
| 149 |
logger.error(f"(ID: {error_id_context_button}) Timeout fetching Force Channel details for /start button (User: {user_id_str}, FChannel: {Var.FORCE_CHANNEL_ID}): {e_timeout_btn}")
|
| 150 |
except RPCError as e_rpc_btn:
|
|
|
|
| 158 |
payload = parts[1]
|
| 159 |
try:
|
| 160 |
msg_id = int(payload)
|
|
|
|
|
|
|
| 161 |
retrieved_messages = await bot.get_messages(chat_id=Var.BIN_CHANNEL, message_ids=msg_id)
|
| 162 |
if not retrieved_messages:
|
| 163 |
await handle_user_error(message, MSG_ERROR_FILE_INVALID)
|
| 164 |
return
|
| 165 |
|
|
|
|
| 166 |
file_msg = retrieved_messages[0] if isinstance(retrieved_messages, list) else retrieved_messages
|
|
|
|
| 167 |
if not file_msg:
|
| 168 |
await handle_user_error(message, MSG_ERROR_FILE_INVALID)
|
| 169 |
return
|
| 170 |
|
| 171 |
stream_link, download_link, file_name, file_size = await generate_media_links(file_msg)
|
|
|
|
| 172 |
reply_text = MSG_LINKS.format(
|
| 173 |
file_name=file_name,
|
| 174 |
file_size=file_size,
|
|
|
|
| 187 |
quote=True,
|
| 188 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 189 |
)
|
|
|
|
| 190 |
except ValueError:
|
| 191 |
await handle_user_error(message, MSG_ERROR_FILE_INVALID_ID)
|
| 192 |
except Exception as e:
|
| 193 |
logger.error(f"Error processing /start with payload: {e}")
|
| 194 |
await handle_user_error(message, MSG_FILE_ACCESS_ERROR)
|
|
|
|
| 195 |
except Exception as e:
|
| 196 |
logger.error(f"Error in start_command: {e}")
|
| 197 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
|
|
|
| 201 |
async def help_command(bot: Client, message: Message):
|
| 202 |
try:
|
| 203 |
user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
|
|
|
|
| 204 |
if message.from_user:
|
| 205 |
await log_new_user(bot, message.from_user.id, message.from_user.first_name)
|
| 206 |
|
|
|
|
| 226 |
])
|
| 227 |
else:
|
| 228 |
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}).")
|
|
|
|
| 229 |
except Exception as e_help:
|
| 230 |
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}")
|
| 231 |
|
|
|
|
| 238 |
quote=True,
|
| 239 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 240 |
)
|
|
|
|
| 241 |
except Exception as e:
|
| 242 |
logger.error(f"Error in help_command: {e}")
|
| 243 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
|
|
|
| 255 |
[InlineKeyboardButton(MSG_BUTTON_GITHUB, url="https://github.com/fyaz05/FileToLink"),
|
| 256 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
| 257 |
]
|
|
|
|
| 258 |
reply_markup = InlineKeyboardMarkup(buttons)
|
| 259 |
|
| 260 |
await message.reply_text(
|
|
|
|
| 263 |
quote=True,
|
| 264 |
link_preview_options=LinkPreviewOptions(is_disabled=True)
|
| 265 |
)
|
|
|
|
| 266 |
except Exception as e:
|
| 267 |
logger.error(f"Error in about_command: {e}")
|
| 268 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
|
|
|
| 272 |
@StreamBot.on_message(filters.command("dc"))
|
| 273 |
async def dc_command(bot: Client, message: Message):
|
| 274 |
try:
|
|
|
|
| 275 |
if not message.from_user and not message.reply_to_message:
|
| 276 |
await handle_user_error(message, MSG_DC_ANON_ERROR)
|
| 277 |
return
|
|
|
|
| 279 |
async def process_dc_info(user: User):
|
| 280 |
dc_text = await generate_dc_text(user)
|
| 281 |
buttons = []
|
| 282 |
+
|
| 283 |
if user.username:
|
| 284 |
profile_url = f"https://t.me/{user.username}"
|
| 285 |
else:
|
| 286 |
profile_url = f"tg://user?id={user.id}"
|
| 287 |
+
|
| 288 |
buttons.append([
|
| 289 |
InlineKeyboardButton(MSG_BUTTON_VIEW_PROFILE, url=profile_url)
|
| 290 |
])
|
| 291 |
buttons.append([
|
| 292 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")
|
| 293 |
])
|
| 294 |
+
|
| 295 |
dc_keyboard = InlineKeyboardMarkup(buttons)
|
|
|
|
| 296 |
await message.reply_text(
|
| 297 |
dc_text,
|
| 298 |
reply_markup=dc_keyboard,
|
|
|
|
| 304 |
try:
|
| 305 |
file_name = get_name(file_msg) or "Untitled File"
|
| 306 |
file_size = humanbytes(get_media_file_size(file_msg))
|
| 307 |
+
|
| 308 |
file_type_map = {
|
| 309 |
"document": MSG_FILE_TYPE_DOCUMENT,
|
| 310 |
"photo": MSG_FILE_TYPE_PHOTO,
|
|
|
|
| 315 |
"animation": MSG_FILE_TYPE_ANIMATION,
|
| 316 |
"video_note": MSG_FILE_TYPE_VIDEO_NOTE
|
| 317 |
}
|
| 318 |
+
|
| 319 |
file_type_attr = next((attr for attr in file_type_map if getattr(file_msg, attr, None)), "unknown")
|
| 320 |
file_type_display = file_type_map.get(file_type_attr, MSG_FILE_TYPE_UNKNOWN)
|
| 321 |
+
|
| 322 |
actual_media = None
|
| 323 |
if file_type_attr == "photo" and file_msg.photo and file_msg.photo.sizes:
|
| 324 |
actual_media = file_msg.photo.sizes[-1]
|
|
|
|
| 326 |
potential = getattr(file_msg, file_type_attr, None)
|
| 327 |
if potential:
|
| 328 |
actual_media = potential
|
| 329 |
+
|
| 330 |
dc_id = MSG_DC_UNKNOWN
|
| 331 |
if hasattr(file_msg, 'raw') and hasattr(file_msg.raw, 'media'):
|
| 332 |
if hasattr(file_msg.raw.media, 'document') and hasattr(file_msg.raw.media.document, 'dc_id'):
|
| 333 |
dc_id = file_msg.raw.media.document.dc_id
|
| 334 |
+
|
| 335 |
dc_text = MSG_DC_FILE_INFO.format(
|
| 336 |
file_name=file_name,
|
| 337 |
file_size=file_size,
|
| 338 |
file_type=file_type_display,
|
| 339 |
dc_id=dc_id
|
| 340 |
)
|
| 341 |
+
|
| 342 |
await message.reply_text(
|
| 343 |
dc_text,
|
| 344 |
quote=True,
|
|
|
|
| 348 |
logger.error(f"Error processing file info for DC command: {e}")
|
| 349 |
await handle_user_error(message, MSG_DC_FILE_ERROR)
|
| 350 |
|
|
|
|
| 351 |
args = message.text.strip().split(maxsplit=1)
|
| 352 |
if len(args) > 1:
|
| 353 |
query = args[1].strip()
|
|
|
|
| 358 |
await handle_user_error(message, MSG_ERROR_USER_INFO)
|
| 359 |
return
|
| 360 |
|
|
|
|
| 361 |
if message.reply_to_message:
|
| 362 |
if has_media(message.reply_to_message):
|
| 363 |
await process_file_dc_info(message.reply_to_message)
|
|
|
|
| 369 |
await handle_user_error(message, MSG_DC_INVALID_USAGE)
|
| 370 |
return
|
| 371 |
|
|
|
|
| 372 |
if message.from_user:
|
| 373 |
await process_dc_info(message.from_user)
|
| 374 |
else:
|
| 375 |
await handle_user_error(message, MSG_DC_ANON_ERROR)
|
|
|
|
| 376 |
except Exception as e:
|
| 377 |
logger.error(f"Error in dc_command: {e}")
|
| 378 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
|
|
|
| 386 |
sent_msg = await message.reply_text(MSG_PING_START, quote=True, link_preview_options=LinkPreviewOptions(is_disabled=True))
|
| 387 |
end_time = time.time()
|
| 388 |
time_taken_ms = (end_time - start_time) * 1000
|
| 389 |
+
|
| 390 |
buttons = [
|
| 391 |
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command"),
|
| 392 |
InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]
|
|
|
|
| 401 |
logger.error(f"Error in ping_command: {e}")
|
| 402 |
await handle_user_error(message, MSG_ERROR_GENERIC)
|
| 403 |
|
|
|
|
|
|
|
| 404 |
async def handle_callback_error(callback_query, error, operation="callback"):
|
|
|
|
| 405 |
error_id = uuid.uuid4().hex[:8]
|
| 406 |
logger.error(f"Error in {operation}: {error}")
|
| 407 |
try:
|
|
|
|
| 414 |
|
| 415 |
@StreamBot.on_callback_query(filters.regex(r"^close_panel$"))
|
| 416 |
async def close_panel_callback(client: Client, callback_query: CallbackQuery):
|
|
|
|
| 417 |
try:
|
| 418 |
await callback_query.answer()
|
| 419 |
await callback_query.message.delete()
|
|
|
|
| 425 |
await ctx.reply_to_message.delete()
|
| 426 |
except Exception as e:
|
| 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:
|
Thunder/bot/plugins/stream.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
Thunder/bot/plugins/stream.py - Streaming and file link plugin handlers for Thunder bot.
|
| 3 |
-
"""
|
| 4 |
|
| 5 |
import time
|
| 6 |
import asyncio
|
|
@@ -9,9 +7,7 @@ import uuid
|
|
| 9 |
from urllib.parse import quote
|
| 10 |
from typing import Optional, Tuple, Dict, Union, List, Set
|
| 11 |
from datetime import datetime, timedelta
|
| 12 |
-
|
| 13 |
-
from pyrogram.client import Client
|
| 14 |
-
from pyrogram import filters, enums
|
| 15 |
from pyrogram.errors import (
|
| 16 |
FloodWait,
|
| 17 |
RPCError,
|
|
@@ -20,15 +16,14 @@ from pyrogram.errors import (
|
|
| 20 |
FileReferenceInvalid,
|
| 21 |
MessageNotModified
|
| 22 |
)
|
| 23 |
-
|
| 24 |
from pyrogram.types import (
|
| 25 |
InlineKeyboardMarkup,
|
| 26 |
InlineKeyboardButton,
|
|
|
|
| 27 |
Message,
|
| 28 |
CallbackQuery,
|
| 29 |
LinkPreviewOptions
|
| 30 |
)
|
| 31 |
-
|
| 32 |
from pyrogram.enums import ChatMemberStatus
|
| 33 |
from Thunder.bot import StreamBot
|
| 34 |
from Thunder.utils.database import db
|
|
@@ -49,7 +44,6 @@ from Thunder.utils.bot_utils import (
|
|
| 49 |
check_admin_privileges
|
| 50 |
)
|
| 51 |
|
| 52 |
-
|
| 53 |
class LRUCache:
|
| 54 |
def __init__(self, max_size=1000, ttl=86400):
|
| 55 |
self.cache = {}
|
|
@@ -59,205 +53,105 @@ class LRUCache:
|
|
| 59 |
self._lock = asyncio.Lock()
|
| 60 |
|
| 61 |
async def get(self, key):
|
| 62 |
-
if key is None:
|
| 63 |
-
return None
|
| 64 |
-
|
| 65 |
async with self._lock:
|
| 66 |
if key not in self.cache:
|
| 67 |
return None
|
| 68 |
-
|
| 69 |
item = self.cache[key]
|
| 70 |
if time.time() - item['timestamp'] > self.ttl:
|
| 71 |
-
self.
|
|
|
|
| 72 |
return None
|
| 73 |
-
|
| 74 |
-
self._update_access_order(key)
|
| 75 |
-
return item
|
| 76 |
-
|
| 77 |
-
def _update_access_order(self, key):
|
| 78 |
-
try:
|
| 79 |
self.access_order.remove(key)
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
self.access_order.append(key)
|
| 83 |
-
|
| 84 |
-
def _remove_item(self, key):
|
| 85 |
-
try:
|
| 86 |
-
del self.cache[key]
|
| 87 |
-
self.access_order.remove(key)
|
| 88 |
-
except (KeyError, ValueError):
|
| 89 |
-
pass
|
| 90 |
|
| 91 |
async def set(self, key, value):
|
| 92 |
-
if key is None or not isinstance(value, dict):
|
| 93 |
-
return
|
| 94 |
-
|
| 95 |
async with self._lock:
|
| 96 |
if key in self.cache:
|
| 97 |
-
self.
|
| 98 |
elif len(self.cache) >= self.max_size:
|
| 99 |
-
lru_key = self.access_order
|
| 100 |
-
self.
|
| 101 |
-
|
| 102 |
self.cache[key] = value
|
| 103 |
-
self.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
async def clean_expired(self):
|
| 106 |
count = 0
|
| 107 |
current_time = time.time()
|
| 108 |
-
|
| 109 |
async with self._lock:
|
| 110 |
-
|
| 111 |
-
for key in self.cache:
|
| 112 |
if current_time - self.cache[key]['timestamp'] > self.ttl:
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
self._remove_item(key)
|
| 117 |
-
count += 1
|
| 118 |
-
|
| 119 |
return count
|
| 120 |
|
| 121 |
-
|
| 122 |
-
# Initialize cache
|
| 123 |
CACHE = LRUCache(max_size=getattr(Var, "CACHE_SIZE", 1000), ttl=86400)
|
| 124 |
|
| 125 |
-
|
| 126 |
-
async def clean_cache_task():
|
| 127 |
-
while True:
|
| 128 |
-
try:
|
| 129 |
-
cleaned_count = await CACHE.clean_expired()
|
| 130 |
-
if cleaned_count > 0:
|
| 131 |
-
logger.info(f"Cache cleanup: Removed {cleaned_count} expired items")
|
| 132 |
-
await asyncio.sleep(3600)
|
| 133 |
-
except Exception as e:
|
| 134 |
-
logger.error(f"Error in cache cleaning task: {e}")
|
| 135 |
-
await asyncio.sleep(60)
|
| 136 |
-
|
| 137 |
-
|
| 138 |
class RateLimiter:
|
| 139 |
def __init__(self, max_calls, time_period):
|
| 140 |
self.max_calls = max_calls
|
| 141 |
self.time_period = time_period
|
| 142 |
self.calls = {}
|
| 143 |
self._lock = asyncio.Lock()
|
| 144 |
-
self._cleanup_task = None
|
| 145 |
-
self._stopping = False
|
| 146 |
-
|
| 147 |
-
def start_cleanup_task(self):
|
| 148 |
-
if self._cleanup_task is None or self._cleanup_task.done():
|
| 149 |
-
self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
|
| 150 |
-
self._cleanup_task.add_done_callback(self._cleanup_task_done)
|
| 151 |
-
|
| 152 |
-
def stop_cleanup_task(self):
|
| 153 |
-
self._stopping = True
|
| 154 |
-
if self._cleanup_task and not self._cleanup_task.done():
|
| 155 |
-
self._cleanup_task.cancel()
|
| 156 |
-
|
| 157 |
-
def _cleanup_task_done(self, task):
|
| 158 |
-
try:
|
| 159 |
-
task.result()
|
| 160 |
-
except asyncio.CancelledError:
|
| 161 |
-
pass
|
| 162 |
-
except Exception as e:
|
| 163 |
-
logger.error(f"Error in rate limiter cleanup task: {e}")
|
| 164 |
-
if not self._stopping:
|
| 165 |
-
asyncio.create_task(self._delayed_restart())
|
| 166 |
-
|
| 167 |
-
async def _delayed_restart(self):
|
| 168 |
-
await asyncio.sleep(5)
|
| 169 |
-
if not self._stopping:
|
| 170 |
-
self.start_cleanup_task()
|
| 171 |
-
|
| 172 |
-
async def _periodic_cleanup(self):
|
| 173 |
-
while not self._stopping:
|
| 174 |
-
try:
|
| 175 |
-
await asyncio.sleep(60)
|
| 176 |
-
await self._cleanup_old_entries()
|
| 177 |
-
except asyncio.CancelledError:
|
| 178 |
-
break
|
| 179 |
-
except Exception as e:
|
| 180 |
-
logger.error(f"Error in periodic cleanup: {e}")
|
| 181 |
-
await asyncio.sleep(30)
|
| 182 |
-
|
| 183 |
-
async def _cleanup_old_entries(self):
|
| 184 |
-
now = time.time()
|
| 185 |
-
async with self._lock:
|
| 186 |
-
for user_id in list(self.calls.keys()):
|
| 187 |
-
self.calls[user_id] = [ts for ts in self.calls[user_id]
|
| 188 |
-
if now - ts <= self.time_period]
|
| 189 |
-
if not self.calls[user_id]:
|
| 190 |
-
del self.calls[user_id]
|
| 191 |
|
| 192 |
async def is_rate_limited(self, user_id):
|
| 193 |
-
if user_id is None:
|
| 194 |
-
return False
|
| 195 |
-
|
| 196 |
async with self._lock:
|
| 197 |
now = time.time()
|
| 198 |
if user_id not in self.calls:
|
| 199 |
self.calls[user_id] = []
|
| 200 |
-
|
| 201 |
self.calls[user_id] = [
|
| 202 |
ts for ts in self.calls[user_id]
|
| 203 |
if now - ts <= self.time_period
|
| 204 |
]
|
| 205 |
-
|
| 206 |
if len(self.calls[user_id]) >= self.max_calls:
|
| 207 |
return True
|
| 208 |
-
|
| 209 |
self.calls[user_id].append(now)
|
| 210 |
return False
|
| 211 |
|
| 212 |
async def get_reset_time(self, user_id):
|
| 213 |
-
if user_id is None:
|
| 214 |
-
return 0
|
| 215 |
-
|
| 216 |
async with self._lock:
|
| 217 |
if user_id not in self.calls or not self.calls[user_id]:
|
| 218 |
return 0
|
| 219 |
-
|
| 220 |
now = time.time()
|
| 221 |
oldest_call = min(self.calls[user_id])
|
| 222 |
return max(0, self.time_period - (now - oldest_call))
|
| 223 |
|
| 224 |
-
|
| 225 |
-
# Initialize rate limiter
|
| 226 |
rate_limiter = RateLimiter(max_calls=20, time_period=60)
|
| 227 |
|
| 228 |
-
|
| 229 |
-
# Helper Functions
|
| 230 |
async def handle_flood_wait(e):
|
| 231 |
wait_time = e.value
|
| 232 |
-
logger.
|
| 233 |
-
jitter = random.uniform(0, 0.
|
| 234 |
-
await asyncio.sleep(wait_time + jitter)
|
| 235 |
-
|
| 236 |
|
| 237 |
def get_file_unique_id(media_message):
|
| 238 |
media_types = [
|
| 239 |
'document', 'video', 'audio', 'photo', 'animation',
|
| 240 |
'voice', 'video_note', 'sticker'
|
| 241 |
]
|
| 242 |
-
|
| 243 |
for media_type in media_types:
|
| 244 |
media = getattr(media_message, media_type, None)
|
| 245 |
if media:
|
| 246 |
return media.file_unique_id
|
| 247 |
-
|
| 248 |
return None
|
| 249 |
|
| 250 |
-
|
| 251 |
async def forward_media(media_message):
|
| 252 |
for retry in range(3):
|
| 253 |
try:
|
| 254 |
result = await media_message.copy(chat_id=Var.BIN_CHANNEL)
|
| 255 |
-
await asyncio.sleep(0.
|
| 256 |
return result
|
| 257 |
except Exception:
|
| 258 |
try:
|
| 259 |
result = await media_message.forward(chat_id=Var.BIN_CHANNEL)
|
| 260 |
-
await asyncio.sleep(0.
|
| 261 |
return result
|
| 262 |
except FloodWait as flood_error:
|
| 263 |
if retry < 2:
|
|
@@ -269,17 +163,22 @@ async def forward_media(media_message):
|
|
| 269 |
logger.error(f"Error forwarding media: {forward_error}")
|
| 270 |
raise
|
| 271 |
await asyncio.sleep(0.5)
|
| 272 |
-
|
| 273 |
raise Exception("Failed to forward media after multiple attempts")
|
| 274 |
|
| 275 |
-
|
| 276 |
async def log_request(log_msg, user, stream_link, online_link):
|
| 277 |
try:
|
| 278 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
await log_msg.reply_text(
|
| 280 |
MSG_NEW_FILE_REQUEST.format(
|
| 281 |
-
|
| 282 |
-
|
| 283 |
online_link=online_link,
|
| 284 |
stream_link=stream_link
|
| 285 |
),
|
|
@@ -290,17 +189,14 @@ async def log_request(log_msg, user, stream_link, online_link):
|
|
| 290 |
except Exception:
|
| 291 |
pass
|
| 292 |
|
| 293 |
-
|
| 294 |
async def process_media_message(client, command_message, media_message, notify=True, shortener=True):
|
| 295 |
retries = 0
|
| 296 |
max_retries = 3
|
| 297 |
cache_key = get_file_unique_id(media_message)
|
| 298 |
-
|
| 299 |
if cache_key is None:
|
| 300 |
if notify:
|
| 301 |
await command_message.reply_text(MSG_ERROR_FILE_ID_EXTRACT, quote=True)
|
| 302 |
return None
|
| 303 |
-
|
| 304 |
cached_data = await CACHE.get(cache_key)
|
| 305 |
if cached_data:
|
| 306 |
if notify:
|
|
@@ -313,12 +209,10 @@ async def process_media_message(client, command_message, media_message, notify=T
|
|
| 313 |
cached_data['online_link']
|
| 314 |
)
|
| 315 |
return cached_data['online_link']
|
| 316 |
-
|
| 317 |
while retries < max_retries:
|
| 318 |
try:
|
| 319 |
log_msg = await forward_media(media_message)
|
| 320 |
stream_link, online_link, media_name, media_size = await generate_media_links(log_msg, shortener=shortener)
|
| 321 |
-
|
| 322 |
await CACHE.set(cache_key, {
|
| 323 |
'media_name': media_name,
|
| 324 |
'media_size': media_size,
|
|
@@ -327,7 +221,6 @@ async def process_media_message(client, command_message, media_message, notify=T
|
|
| 327 |
'message_id': log_msg.id,
|
| 328 |
'timestamp': time.time()
|
| 329 |
})
|
| 330 |
-
|
| 331 |
if notify:
|
| 332 |
await send_links_to_user(
|
| 333 |
client,
|
|
@@ -337,10 +230,8 @@ async def process_media_message(client, command_message, media_message, notify=T
|
|
| 337 |
stream_link,
|
| 338 |
online_link
|
| 339 |
)
|
| 340 |
-
|
| 341 |
await log_request(log_msg, command_message.from_user, stream_link, online_link)
|
| 342 |
return online_link
|
| 343 |
-
|
| 344 |
except FloodWait as e:
|
| 345 |
await handle_flood_wait(e)
|
| 346 |
retries += 1
|
|
@@ -359,22 +250,18 @@ async def process_media_message(client, command_message, media_message, notify=T
|
|
| 359 |
retries += 1
|
| 360 |
await asyncio.sleep(0.3)
|
| 361 |
continue
|
| 362 |
-
|
| 363 |
if notify:
|
| 364 |
await handle_user_error(
|
| 365 |
command_message,
|
| 366 |
MSG_ERROR_PROCESSING_MEDIA
|
| 367 |
)
|
| 368 |
-
|
| 369 |
await notify_owner(
|
| 370 |
client,
|
| 371 |
MSG_CRITICAL_ERROR.format(error=e, error_id=uuid.uuid4().hex[:8])
|
| 372 |
)
|
| 373 |
return None
|
| 374 |
-
|
| 375 |
return None
|
| 376 |
|
| 377 |
-
|
| 378 |
async def retry_failed_media(client, command_message, media_messages, status_msg=None, shortener=True):
|
| 379 |
results = []
|
| 380 |
for i, msg in enumerate(media_messages):
|
|
@@ -382,7 +269,6 @@ async def retry_failed_media(client, command_message, media_messages, status_msg
|
|
| 382 |
result = await process_media_message(client, command_message, msg, notify=False, shortener=shortener)
|
| 383 |
if result:
|
| 384 |
results.append(result)
|
| 385 |
-
|
| 386 |
if status_msg and i % 2 == 0:
|
| 387 |
try:
|
| 388 |
await status_msg.edit(f"🔄 **Retrying Failed Files:** {len(results)}/{len(media_messages)} processed")
|
|
@@ -390,24 +276,36 @@ async def retry_failed_media(client, command_message, media_messages, status_msg
|
|
| 390 |
pass
|
| 391 |
except Exception as e:
|
| 392 |
logger.error(f"Error retrying media: {e}")
|
| 393 |
-
|
| 394 |
return results
|
| 395 |
|
| 396 |
-
|
| 397 |
async def process_multiple_messages(client, command_message, reply_msg, num_files, status_msg, shortener=True):
|
| 398 |
chat_id = command_message.chat.id
|
| 399 |
start_message_id = reply_msg.id
|
| 400 |
end_message_id = start_message_id + num_files - 1
|
| 401 |
message_ids = list(range(start_message_id, end_message_id + 1))
|
| 402 |
-
|
| 403 |
-
batch_size = 10
|
| 404 |
-
processed_count = 0
|
| 405 |
-
failed_count = 0
|
| 406 |
-
download_links = []
|
| 407 |
-
failed_messages = []
|
| 408 |
last_status_text = ""
|
| 409 |
-
|
| 410 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
for i in range(0, len(message_ids), batch_size):
|
| 412 |
batch_ids = message_ids[i:i+batch_size]
|
| 413 |
new_status_text = MSG_PROCESSING_BATCH.format(
|
|
@@ -415,16 +313,13 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 415 |
total_batches=(len(message_ids)+batch_size-1)//batch_size,
|
| 416 |
file_count=len(batch_ids)
|
| 417 |
)
|
| 418 |
-
|
| 419 |
if new_status_text != last_status_text:
|
| 420 |
try:
|
| 421 |
await status_msg.edit(new_status_text)
|
| 422 |
last_status_text = new_status_text
|
| 423 |
except MessageNotModified:
|
| 424 |
pass
|
| 425 |
-
|
| 426 |
await asyncio.sleep(1.0)
|
| 427 |
-
|
| 428 |
messages = []
|
| 429 |
for retry in range(3):
|
| 430 |
try:
|
|
@@ -439,14 +334,12 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 439 |
if retry == 2:
|
| 440 |
logger.error(f"Failed to get batch {i//batch_size+1}: {e}")
|
| 441 |
await asyncio.sleep(0.5)
|
| 442 |
-
|
| 443 |
for msg in messages:
|
| 444 |
if msg and msg.media:
|
| 445 |
try:
|
| 446 |
result = await process_media_message(
|
| 447 |
client, command_message, msg, notify=False, shortener=shortener
|
| 448 |
)
|
| 449 |
-
|
| 450 |
if result:
|
| 451 |
download_links.append(result)
|
| 452 |
processed_count += 1
|
|
@@ -457,16 +350,13 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 457 |
failed_count += 1
|
| 458 |
failed_messages.append(msg)
|
| 459 |
logger.error(f"Failed to process {msg.id}: {e}")
|
| 460 |
-
|
| 461 |
await asyncio.sleep(1.0)
|
| 462 |
-
|
| 463 |
if processed_count % 5 == 0 or processed_count + failed_count == len(message_ids):
|
| 464 |
new_status_text = MSG_PROCESSING_STATUS.format(
|
| 465 |
processed=processed_count,
|
| 466 |
total=num_files,
|
| 467 |
failed=failed_count
|
| 468 |
)
|
| 469 |
-
|
| 470 |
if new_status_text != last_status_text:
|
| 471 |
try:
|
| 472 |
await status_msg.edit(new_status_text)
|
|
@@ -474,7 +364,6 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 474 |
except MessageNotModified:
|
| 475 |
pass
|
| 476 |
|
| 477 |
-
# Handle retries for failed messages
|
| 478 |
if failed_messages and len(failed_messages) < num_files / 2:
|
| 479 |
new_status_text = MSG_RETRYING_FILES.format(count=len(failed_messages))
|
| 480 |
if new_status_text != last_status_text:
|
|
@@ -483,32 +372,23 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 483 |
last_status_text = new_status_text
|
| 484 |
except MessageNotModified:
|
| 485 |
pass
|
| 486 |
-
|
| 487 |
retry_results = await retry_failed_media(
|
| 488 |
client, command_message, failed_messages, status_msg, shortener
|
| 489 |
)
|
| 490 |
-
|
| 491 |
if retry_results:
|
| 492 |
download_links.extend(retry_results)
|
| 493 |
processed_count += len(retry_results)
|
| 494 |
failed_count -= len(retry_results)
|
| 495 |
|
| 496 |
-
# Send batch links to user
|
| 497 |
def chunk_list(lst, n):
|
| 498 |
for i in range(0, len(lst), n):
|
| 499 |
yield lst[i:i + n]
|
| 500 |
|
| 501 |
for chunk in chunk_list(download_links, 20):
|
| 502 |
links_text = "\n".join(chunk)
|
| 503 |
-
|
| 504 |
-
# Message for the group chat
|
| 505 |
group_message_content = MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n`{links_text}`"
|
| 506 |
-
|
| 507 |
-
# Message for the user's DM
|
| 508 |
dm_prefix = MSG_DM_BATCH_PREFIX.format(chat_title=command_message.chat.title)
|
| 509 |
dm_message_text = f"{dm_prefix}\n{group_message_content}"
|
| 510 |
-
|
| 511 |
-
# Send to original chat (group/supergroup)
|
| 512 |
try:
|
| 513 |
await command_message.reply_text(
|
| 514 |
group_message_content,
|
|
@@ -518,8 +398,6 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 518 |
)
|
| 519 |
except Exception as e:
|
| 520 |
logger.error(f"Error sending batch links to original chat {command_message.chat.id}: {e}")
|
| 521 |
-
|
| 522 |
-
# Send to user's DM (if in a group/supergroup)
|
| 523 |
if command_message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
|
| 524 |
try:
|
| 525 |
await client.send_message(
|
|
@@ -534,24 +412,20 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 534 |
MSG_ERROR_DM_FAILED,
|
| 535 |
quote=True
|
| 536 |
)
|
| 537 |
-
|
| 538 |
if len(chunk) > 10:
|
| 539 |
await asyncio.sleep(0.5)
|
| 540 |
|
| 541 |
-
# Update final status
|
| 542 |
new_status_text = MSG_PROCESSING_RESULT.format(
|
| 543 |
processed=processed_count,
|
| 544 |
total=num_files,
|
| 545 |
failed=failed_count
|
| 546 |
)
|
| 547 |
-
|
| 548 |
if new_status_text != last_status_text:
|
| 549 |
try:
|
| 550 |
await status_msg.edit(new_status_text)
|
| 551 |
last_status_text = new_status_text
|
| 552 |
except MessageNotModified:
|
| 553 |
pass
|
| 554 |
-
|
| 555 |
except Exception as e:
|
| 556 |
logger.error(f"Error in batch processing: {e}")
|
| 557 |
new_status_text = MSG_PROCESSING_ERROR.format(
|
|
@@ -560,7 +434,6 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 560 |
total=num_files,
|
| 561 |
error_id=uuid.uuid4().hex[:8]
|
| 562 |
)
|
| 563 |
-
|
| 564 |
if new_status_text != last_status_text:
|
| 565 |
try:
|
| 566 |
await status_msg.edit(new_status_text)
|
|
@@ -568,7 +441,6 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 568 |
except MessageNotModified:
|
| 569 |
pass
|
| 570 |
|
| 571 |
-
|
| 572 |
@StreamBot.on_message(filters.command("link") & ~filters.private)
|
| 573 |
@check_banned
|
| 574 |
@require_token
|
|
@@ -576,7 +448,6 @@ async def process_multiple_messages(client, command_message, reply_msg, num_file
|
|
| 576 |
@force_channel_check
|
| 577 |
async def link_handler(client, message, shortener=True):
|
| 578 |
user_id = message.from_user.id
|
| 579 |
-
|
| 580 |
if not await db.is_user_exist(user_id):
|
| 581 |
try:
|
| 582 |
invite_link = f"https://t.me/{client.me.username}?start=start"
|
|
@@ -590,7 +461,6 @@ async def link_handler(client, message, shortener=True):
|
|
| 590 |
except Exception:
|
| 591 |
pass
|
| 592 |
return
|
| 593 |
-
|
| 594 |
if await rate_limiter.is_rate_limited(user_id):
|
| 595 |
reset_time = await rate_limiter.get_reset_time(user_id)
|
| 596 |
await message.reply_text(
|
|
@@ -598,7 +468,6 @@ async def link_handler(client, message, shortener=True):
|
|
| 598 |
quote=True
|
| 599 |
)
|
| 600 |
return
|
| 601 |
-
|
| 602 |
if message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
|
| 603 |
is_admin = await check_admin_privileges(client, message.chat.id)
|
| 604 |
if not is_admin:
|
|
@@ -607,14 +476,12 @@ async def link_handler(client, message, shortener=True):
|
|
| 607 |
quote=True
|
| 608 |
)
|
| 609 |
return
|
| 610 |
-
|
| 611 |
if not message.reply_to_message:
|
| 612 |
await message.reply_text(
|
| 613 |
MSG_ERROR_REPLY_FILE,
|
| 614 |
quote=True
|
| 615 |
)
|
| 616 |
return
|
| 617 |
-
|
| 618 |
reply_msg = message.reply_to_message
|
| 619 |
if not reply_msg.media:
|
| 620 |
await message.reply_text(
|
|
@@ -622,10 +489,8 @@ async def link_handler(client, message, shortener=True):
|
|
| 622 |
quote=True
|
| 623 |
)
|
| 624 |
return
|
| 625 |
-
|
| 626 |
command_parts = message.text.strip().split()
|
| 627 |
num_files = 1
|
| 628 |
-
|
| 629 |
if len(command_parts) > 1:
|
| 630 |
try:
|
| 631 |
num_files = int(command_parts[1])
|
|
@@ -641,12 +506,10 @@ async def link_handler(client, message, shortener=True):
|
|
| 641 |
quote=True
|
| 642 |
)
|
| 643 |
return
|
| 644 |
-
|
| 645 |
processing_msg = await message.reply_text(
|
| 646 |
MSG_PROCESSING_REQUEST,
|
| 647 |
quote=True
|
| 648 |
)
|
| 649 |
-
|
| 650 |
try:
|
| 651 |
if num_files == 1:
|
| 652 |
result = await process_media_message(client, message, reply_msg, shortener=shortener)
|
|
@@ -684,7 +547,6 @@ async def link_handler(client, message, shortener=True):
|
|
| 684 |
MSG_ERROR_DM_FAILED,
|
| 685 |
quote=True
|
| 686 |
)
|
| 687 |
-
|
| 688 |
await processing_msg.delete()
|
| 689 |
else:
|
| 690 |
try:
|
|
@@ -693,7 +555,6 @@ async def link_handler(client, message, shortener=True):
|
|
| 693 |
pass
|
| 694 |
else:
|
| 695 |
await process_multiple_messages(client, message, reply_msg, num_files, processing_msg, shortener)
|
| 696 |
-
|
| 697 |
except Exception as e:
|
| 698 |
logger.error(f"Error handling link command: {e}")
|
| 699 |
try:
|
|
@@ -701,7 +562,6 @@ async def link_handler(client, message, shortener=True):
|
|
| 701 |
except MessageNotModified:
|
| 702 |
pass
|
| 703 |
|
| 704 |
-
|
| 705 |
@StreamBot.on_message(
|
| 706 |
filters.private & filters.incoming & (
|
| 707 |
filters.document | filters.video | filters.photo | filters.audio |
|
|
@@ -716,7 +576,6 @@ async def link_handler(client, message, shortener=True):
|
|
| 716 |
async def private_receive_handler(client, message, shortener=True):
|
| 717 |
if not message.from_user:
|
| 718 |
return
|
| 719 |
-
|
| 720 |
if await rate_limiter.is_rate_limited(message.from_user.id):
|
| 721 |
reset_time = await rate_limiter.get_reset_time(message.from_user.id)
|
| 722 |
await message.reply_text(
|
|
@@ -724,18 +583,15 @@ async def private_receive_handler(client, message, shortener=True):
|
|
| 724 |
quote=True
|
| 725 |
)
|
| 726 |
return
|
| 727 |
-
|
| 728 |
await log_new_user(
|
| 729 |
bot=client,
|
| 730 |
user_id=message.from_user.id,
|
| 731 |
first_name=message.from_user.first_name
|
| 732 |
)
|
| 733 |
-
|
| 734 |
processing_msg = await message.reply_text(
|
| 735 |
MSG_PROCESSING_FILE,
|
| 736 |
quote=True
|
| 737 |
)
|
| 738 |
-
|
| 739 |
try:
|
| 740 |
result = await process_media_message(client, message, message, shortener=shortener)
|
| 741 |
if result:
|
|
@@ -752,12 +608,11 @@ async def private_receive_handler(client, message, shortener=True):
|
|
| 752 |
except MessageNotModified:
|
| 753 |
pass
|
| 754 |
|
| 755 |
-
|
| 756 |
@StreamBot.on_message(
|
| 757 |
filters.channel & filters.incoming & (
|
| 758 |
filters.document | filters.video | filters.photo | filters.audio |
|
| 759 |
filters.voice | filters.animation | filters.video_note
|
| 760 |
-
),
|
| 761 |
group=-1
|
| 762 |
)
|
| 763 |
@shorten_link
|
|
@@ -765,7 +620,6 @@ async def channel_receive_handler(client, broadcast, shortener=True):
|
|
| 765 |
if hasattr(Var, 'BANNED_CHANNELS') and int(broadcast.chat.id) in Var.BANNED_CHANNELS:
|
| 766 |
await client.leave_chat(broadcast.chat.id)
|
| 767 |
return
|
| 768 |
-
|
| 769 |
can_edit = False
|
| 770 |
try:
|
| 771 |
member = await client.get_chat_member(broadcast.chat.id, client.me.id)
|
|
@@ -775,17 +629,13 @@ async def channel_receive_handler(client, broadcast, shortener=True):
|
|
| 775 |
]
|
| 776 |
except Exception:
|
| 777 |
can_edit = False
|
| 778 |
-
|
| 779 |
retries = 0
|
| 780 |
max_retries = 3
|
| 781 |
-
|
| 782 |
while retries < max_retries:
|
| 783 |
try:
|
| 784 |
log_msg = await forward_media(broadcast)
|
| 785 |
stream_link, online_link, media_name, media_size = await generate_media_links(log_msg, shortener=shortener)
|
| 786 |
-
|
| 787 |
await log_request(log_msg, broadcast.chat, stream_link, online_link)
|
| 788 |
-
|
| 789 |
if can_edit:
|
| 790 |
try:
|
| 791 |
await client.edit_message_reply_markup(
|
|
@@ -800,9 +650,7 @@ async def channel_receive_handler(client, broadcast, shortener=True):
|
|
| 800 |
)
|
| 801 |
except Exception:
|
| 802 |
pass
|
| 803 |
-
|
| 804 |
break
|
| 805 |
-
|
| 806 |
except FloodWait as e:
|
| 807 |
await handle_flood_wait(e)
|
| 808 |
retries += 1
|
|
@@ -817,13 +665,18 @@ async def channel_receive_handler(client, broadcast, shortener=True):
|
|
| 817 |
retries += 1
|
| 818 |
await asyncio.sleep(0.5)
|
| 819 |
continue
|
| 820 |
-
|
| 821 |
await notify_owner(
|
| 822 |
client,
|
| 823 |
MSG_CRITICAL_ERROR.format(error=e, error_id=uuid.uuid4().hex[:8])
|
| 824 |
)
|
| 825 |
break
|
| 826 |
|
| 827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 828 |
StreamBot.loop.create_task(clean_cache_task())
|
| 829 |
-
rate_limiter.start_cleanup_task()
|
|
|
|
| 1 |
+
# Thunder/bot/plugins/stream.py
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import time
|
| 4 |
import asyncio
|
|
|
|
| 7 |
from urllib.parse import quote
|
| 8 |
from typing import Optional, Tuple, Dict, Union, List, Set
|
| 9 |
from datetime import datetime, timedelta
|
| 10 |
+
from pyrogram import Client, filters, enums
|
|
|
|
|
|
|
| 11 |
from pyrogram.errors import (
|
| 12 |
FloodWait,
|
| 13 |
RPCError,
|
|
|
|
| 16 |
FileReferenceInvalid,
|
| 17 |
MessageNotModified
|
| 18 |
)
|
|
|
|
| 19 |
from pyrogram.types import (
|
| 20 |
InlineKeyboardMarkup,
|
| 21 |
InlineKeyboardButton,
|
| 22 |
+
InlineKeyboardMarkup,
|
| 23 |
Message,
|
| 24 |
CallbackQuery,
|
| 25 |
LinkPreviewOptions
|
| 26 |
)
|
|
|
|
| 27 |
from pyrogram.enums import ChatMemberStatus
|
| 28 |
from Thunder.bot import StreamBot
|
| 29 |
from Thunder.utils.database import db
|
|
|
|
| 44 |
check_admin_privileges
|
| 45 |
)
|
| 46 |
|
|
|
|
| 47 |
class LRUCache:
|
| 48 |
def __init__(self, max_size=1000, ttl=86400):
|
| 49 |
self.cache = {}
|
|
|
|
| 53 |
self._lock = asyncio.Lock()
|
| 54 |
|
| 55 |
async def get(self, key):
|
|
|
|
|
|
|
|
|
|
| 56 |
async with self._lock:
|
| 57 |
if key not in self.cache:
|
| 58 |
return None
|
|
|
|
| 59 |
item = self.cache[key]
|
| 60 |
if time.time() - item['timestamp'] > self.ttl:
|
| 61 |
+
del self.cache[key]
|
| 62 |
+
self.access_order.remove(key)
|
| 63 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
self.access_order.remove(key)
|
| 65 |
+
self.access_order.append(key)
|
| 66 |
+
return item
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
|
| 68 |
async def set(self, key, value):
|
|
|
|
|
|
|
|
|
|
| 69 |
async with self._lock:
|
| 70 |
if key in self.cache:
|
| 71 |
+
self.access_order.remove(key)
|
| 72 |
elif len(self.cache) >= self.max_size:
|
| 73 |
+
lru_key = self.access_order.pop(0)
|
| 74 |
+
del self.cache[lru_key]
|
|
|
|
| 75 |
self.cache[key] = value
|
| 76 |
+
self.access_order.append(key)
|
| 77 |
+
|
| 78 |
+
async def delete(self, key):
|
| 79 |
+
async with self._lock:
|
| 80 |
+
if key in self.cache:
|
| 81 |
+
del self.cache[key]
|
| 82 |
+
self.access_order.remove(key)
|
| 83 |
|
| 84 |
async def clean_expired(self):
|
| 85 |
count = 0
|
| 86 |
current_time = time.time()
|
|
|
|
| 87 |
async with self._lock:
|
| 88 |
+
for key in list(self.cache.keys()):
|
|
|
|
| 89 |
if current_time - self.cache[key]['timestamp'] > self.ttl:
|
| 90 |
+
del self.cache[key]
|
| 91 |
+
self.access_order.remove(key)
|
| 92 |
+
count += 1
|
|
|
|
|
|
|
|
|
|
| 93 |
return count
|
| 94 |
|
|
|
|
|
|
|
| 95 |
CACHE = LRUCache(max_size=getattr(Var, "CACHE_SIZE", 1000), ttl=86400)
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
class RateLimiter:
|
| 98 |
def __init__(self, max_calls, time_period):
|
| 99 |
self.max_calls = max_calls
|
| 100 |
self.time_period = time_period
|
| 101 |
self.calls = {}
|
| 102 |
self._lock = asyncio.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
async def is_rate_limited(self, user_id):
|
|
|
|
|
|
|
|
|
|
| 105 |
async with self._lock:
|
| 106 |
now = time.time()
|
| 107 |
if user_id not in self.calls:
|
| 108 |
self.calls[user_id] = []
|
|
|
|
| 109 |
self.calls[user_id] = [
|
| 110 |
ts for ts in self.calls[user_id]
|
| 111 |
if now - ts <= self.time_period
|
| 112 |
]
|
|
|
|
| 113 |
if len(self.calls[user_id]) >= self.max_calls:
|
| 114 |
return True
|
|
|
|
| 115 |
self.calls[user_id].append(now)
|
| 116 |
return False
|
| 117 |
|
| 118 |
async def get_reset_time(self, user_id):
|
|
|
|
|
|
|
|
|
|
| 119 |
async with self._lock:
|
| 120 |
if user_id not in self.calls or not self.calls[user_id]:
|
| 121 |
return 0
|
|
|
|
| 122 |
now = time.time()
|
| 123 |
oldest_call = min(self.calls[user_id])
|
| 124 |
return max(0, self.time_period - (now - oldest_call))
|
| 125 |
|
|
|
|
|
|
|
| 126 |
rate_limiter = RateLimiter(max_calls=20, time_period=60)
|
| 127 |
|
|
|
|
|
|
|
| 128 |
async def handle_flood_wait(e):
|
| 129 |
wait_time = e.value
|
| 130 |
+
logger.warning(f"FloodWait encountered. Sleeping for {wait_time} seconds.")
|
| 131 |
+
jitter = random.uniform(0, 0.1 * wait_time)
|
| 132 |
+
await asyncio.sleep(wait_time + jitter + 1)
|
|
|
|
| 133 |
|
| 134 |
def get_file_unique_id(media_message):
|
| 135 |
media_types = [
|
| 136 |
'document', 'video', 'audio', 'photo', 'animation',
|
| 137 |
'voice', 'video_note', 'sticker'
|
| 138 |
]
|
|
|
|
| 139 |
for media_type in media_types:
|
| 140 |
media = getattr(media_message, media_type, None)
|
| 141 |
if media:
|
| 142 |
return media.file_unique_id
|
|
|
|
| 143 |
return None
|
| 144 |
|
|
|
|
| 145 |
async def forward_media(media_message):
|
| 146 |
for retry in range(3):
|
| 147 |
try:
|
| 148 |
result = await media_message.copy(chat_id=Var.BIN_CHANNEL)
|
| 149 |
+
await asyncio.sleep(0.2)
|
| 150 |
return result
|
| 151 |
except Exception:
|
| 152 |
try:
|
| 153 |
result = await media_message.forward(chat_id=Var.BIN_CHANNEL)
|
| 154 |
+
await asyncio.sleep(0.2)
|
| 155 |
return result
|
| 156 |
except FloodWait as flood_error:
|
| 157 |
if retry < 2:
|
|
|
|
| 163 |
logger.error(f"Error forwarding media: {forward_error}")
|
| 164 |
raise
|
| 165 |
await asyncio.sleep(0.5)
|
|
|
|
| 166 |
raise Exception("Failed to forward media after multiple attempts")
|
| 167 |
|
|
|
|
| 168 |
async def log_request(log_msg, user, stream_link, online_link):
|
| 169 |
try:
|
| 170 |
+
if getattr(user, 'title', None):
|
| 171 |
+
source_info = f"{user.title} (Chat/Channel)"
|
| 172 |
+
else:
|
| 173 |
+
first = getattr(user, 'first_name', '') or ''
|
| 174 |
+
last = getattr(user, 'last_name', '') or ''
|
| 175 |
+
display_name = f"{first} {last}".strip() or "Unknown"
|
| 176 |
+
source_info = f"{display_name}"
|
| 177 |
+
id_ = user.id
|
| 178 |
await log_msg.reply_text(
|
| 179 |
MSG_NEW_FILE_REQUEST.format(
|
| 180 |
+
source_info=source_info,
|
| 181 |
+
id_=id_,
|
| 182 |
online_link=online_link,
|
| 183 |
stream_link=stream_link
|
| 184 |
),
|
|
|
|
| 189 |
except Exception:
|
| 190 |
pass
|
| 191 |
|
|
|
|
| 192 |
async def process_media_message(client, command_message, media_message, notify=True, shortener=True):
|
| 193 |
retries = 0
|
| 194 |
max_retries = 3
|
| 195 |
cache_key = get_file_unique_id(media_message)
|
|
|
|
| 196 |
if cache_key is None:
|
| 197 |
if notify:
|
| 198 |
await command_message.reply_text(MSG_ERROR_FILE_ID_EXTRACT, quote=True)
|
| 199 |
return None
|
|
|
|
| 200 |
cached_data = await CACHE.get(cache_key)
|
| 201 |
if cached_data:
|
| 202 |
if notify:
|
|
|
|
| 209 |
cached_data['online_link']
|
| 210 |
)
|
| 211 |
return cached_data['online_link']
|
|
|
|
| 212 |
while retries < max_retries:
|
| 213 |
try:
|
| 214 |
log_msg = await forward_media(media_message)
|
| 215 |
stream_link, online_link, media_name, media_size = await generate_media_links(log_msg, shortener=shortener)
|
|
|
|
| 216 |
await CACHE.set(cache_key, {
|
| 217 |
'media_name': media_name,
|
| 218 |
'media_size': media_size,
|
|
|
|
| 221 |
'message_id': log_msg.id,
|
| 222 |
'timestamp': time.time()
|
| 223 |
})
|
|
|
|
| 224 |
if notify:
|
| 225 |
await send_links_to_user(
|
| 226 |
client,
|
|
|
|
| 230 |
stream_link,
|
| 231 |
online_link
|
| 232 |
)
|
|
|
|
| 233 |
await log_request(log_msg, command_message.from_user, stream_link, online_link)
|
| 234 |
return online_link
|
|
|
|
| 235 |
except FloodWait as e:
|
| 236 |
await handle_flood_wait(e)
|
| 237 |
retries += 1
|
|
|
|
| 250 |
retries += 1
|
| 251 |
await asyncio.sleep(0.3)
|
| 252 |
continue
|
|
|
|
| 253 |
if notify:
|
| 254 |
await handle_user_error(
|
| 255 |
command_message,
|
| 256 |
MSG_ERROR_PROCESSING_MEDIA
|
| 257 |
)
|
|
|
|
| 258 |
await notify_owner(
|
| 259 |
client,
|
| 260 |
MSG_CRITICAL_ERROR.format(error=e, error_id=uuid.uuid4().hex[:8])
|
| 261 |
)
|
| 262 |
return None
|
|
|
|
| 263 |
return None
|
| 264 |
|
|
|
|
| 265 |
async def retry_failed_media(client, command_message, media_messages, status_msg=None, shortener=True):
|
| 266 |
results = []
|
| 267 |
for i, msg in enumerate(media_messages):
|
|
|
|
| 269 |
result = await process_media_message(client, command_message, msg, notify=False, shortener=shortener)
|
| 270 |
if result:
|
| 271 |
results.append(result)
|
|
|
|
| 272 |
if status_msg and i % 2 == 0:
|
| 273 |
try:
|
| 274 |
await status_msg.edit(f"🔄 **Retrying Failed Files:** {len(results)}/{len(media_messages)} processed")
|
|
|
|
| 276 |
pass
|
| 277 |
except Exception as e:
|
| 278 |
logger.error(f"Error retrying media: {e}")
|
|
|
|
| 279 |
return results
|
| 280 |
|
|
|
|
| 281 |
async def process_multiple_messages(client, command_message, reply_msg, num_files, status_msg, shortener=True):
|
| 282 |
chat_id = command_message.chat.id
|
| 283 |
start_message_id = reply_msg.id
|
| 284 |
end_message_id = start_message_id + num_files - 1
|
| 285 |
message_ids = list(range(start_message_id, end_message_id + 1))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
last_status_text = ""
|
|
|
|
| 287 |
try:
|
| 288 |
+
batch_size = 5
|
| 289 |
+
processed_count = 0
|
| 290 |
+
failed_count = 0
|
| 291 |
+
download_links = []
|
| 292 |
+
failed_messages = []
|
| 293 |
+
|
| 294 |
+
async def process_single_message(msg):
|
| 295 |
+
try:
|
| 296 |
+
return await process_media_message(
|
| 297 |
+
client,
|
| 298 |
+
command_message,
|
| 299 |
+
msg,
|
| 300 |
+
notify=False
|
| 301 |
+
)
|
| 302 |
+
except FloodWait as e:
|
| 303 |
+
await handle_flood_wait(e)
|
| 304 |
+
return await process_media_message(client, command_message, msg, notify=False)
|
| 305 |
+
except Exception as e:
|
| 306 |
+
logger.error(f"Message {msg.id} error: {e}")
|
| 307 |
+
return None
|
| 308 |
+
|
| 309 |
for i in range(0, len(message_ids), batch_size):
|
| 310 |
batch_ids = message_ids[i:i+batch_size]
|
| 311 |
new_status_text = MSG_PROCESSING_BATCH.format(
|
|
|
|
| 313 |
total_batches=(len(message_ids)+batch_size-1)//batch_size,
|
| 314 |
file_count=len(batch_ids)
|
| 315 |
)
|
|
|
|
| 316 |
if new_status_text != last_status_text:
|
| 317 |
try:
|
| 318 |
await status_msg.edit(new_status_text)
|
| 319 |
last_status_text = new_status_text
|
| 320 |
except MessageNotModified:
|
| 321 |
pass
|
|
|
|
| 322 |
await asyncio.sleep(1.0)
|
|
|
|
| 323 |
messages = []
|
| 324 |
for retry in range(3):
|
| 325 |
try:
|
|
|
|
| 334 |
if retry == 2:
|
| 335 |
logger.error(f"Failed to get batch {i//batch_size+1}: {e}")
|
| 336 |
await asyncio.sleep(0.5)
|
|
|
|
| 337 |
for msg in messages:
|
| 338 |
if msg and msg.media:
|
| 339 |
try:
|
| 340 |
result = await process_media_message(
|
| 341 |
client, command_message, msg, notify=False, shortener=shortener
|
| 342 |
)
|
|
|
|
| 343 |
if result:
|
| 344 |
download_links.append(result)
|
| 345 |
processed_count += 1
|
|
|
|
| 350 |
failed_count += 1
|
| 351 |
failed_messages.append(msg)
|
| 352 |
logger.error(f"Failed to process {msg.id}: {e}")
|
|
|
|
| 353 |
await asyncio.sleep(1.0)
|
|
|
|
| 354 |
if processed_count % 5 == 0 or processed_count + failed_count == len(message_ids):
|
| 355 |
new_status_text = MSG_PROCESSING_STATUS.format(
|
| 356 |
processed=processed_count,
|
| 357 |
total=num_files,
|
| 358 |
failed=failed_count
|
| 359 |
)
|
|
|
|
| 360 |
if new_status_text != last_status_text:
|
| 361 |
try:
|
| 362 |
await status_msg.edit(new_status_text)
|
|
|
|
| 364 |
except MessageNotModified:
|
| 365 |
pass
|
| 366 |
|
|
|
|
| 367 |
if failed_messages and len(failed_messages) < num_files / 2:
|
| 368 |
new_status_text = MSG_RETRYING_FILES.format(count=len(failed_messages))
|
| 369 |
if new_status_text != last_status_text:
|
|
|
|
| 372 |
last_status_text = new_status_text
|
| 373 |
except MessageNotModified:
|
| 374 |
pass
|
|
|
|
| 375 |
retry_results = await retry_failed_media(
|
| 376 |
client, command_message, failed_messages, status_msg, shortener
|
| 377 |
)
|
|
|
|
| 378 |
if retry_results:
|
| 379 |
download_links.extend(retry_results)
|
| 380 |
processed_count += len(retry_results)
|
| 381 |
failed_count -= len(retry_results)
|
| 382 |
|
|
|
|
| 383 |
def chunk_list(lst, n):
|
| 384 |
for i in range(0, len(lst), n):
|
| 385 |
yield lst[i:i + n]
|
| 386 |
|
| 387 |
for chunk in chunk_list(download_links, 20):
|
| 388 |
links_text = "\n".join(chunk)
|
|
|
|
|
|
|
| 389 |
group_message_content = MSG_BATCH_LINKS_READY.format(count=len(chunk)) + f"\n\n`{links_text}`"
|
|
|
|
|
|
|
| 390 |
dm_prefix = MSG_DM_BATCH_PREFIX.format(chat_title=command_message.chat.title)
|
| 391 |
dm_message_text = f"{dm_prefix}\n{group_message_content}"
|
|
|
|
|
|
|
| 392 |
try:
|
| 393 |
await command_message.reply_text(
|
| 394 |
group_message_content,
|
|
|
|
| 398 |
)
|
| 399 |
except Exception as e:
|
| 400 |
logger.error(f"Error sending batch links to original chat {command_message.chat.id}: {e}")
|
|
|
|
|
|
|
| 401 |
if command_message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
|
| 402 |
try:
|
| 403 |
await client.send_message(
|
|
|
|
| 412 |
MSG_ERROR_DM_FAILED,
|
| 413 |
quote=True
|
| 414 |
)
|
|
|
|
| 415 |
if len(chunk) > 10:
|
| 416 |
await asyncio.sleep(0.5)
|
| 417 |
|
|
|
|
| 418 |
new_status_text = MSG_PROCESSING_RESULT.format(
|
| 419 |
processed=processed_count,
|
| 420 |
total=num_files,
|
| 421 |
failed=failed_count
|
| 422 |
)
|
|
|
|
| 423 |
if new_status_text != last_status_text:
|
| 424 |
try:
|
| 425 |
await status_msg.edit(new_status_text)
|
| 426 |
last_status_text = new_status_text
|
| 427 |
except MessageNotModified:
|
| 428 |
pass
|
|
|
|
| 429 |
except Exception as e:
|
| 430 |
logger.error(f"Error in batch processing: {e}")
|
| 431 |
new_status_text = MSG_PROCESSING_ERROR.format(
|
|
|
|
| 434 |
total=num_files,
|
| 435 |
error_id=uuid.uuid4().hex[:8]
|
| 436 |
)
|
|
|
|
| 437 |
if new_status_text != last_status_text:
|
| 438 |
try:
|
| 439 |
await status_msg.edit(new_status_text)
|
|
|
|
| 441 |
except MessageNotModified:
|
| 442 |
pass
|
| 443 |
|
|
|
|
| 444 |
@StreamBot.on_message(filters.command("link") & ~filters.private)
|
| 445 |
@check_banned
|
| 446 |
@require_token
|
|
|
|
| 448 |
@force_channel_check
|
| 449 |
async def link_handler(client, message, shortener=True):
|
| 450 |
user_id = message.from_user.id
|
|
|
|
| 451 |
if not await db.is_user_exist(user_id):
|
| 452 |
try:
|
| 453 |
invite_link = f"https://t.me/{client.me.username}?start=start"
|
|
|
|
| 461 |
except Exception:
|
| 462 |
pass
|
| 463 |
return
|
|
|
|
| 464 |
if await rate_limiter.is_rate_limited(user_id):
|
| 465 |
reset_time = await rate_limiter.get_reset_time(user_id)
|
| 466 |
await message.reply_text(
|
|
|
|
| 468 |
quote=True
|
| 469 |
)
|
| 470 |
return
|
|
|
|
| 471 |
if message.chat.type in [enums.ChatType.GROUP, enums.ChatType.SUPERGROUP]:
|
| 472 |
is_admin = await check_admin_privileges(client, message.chat.id)
|
| 473 |
if not is_admin:
|
|
|
|
| 476 |
quote=True
|
| 477 |
)
|
| 478 |
return
|
|
|
|
| 479 |
if not message.reply_to_message:
|
| 480 |
await message.reply_text(
|
| 481 |
MSG_ERROR_REPLY_FILE,
|
| 482 |
quote=True
|
| 483 |
)
|
| 484 |
return
|
|
|
|
| 485 |
reply_msg = message.reply_to_message
|
| 486 |
if not reply_msg.media:
|
| 487 |
await message.reply_text(
|
|
|
|
| 489 |
quote=True
|
| 490 |
)
|
| 491 |
return
|
|
|
|
| 492 |
command_parts = message.text.strip().split()
|
| 493 |
num_files = 1
|
|
|
|
| 494 |
if len(command_parts) > 1:
|
| 495 |
try:
|
| 496 |
num_files = int(command_parts[1])
|
|
|
|
| 506 |
quote=True
|
| 507 |
)
|
| 508 |
return
|
|
|
|
| 509 |
processing_msg = await message.reply_text(
|
| 510 |
MSG_PROCESSING_REQUEST,
|
| 511 |
quote=True
|
| 512 |
)
|
|
|
|
| 513 |
try:
|
| 514 |
if num_files == 1:
|
| 515 |
result = await process_media_message(client, message, reply_msg, shortener=shortener)
|
|
|
|
| 547 |
MSG_ERROR_DM_FAILED,
|
| 548 |
quote=True
|
| 549 |
)
|
|
|
|
| 550 |
await processing_msg.delete()
|
| 551 |
else:
|
| 552 |
try:
|
|
|
|
| 555 |
pass
|
| 556 |
else:
|
| 557 |
await process_multiple_messages(client, message, reply_msg, num_files, processing_msg, shortener)
|
|
|
|
| 558 |
except Exception as e:
|
| 559 |
logger.error(f"Error handling link command: {e}")
|
| 560 |
try:
|
|
|
|
| 562 |
except MessageNotModified:
|
| 563 |
pass
|
| 564 |
|
|
|
|
| 565 |
@StreamBot.on_message(
|
| 566 |
filters.private & filters.incoming & (
|
| 567 |
filters.document | filters.video | filters.photo | filters.audio |
|
|
|
|
| 576 |
async def private_receive_handler(client, message, shortener=True):
|
| 577 |
if not message.from_user:
|
| 578 |
return
|
|
|
|
| 579 |
if await rate_limiter.is_rate_limited(message.from_user.id):
|
| 580 |
reset_time = await rate_limiter.get_reset_time(message.from_user.id)
|
| 581 |
await message.reply_text(
|
|
|
|
| 583 |
quote=True
|
| 584 |
)
|
| 585 |
return
|
|
|
|
| 586 |
await log_new_user(
|
| 587 |
bot=client,
|
| 588 |
user_id=message.from_user.id,
|
| 589 |
first_name=message.from_user.first_name
|
| 590 |
)
|
|
|
|
| 591 |
processing_msg = await message.reply_text(
|
| 592 |
MSG_PROCESSING_FILE,
|
| 593 |
quote=True
|
| 594 |
)
|
|
|
|
| 595 |
try:
|
| 596 |
result = await process_media_message(client, message, message, shortener=shortener)
|
| 597 |
if result:
|
|
|
|
| 608 |
except MessageNotModified:
|
| 609 |
pass
|
| 610 |
|
|
|
|
| 611 |
@StreamBot.on_message(
|
| 612 |
filters.channel & filters.incoming & (
|
| 613 |
filters.document | filters.video | filters.photo | filters.audio |
|
| 614 |
filters.voice | filters.animation | filters.video_note
|
| 615 |
+
) & ~filters.chat(Var.BIN_CHANNEL),
|
| 616 |
group=-1
|
| 617 |
)
|
| 618 |
@shorten_link
|
|
|
|
| 620 |
if hasattr(Var, 'BANNED_CHANNELS') and int(broadcast.chat.id) in Var.BANNED_CHANNELS:
|
| 621 |
await client.leave_chat(broadcast.chat.id)
|
| 622 |
return
|
|
|
|
| 623 |
can_edit = False
|
| 624 |
try:
|
| 625 |
member = await client.get_chat_member(broadcast.chat.id, client.me.id)
|
|
|
|
| 629 |
]
|
| 630 |
except Exception:
|
| 631 |
can_edit = False
|
|
|
|
| 632 |
retries = 0
|
| 633 |
max_retries = 3
|
|
|
|
| 634 |
while retries < max_retries:
|
| 635 |
try:
|
| 636 |
log_msg = await forward_media(broadcast)
|
| 637 |
stream_link, online_link, media_name, media_size = await generate_media_links(log_msg, shortener=shortener)
|
|
|
|
| 638 |
await log_request(log_msg, broadcast.chat, stream_link, online_link)
|
|
|
|
| 639 |
if can_edit:
|
| 640 |
try:
|
| 641 |
await client.edit_message_reply_markup(
|
|
|
|
| 650 |
)
|
| 651 |
except Exception:
|
| 652 |
pass
|
|
|
|
| 653 |
break
|
|
|
|
| 654 |
except FloodWait as e:
|
| 655 |
await handle_flood_wait(e)
|
| 656 |
retries += 1
|
|
|
|
| 665 |
retries += 1
|
| 666 |
await asyncio.sleep(0.5)
|
| 667 |
continue
|
|
|
|
| 668 |
await notify_owner(
|
| 669 |
client,
|
| 670 |
MSG_CRITICAL_ERROR.format(error=e, error_id=uuid.uuid4().hex[:8])
|
| 671 |
)
|
| 672 |
break
|
| 673 |
|
| 674 |
+
async def clean_cache_task():
|
| 675 |
+
while True:
|
| 676 |
+
try:
|
| 677 |
+
await asyncio.sleep(3600)
|
| 678 |
+
await CACHE.clean_expired()
|
| 679 |
+
except Exception as e:
|
| 680 |
+
logger.error(f"Error in cache cleaning task: {e}")
|
| 681 |
+
|
| 682 |
StreamBot.loop.create_task(clean_cache_task())
|
|
|
Thunder/utils/bot_utils.py
CHANGED
|
@@ -1,24 +1,19 @@
|
|
| 1 |
# Thunder/utils/bot_utils.py
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
-
import time
|
| 5 |
-
import random
|
| 6 |
-
import hashlib
|
| 7 |
-
import uuid
|
| 8 |
from typing import Optional, Tuple
|
| 9 |
-
from urllib.parse import
|
| 10 |
|
| 11 |
from pyrogram import Client, enums
|
| 12 |
-
from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, User, CallbackQuery, LinkPreviewOptions
|
| 13 |
from pyrogram.enums import ChatMemberStatus
|
| 14 |
-
from pyrogram.raw.functions.messages import DeleteHistory
|
| 15 |
-
from pyrogram.errors import FloodWait, UserNotParticipant, PeerIdInvalid, ChannelPrivate, ChatAdminRequired, BadRequest
|
| 16 |
|
| 17 |
-
from Thunder.bot import StreamBot
|
| 18 |
from Thunder.vars import Var
|
| 19 |
from Thunder.utils.database import db
|
| 20 |
from Thunder.utils.human_readable import humanbytes
|
|
|
|
| 21 |
from Thunder.utils.logger import logger
|
|
|
|
| 22 |
from Thunder.utils.messages import (
|
| 23 |
MSG_BUTTON_GET_HELP,
|
| 24 |
MSG_NEW_USER,
|
|
@@ -26,99 +21,75 @@ from Thunder.utils.messages import (
|
|
| 26 |
MSG_DC_USER_INFO,
|
| 27 |
MSG_LINKS,
|
| 28 |
MSG_BUTTON_STREAM_NOW,
|
| 29 |
-
MSG_BUTTON_DOWNLOAD
|
| 30 |
-
MSG_ERROR_GENERIC_CALLBACK
|
| 31 |
)
|
| 32 |
from Thunder.utils.file_properties import get_name, get_media_file_size, get_hash
|
| 33 |
from Thunder.utils.shortener import shorten
|
| 34 |
|
| 35 |
-
|
| 36 |
async def notify_channel(bot: Client, text: str):
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
await bot.send_message(chat_id=Var.BIN_CHANNEL, text=text)
|
| 40 |
-
except Exception:
|
| 41 |
-
pass
|
| 42 |
|
|
|
|
| 43 |
async def notify_owner(client: Client, text: str):
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
for owner_id in owner_ids
|
| 50 |
-
]
|
| 51 |
-
results = await asyncio.gather(*tasks)
|
| 52 |
-
await asyncio.sleep(0.1)
|
| 53 |
-
else:
|
| 54 |
-
await client.send_message(chat_id=owner_ids, text=text)
|
| 55 |
-
await asyncio.sleep(0.1)
|
| 56 |
-
|
| 57 |
-
if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
|
| 58 |
-
await client.send_message(chat_id=Var.BIN_CHANNEL, text=text)
|
| 59 |
-
await asyncio.sleep(0.1)
|
| 60 |
-
except Exception as e:
|
| 61 |
-
logger.error(f"Failed to send message to owner: {e}")
|
| 62 |
|
|
|
|
| 63 |
async def handle_user_error(message: Message, error_msg: str):
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
)
|
| 73 |
-
|
| 74 |
-
pass
|
| 75 |
|
|
|
|
| 76 |
async def log_new_user(bot: Client, user_id: int, first_name: str):
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
user_id=user_id
|
| 86 |
-
)
|
| 87 |
-
)
|
| 88 |
-
except Exception:
|
| 89 |
-
pass
|
| 90 |
|
|
|
|
| 91 |
async def generate_media_links(log_msg: Message, shortener: bool = True) -> Tuple[str, str, str, str]:
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
online_link = await shorten(online_link_raw)
|
| 116 |
-
|
| 117 |
-
return stream_link, online_link, media_name, media_size
|
| 118 |
-
except Exception as e:
|
| 119 |
-
logger.error(f"Error generating media links: {e}")
|
| 120 |
-
return "", "", "", ""
|
| 121 |
|
|
|
|
| 122 |
async def send_links_to_user(client: Client, command_message: Message, media_name: str, media_size: str, stream_link: str, online_link: str):
|
| 123 |
msg_text = MSG_LINKS.format(
|
| 124 |
file_name=media_name,
|
|
@@ -126,25 +97,22 @@ async def send_links_to_user(client: Client, command_message: Message, media_nam
|
|
| 126 |
download_link=online_link,
|
| 127 |
stream_link=stream_link
|
| 128 |
)
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
await asyncio.sleep(0.3)
|
| 144 |
-
except Exception as e:
|
| 145 |
-
logger.error(f"Error sending links to user: {e}")
|
| 146 |
-
raise
|
| 147 |
|
|
|
|
| 148 |
async def generate_dc_text(user: User) -> str:
|
| 149 |
dc_id = user.dc_id if user.dc_id is not None else MSG_DC_UNKNOWN
|
| 150 |
return MSG_DC_USER_INFO.format(
|
|
@@ -153,39 +121,28 @@ async def generate_dc_text(user: User) -> str:
|
|
| 153 |
dc_id=dc_id
|
| 154 |
)
|
| 155 |
|
|
|
|
| 156 |
async def get_user_safely(bot: Client, query) -> Optional[User]:
|
| 157 |
-
|
| 158 |
-
if
|
| 159 |
-
if query.startswith('@'):
|
| 160 |
-
return await bot.get_users(query)
|
| 161 |
-
elif query.isdigit():
|
| 162 |
-
return await bot.get_users(int(query))
|
| 163 |
-
elif isinstance(query, int):
|
| 164 |
return await bot.get_users(query)
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|
|
|
|
| 169 |
async def check_admin_privileges(client: Client, chat_id: int) -> bool:
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
return member.status in [
|
| 173 |
-
ChatMemberStatus.ADMINISTRATOR,
|
| 174 |
-
ChatMemberStatus.OWNER
|
| 175 |
-
]
|
| 176 |
-
except Exception:
|
| 177 |
-
return False
|
| 178 |
|
|
|
|
| 179 |
async def _execute_command_from_callback(client: Client, callback_query: CallbackQuery, command_name: str, command_func):
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
await command_func(client, mock_message)
|
| 188 |
-
logger.info(f"User {callback_query.from_user.id} used {command_name} via callback")
|
| 189 |
-
except Exception as e:
|
| 190 |
-
logger.error(f"Error in {command_name}_callback: {e}")
|
| 191 |
-
await callback_query.answer(MSG_ERROR_GENERIC_CALLBACK.format(error_id=uuid.uuid4().hex[:8]), show_alert=True)
|
|
|
|
| 1 |
# Thunder/utils/bot_utils.py
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
from typing import Optional, Tuple
|
| 5 |
+
from urllib.parse import quote
|
| 6 |
|
| 7 |
from pyrogram import Client, enums
|
| 8 |
+
from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, User, CallbackQuery, LinkPreviewOptions, ReplyParameters
|
| 9 |
from pyrogram.enums import ChatMemberStatus
|
|
|
|
|
|
|
| 10 |
|
|
|
|
| 11 |
from Thunder.vars import Var
|
| 12 |
from Thunder.utils.database import db
|
| 13 |
from Thunder.utils.human_readable import humanbytes
|
| 14 |
+
from Thunder.utils.retry_api import retry_send_message
|
| 15 |
from Thunder.utils.logger import logger
|
| 16 |
+
from Thunder.utils.error_handling import log_errors
|
| 17 |
from Thunder.utils.messages import (
|
| 18 |
MSG_BUTTON_GET_HELP,
|
| 19 |
MSG_NEW_USER,
|
|
|
|
| 21 |
MSG_DC_USER_INFO,
|
| 22 |
MSG_LINKS,
|
| 23 |
MSG_BUTTON_STREAM_NOW,
|
| 24 |
+
MSG_BUTTON_DOWNLOAD
|
|
|
|
| 25 |
)
|
| 26 |
from Thunder.utils.file_properties import get_name, get_media_file_size, get_hash
|
| 27 |
from Thunder.utils.shortener import shorten
|
| 28 |
|
| 29 |
+
@log_errors
|
| 30 |
async def notify_channel(bot: Client, text: str):
|
| 31 |
+
if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
|
| 32 |
+
await bot.send_message(chat_id=Var.BIN_CHANNEL, text=text)
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
+
@log_errors
|
| 35 |
async def notify_owner(client: Client, text: str):
|
| 36 |
+
owner_ids = Var.OWNER_ID
|
| 37 |
+
tasks = [retry_send_message(client, oid, text) for oid in (owner_ids if isinstance(owner_ids, (list, tuple, set)) else [owner_ids])]
|
| 38 |
+
if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
|
| 39 |
+
tasks.append(retry_send_message(client, Var.BIN_CHANNEL, text))
|
| 40 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
+
@log_errors
|
| 43 |
async def handle_user_error(message: Message, error_msg: str):
|
| 44 |
+
await retry_send_message(
|
| 45 |
+
message._client,
|
| 46 |
+
message.chat.id,
|
| 47 |
+
error_msg,
|
| 48 |
+
reply_parameters=ReplyParameters(message_id=message.id),
|
| 49 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 50 |
+
reply_markup=InlineKeyboardMarkup([
|
| 51 |
+
[InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]
|
| 52 |
+
])
|
| 53 |
+
)
|
|
|
|
| 54 |
|
| 55 |
+
@log_errors
|
| 56 |
async def log_new_user(bot: Client, user_id: int, first_name: str):
|
| 57 |
+
if not await db.is_user_exist(user_id):
|
| 58 |
+
await db.add_user(user_id)
|
| 59 |
+
if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
|
| 60 |
+
await retry_send_message(
|
| 61 |
+
bot,
|
| 62 |
+
Var.BIN_CHANNEL,
|
| 63 |
+
MSG_NEW_USER.format(first_name=first_name, user_id=user_id)
|
| 64 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
+
@log_errors
|
| 67 |
async def generate_media_links(log_msg: Message, shortener: bool = True) -> Tuple[str, str, str, str]:
|
| 68 |
+
base_url = Var.URL.rstrip("/")
|
| 69 |
+
file_id = log_msg.id
|
| 70 |
+
media_name = get_name(log_msg)
|
| 71 |
+
if isinstance(media_name, bytes):
|
| 72 |
+
media_name = media_name.decode('utf-8', errors='replace')
|
| 73 |
+
else:
|
| 74 |
+
media_name = str(media_name)
|
| 75 |
+
media_size = humanbytes(get_media_file_size(log_msg))
|
| 76 |
+
file_name_encoded = quote(media_name)
|
| 77 |
+
hash_value = get_hash(log_msg)
|
| 78 |
+
stream_link = f"{base_url}/watch/{hash_value}{file_id}/{file_name_encoded}"
|
| 79 |
+
online_link = f"{base_url}/{hash_value}{file_id}/{file_name_encoded}"
|
| 80 |
+
if shortener and getattr(Var, "SHORTEN_MEDIA_LINKS", False):
|
| 81 |
+
shortened_results = await asyncio.gather(
|
| 82 |
+
shorten(stream_link),
|
| 83 |
+
shorten(online_link),
|
| 84 |
+
return_exceptions=True
|
| 85 |
+
)
|
| 86 |
+
if not isinstance(shortened_results[0], Exception):
|
| 87 |
+
stream_link = shortened_results[0]
|
| 88 |
+
if not isinstance(shortened_results[1], Exception):
|
| 89 |
+
online_link = shortened_results[1]
|
| 90 |
+
return stream_link, online_link, media_name, media_size
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
+
@log_errors
|
| 93 |
async def send_links_to_user(client: Client, command_message: Message, media_name: str, media_size: str, stream_link: str, online_link: str):
|
| 94 |
msg_text = MSG_LINKS.format(
|
| 95 |
file_name=media_name,
|
|
|
|
| 97 |
download_link=online_link,
|
| 98 |
stream_link=stream_link
|
| 99 |
)
|
| 100 |
+
await retry_send_message(
|
| 101 |
+
client,
|
| 102 |
+
command_message.chat.id,
|
| 103 |
+
msg_text,
|
| 104 |
+
reply_parameters=ReplyParameters(message_id=command_message.id),
|
| 105 |
+
link_preview_options=LinkPreviewOptions(is_disabled=True),
|
| 106 |
+
parse_mode=enums.ParseMode.MARKDOWN,
|
| 107 |
+
reply_markup=InlineKeyboardMarkup([
|
| 108 |
+
[
|
| 109 |
+
InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
|
| 110 |
+
InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=online_link)
|
| 111 |
+
]
|
| 112 |
+
])
|
| 113 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
@log_errors
|
| 116 |
async def generate_dc_text(user: User) -> str:
|
| 117 |
dc_id = user.dc_id if user.dc_id is not None else MSG_DC_UNKNOWN
|
| 118 |
return MSG_DC_USER_INFO.format(
|
|
|
|
| 121 |
dc_id=dc_id
|
| 122 |
)
|
| 123 |
|
| 124 |
+
@log_errors
|
| 125 |
async def get_user_safely(bot: Client, query) -> Optional[User]:
|
| 126 |
+
if isinstance(query, str):
|
| 127 |
+
if query.startswith('@'):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
return await bot.get_users(query)
|
| 129 |
+
elif query.isdigit():
|
| 130 |
+
return await bot.get_users(int(query))
|
| 131 |
+
elif isinstance(query, int):
|
| 132 |
+
return await bot.get_users(query)
|
| 133 |
+
return None
|
| 134 |
|
| 135 |
+
@log_errors
|
| 136 |
async def check_admin_privileges(client: Client, chat_id: int) -> bool:
|
| 137 |
+
member = await client.get_chat_member(chat_id, client.me.id)
|
| 138 |
+
return member.status in [ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
+
@log_errors
|
| 141 |
async def _execute_command_from_callback(client: Client, callback_query: CallbackQuery, command_name: str, command_func):
|
| 142 |
+
await callback_query.answer()
|
| 143 |
+
mock_message = callback_query.message
|
| 144 |
+
mock_message.from_user = callback_query.from_user
|
| 145 |
+
mock_message.chat = callback_query.message.chat
|
| 146 |
+
mock_message.text = f"/{command_name}"
|
| 147 |
+
mock_message.command = [command_name]
|
| 148 |
+
await command_func(client, mock_message)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Thunder/utils/broadcast_helper.py
CHANGED
|
@@ -1,41 +1,24 @@
|
|
| 1 |
# Thunder/utils/broadcast_helper.py
|
| 2 |
|
| 3 |
import asyncio
|
| 4 |
-
import traceback
|
| 5 |
from typing import Tuple
|
| 6 |
-
|
| 7 |
from pyrogram.errors import FloodWait, InputUserDeactivated, UserIsBlocked, PeerIdInvalid
|
| 8 |
from pyrogram.types import Message
|
| 9 |
-
from Thunder.utils.
|
| 10 |
-
|
| 11 |
|
|
|
|
| 12 |
async def send_msg(user_id: int, message: Message) -> Tuple[int, str]:
|
| 13 |
-
# Attempt to forward a message to a user and handle exceptions
|
| 14 |
try:
|
| 15 |
await message.forward(chat_id=user_id)
|
| 16 |
-
return 200, None
|
| 17 |
-
|
| 18 |
except FloodWait as e:
|
| 19 |
-
logger.warning(f"FloodWait error: sleeping for {e.value} seconds.")
|
| 20 |
await asyncio.sleep(e.value + 1)
|
| 21 |
-
return await send_msg(user_id, message)
|
| 22 |
-
|
| 23 |
except InputUserDeactivated:
|
| 24 |
-
|
| 25 |
-
logger.error(error_msg)
|
| 26 |
-
return 400, error_msg
|
| 27 |
-
|
| 28 |
except UserIsBlocked:
|
| 29 |
-
|
| 30 |
-
logger.error(error_msg)
|
| 31 |
-
return 400, error_msg
|
| 32 |
-
|
| 33 |
except PeerIdInvalid:
|
| 34 |
-
|
| 35 |
-
logger.error(error_msg)
|
| 36 |
-
return 400, error_msg
|
| 37 |
-
|
| 38 |
except Exception as e:
|
| 39 |
-
|
| 40 |
-
logger.error(f"Unexpected error: {error_msg}", exc_info=True)
|
| 41 |
-
return 500, error_msg
|
|
|
|
| 1 |
# Thunder/utils/broadcast_helper.py
|
| 2 |
|
| 3 |
import asyncio
|
|
|
|
| 4 |
from typing import Tuple
|
|
|
|
| 5 |
from pyrogram.errors import FloodWait, InputUserDeactivated, UserIsBlocked, PeerIdInvalid
|
| 6 |
from pyrogram.types import Message
|
| 7 |
+
from Thunder.utils.error_handling import log_errors
|
|
|
|
| 8 |
|
| 9 |
+
@log_errors
|
| 10 |
async def send_msg(user_id: int, message: Message) -> Tuple[int, str]:
|
|
|
|
| 11 |
try:
|
| 12 |
await message.forward(chat_id=user_id)
|
| 13 |
+
return 200, None
|
|
|
|
| 14 |
except FloodWait as e:
|
|
|
|
| 15 |
await asyncio.sleep(e.value + 1)
|
| 16 |
+
return await send_msg(user_id, message)
|
|
|
|
| 17 |
except InputUserDeactivated:
|
| 18 |
+
return 400, f"{user_id} : deactivated"
|
|
|
|
|
|
|
|
|
|
| 19 |
except UserIsBlocked:
|
| 20 |
+
return 400, f"{user_id} : blocked the bot"
|
|
|
|
|
|
|
|
|
|
| 21 |
except PeerIdInvalid:
|
| 22 |
+
return 400, f"{user_id} : user ID invalid"
|
|
|
|
|
|
|
|
|
|
| 23 |
except Exception as e:
|
| 24 |
+
return 500, f"{user_id} : {str(e)}"
|
|
|
|
|
|
Thunder/utils/config_parser.py
CHANGED
|
@@ -2,44 +2,36 @@
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
from typing import Dict, Optional
|
| 5 |
-
from Thunder.utils.
|
| 6 |
-
|
| 7 |
|
| 8 |
class TokenParser:
|
| 9 |
-
# Class to parse multiple bot tokens from environment variables
|
| 10 |
-
|
| 11 |
def __init__(self, config_file: Optional[str] = None):
|
| 12 |
-
# Initialize the TokenParser
|
| 13 |
self.tokens: Dict[int, str] = {}
|
| 14 |
self.config_file = config_file
|
|
|
|
| 15 |
|
|
|
|
| 16 |
def parse_from_env(self) -> Dict[int, str]:
|
| 17 |
-
|
| 18 |
-
|
| 19 |
|
| 20 |
-
# Filter environment variables that start with "MULTI_TOKEN"
|
| 21 |
multi_tokens = {
|
| 22 |
-
key: value
|
|
|
|
| 23 |
if key.startswith("MULTI_TOKEN") and value.strip()
|
| 24 |
}
|
| 25 |
-
|
| 26 |
if not multi_tokens:
|
| 27 |
-
logger.info("No MULTI_TOKEN environment variables found. Running with primary bot only.")
|
| 28 |
return {}
|
| 29 |
-
|
| 30 |
-
# Extract numeric part and sort tokens
|
| 31 |
sorted_tokens = sorted(
|
| 32 |
multi_tokens.items(),
|
| 33 |
key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0)
|
| 34 |
)
|
| 35 |
-
|
| 36 |
-
# Map to a dictionary with integer keys starting at 1
|
| 37 |
self.tokens = {
|
| 38 |
-
index + 1: token
|
|
|
|
| 39 |
}
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
logger.info("No valid MULTI_TOKEN environment variables found. Running with primary bot only.")
|
| 43 |
-
return {}
|
| 44 |
-
|
| 45 |
-
return self.tokens
|
|
|
|
| 2 |
|
| 3 |
import os
|
| 4 |
from typing import Dict, Optional
|
| 5 |
+
from Thunder.utils.error_handling import log_errors
|
|
|
|
| 6 |
|
| 7 |
class TokenParser:
|
|
|
|
|
|
|
| 8 |
def __init__(self, config_file: Optional[str] = None):
|
|
|
|
| 9 |
self.tokens: Dict[int, str] = {}
|
| 10 |
self.config_file = config_file
|
| 11 |
+
self._env_cache = None
|
| 12 |
|
| 13 |
+
@log_errors
|
| 14 |
def parse_from_env(self) -> Dict[int, str]:
|
| 15 |
+
if self._env_cache is None:
|
| 16 |
+
self._env_cache = dict(os.environ)
|
| 17 |
|
|
|
|
| 18 |
multi_tokens = {
|
| 19 |
+
key: value.strip()
|
| 20 |
+
for key, value in self._env_cache.items()
|
| 21 |
if key.startswith("MULTI_TOKEN") and value.strip()
|
| 22 |
}
|
| 23 |
+
|
| 24 |
if not multi_tokens:
|
|
|
|
| 25 |
return {}
|
| 26 |
+
|
|
|
|
| 27 |
sorted_tokens = sorted(
|
| 28 |
multi_tokens.items(),
|
| 29 |
key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0)
|
| 30 |
)
|
| 31 |
+
|
|
|
|
| 32 |
self.tokens = {
|
| 33 |
+
index + 1: token
|
| 34 |
+
for index, (_, token) in enumerate(sorted_tokens)
|
| 35 |
}
|
| 36 |
+
|
| 37 |
+
return self.tokens
|
|
|
|
|
|
|
|
|
|
|
|
Thunder/utils/custom_dl.py
CHANGED
|
@@ -15,17 +15,15 @@ from .file_properties import get_file_ids
|
|
| 15 |
from Thunder.utils.logger import logger
|
| 16 |
|
| 17 |
class ByteStreamer:
|
| 18 |
-
# A custom class that handles streaming of media files from Telegram servers
|
| 19 |
|
| 20 |
def __init__(self, client: Client):
|
| 21 |
self.client = client
|
| 22 |
-
self.clean_timer = 30 * 60
|
| 23 |
self.cached_file_ids: Dict[int, FileId] = {}
|
| 24 |
self.file_references_cache: Dict[int, Dict[str, Union[FileId, float]]] = {}
|
| 25 |
self.cache_lock = asyncio.Lock()
|
| 26 |
self.media_sessions_lock = asyncio.Lock()
|
| 27 |
|
| 28 |
-
# Start cache cleaning tasks
|
| 29 |
self.cache_cleaner_task = asyncio.create_task(self.clean_cache())
|
| 30 |
self.session_cleaner_task = asyncio.create_task(self.cleanup_media_sessions())
|
| 31 |
|
|
@@ -44,7 +42,6 @@ class ByteStreamer:
|
|
| 44 |
except asyncio.CancelledError:
|
| 45 |
pass
|
| 46 |
|
| 47 |
-
# Clean up all media sessions
|
| 48 |
for dc_id, session in list(self.client.media_sessions.items()):
|
| 49 |
try:
|
| 50 |
await session.stop()
|
|
@@ -79,14 +76,12 @@ class ByteStreamer:
|
|
| 79 |
async def refresh_file_reference(self, file_id: FileId) -> Optional[FileId]:
|
| 80 |
logger.debug(f"Refreshing file reference for message ID {file_id.message_id}")
|
| 81 |
|
| 82 |
-
# Check if we have a fresh cached reference
|
| 83 |
async with self.cache_lock:
|
| 84 |
cached_ref = self.file_references_cache.get(file_id.message_id)
|
| 85 |
-
if cached_ref and time.time() - cached_ref["timestamp"] < 3600:
|
| 86 |
return cached_ref["file_id"]
|
| 87 |
|
| 88 |
try:
|
| 89 |
-
# Fetch the message again to get fresh file reference
|
| 90 |
message = await self.client.get_messages(
|
| 91 |
chat_id=Var.BIN_CHANNEL,
|
| 92 |
message_ids=file_id.message_id
|
|
@@ -96,13 +91,11 @@ class ByteStreamer:
|
|
| 96 |
logger.debug(f"Media message {file_id.message_id} not found when refreshing file reference")
|
| 97 |
return None
|
| 98 |
|
| 99 |
-
# Extract new file ID
|
| 100 |
new_file_id = await get_file_ids(self.client, Var.BIN_CHANNEL, file_id.message_id)
|
| 101 |
if not new_file_id:
|
| 102 |
logger.error(f"Failed to extract file ID from message {file_id.message_id}")
|
| 103 |
return None
|
| 104 |
|
| 105 |
-
# Cache the new file reference
|
| 106 |
async with self.cache_lock:
|
| 107 |
self.file_references_cache[file_id.message_id] = {
|
| 108 |
"file_id": new_file_id,
|
|
@@ -133,7 +126,6 @@ class ByteStreamer:
|
|
| 133 |
|
| 134 |
for attempt in range(6):
|
| 135 |
if attempt > 0:
|
| 136 |
-
# Add exponential backoff between attempts
|
| 137 |
await asyncio.sleep(1 * (2 ** (attempt - 1)))
|
| 138 |
|
| 139 |
try:
|
|
@@ -167,11 +159,9 @@ class ByteStreamer:
|
|
| 167 |
)
|
| 168 |
await media_session.start()
|
| 169 |
|
| 170 |
-
# Mark the session creation time for cleanup
|
| 171 |
setattr(media_session, 'last_used', time.time())
|
| 172 |
client.media_sessions[file_id.dc_id] = media_session
|
| 173 |
else:
|
| 174 |
-
# Update last used time
|
| 175 |
setattr(media_session, 'last_used', time.time())
|
| 176 |
|
| 177 |
return media_session
|
|
@@ -248,17 +238,18 @@ class ByteStreamer:
|
|
| 248 |
|
| 249 |
# Handle missing file reference
|
| 250 |
if location is None:
|
| 251 |
-
logger.warning(f"Missing file reference
|
| 252 |
refreshed_file_id = await self.refresh_file_reference(file_id)
|
| 253 |
if refreshed_file_id:
|
| 254 |
location = await self.get_location(refreshed_file_id)
|
| 255 |
file_id = refreshed_file_id
|
| 256 |
|
| 257 |
if location is None:
|
| 258 |
-
logger.error(
|
| 259 |
-
|
| 260 |
|
| 261 |
max_retries = 5
|
|
|
|
| 262 |
|
| 263 |
while current_part <= part_count:
|
| 264 |
try:
|
|
@@ -266,54 +257,42 @@ class ByteStreamer:
|
|
| 266 |
raw.functions.upload.GetFile(
|
| 267 |
location=location, offset=offset, limit=chunk_size
|
| 268 |
),
|
| 269 |
-
timeout=Var.TIMEOUT
|
| 270 |
)
|
| 271 |
except FloodWait as e:
|
| 272 |
logger.warning(f"FloodWait encountered: waiting for {e.value} seconds.")
|
| 273 |
await asyncio.sleep(e.value + 1)
|
| 274 |
continue
|
| 275 |
except (FileReferenceExpired, FileReferenceInvalid) as e:
|
| 276 |
-
logger.
|
| 277 |
refreshed_file_id = await self.refresh_file_reference(file_id)
|
| 278 |
if refreshed_file_id:
|
| 279 |
-
file_id = refreshed_file_id
|
| 280 |
location = await self.get_location(file_id)
|
| 281 |
if location is None:
|
| 282 |
-
logger.error(
|
| 283 |
-
|
| 284 |
-
logger.info(f"Successfully refreshed file reference for {file_id.message_id} and got new location.")
|
| 285 |
retry_count = 0
|
| 286 |
continue
|
| 287 |
else:
|
| 288 |
-
logger.error(
|
| 289 |
-
|
| 290 |
-
except asyncio.TimeoutError as e: # Specific handling for asyncio.TimeoutError
|
| 291 |
-
retry_count += 1
|
| 292 |
-
logger.warning(f"TimeoutError during file fetch for {file_id.message_id} (attempt {retry_count}/{max_retries}): {e}")
|
| 293 |
-
if retry_count >= max_retries:
|
| 294 |
-
logger.error(f"Max retries reached for TimeoutError on {file_id.message_id}: {e}")
|
| 295 |
-
raise TimeoutError(f"Max retries for TimeoutError on {file_id.message_id} after {max_retries} attempts.")
|
| 296 |
-
await asyncio.sleep(1 * (2 ** (retry_count - 1)))
|
| 297 |
-
continue
|
| 298 |
except RPCError as e:
|
| 299 |
retry_count += 1
|
| 300 |
-
logger.warning(f"RPC Error during file fetch
|
| 301 |
if retry_count >= max_retries:
|
| 302 |
-
|
| 303 |
-
raise RPCError(f"Max retries for RPCError on {file_id.message_id}: {e}")
|
| 304 |
await asyncio.sleep(1 * (2 ** (retry_count - 1)))
|
| 305 |
continue
|
| 306 |
except ConnectionError as e:
|
| 307 |
retry_count += 1
|
| 308 |
-
logger.
|
| 309 |
if retry_count >= max_retries:
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
await asyncio.sleep(1) # Brief pause before retry
|
| 313 |
continue
|
| 314 |
except Exception as e:
|
| 315 |
-
logger.error(f"Unexpected error during file fetch
|
| 316 |
-
|
| 317 |
|
| 318 |
if isinstance(r, raw.types.upload.File):
|
| 319 |
chunk = r.bytes
|
|
@@ -330,13 +309,12 @@ class ByteStreamer:
|
|
| 330 |
|
| 331 |
current_part += 1
|
| 332 |
offset += chunk_size
|
| 333 |
-
retry_count = 0
|
| 334 |
|
| 335 |
if current_part > part_count:
|
| 336 |
break
|
| 337 |
-
except (AttributeError) as e:
|
| 338 |
-
logger.error(f"Error while yielding file
|
| 339 |
-
raise
|
| 340 |
finally:
|
| 341 |
work_loads[index] -= 1
|
| 342 |
|
|
@@ -346,15 +324,12 @@ class ByteStreamer:
|
|
| 346 |
await asyncio.sleep(self.clean_timer)
|
| 347 |
|
| 348 |
async with self.cache_lock:
|
| 349 |
-
# Instead of clearing everything, we could selectively clean
|
| 350 |
-
# based on timestamps if we tracked them
|
| 351 |
self.cached_file_ids.clear()
|
| 352 |
|
| 353 |
-
# Clean file reference cache based on age
|
| 354 |
now = time.time()
|
| 355 |
expired_refs = [
|
| 356 |
key for key, value in self.file_references_cache.items()
|
| 357 |
-
if now - value["timestamp"] > 3600
|
| 358 |
]
|
| 359 |
for key in expired_refs:
|
| 360 |
self.file_references_cache.pop(key, None)
|
|
@@ -365,12 +340,12 @@ class ByteStreamer:
|
|
| 365 |
break
|
| 366 |
except Exception as e:
|
| 367 |
logger.error(f"Error in cache cleaning task: {e}")
|
| 368 |
-
await asyncio.sleep(60)
|
| 369 |
|
| 370 |
async def cleanup_media_sessions(self) -> None:
|
| 371 |
while True:
|
| 372 |
try:
|
| 373 |
-
await asyncio.sleep(1800)
|
| 374 |
|
| 375 |
async with self.media_sessions_lock:
|
| 376 |
now = time.time()
|
|
@@ -393,4 +368,4 @@ class ByteStreamer:
|
|
| 393 |
break
|
| 394 |
except Exception as e:
|
| 395 |
logger.error(f"Error in media session cleaning task: {e}")
|
| 396 |
-
await asyncio.sleep(60)
|
|
|
|
| 15 |
from Thunder.utils.logger import logger
|
| 16 |
|
| 17 |
class ByteStreamer:
|
|
|
|
| 18 |
|
| 19 |
def __init__(self, client: Client):
|
| 20 |
self.client = client
|
| 21 |
+
self.clean_timer = 30 * 60
|
| 22 |
self.cached_file_ids: Dict[int, FileId] = {}
|
| 23 |
self.file_references_cache: Dict[int, Dict[str, Union[FileId, float]]] = {}
|
| 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 |
|
|
|
|
| 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()
|
|
|
|
| 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
|
|
|
|
| 91 |
logger.debug(f"Media message {file_id.message_id} not found when refreshing file reference")
|
| 92 |
return None
|
| 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,
|
|
|
|
| 126 |
|
| 127 |
for attempt in range(6):
|
| 128 |
if attempt > 0:
|
|
|
|
| 129 |
await asyncio.sleep(1 * (2 ** (attempt - 1)))
|
| 130 |
|
| 131 |
try:
|
|
|
|
| 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
|
|
|
|
| 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 |
max_retries = 5
|
| 252 |
+
retry_count = 0
|
| 253 |
|
| 254 |
while current_part <= part_count:
|
| 255 |
try:
|
|
|
|
| 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
|
|
|
|
| 309 |
|
| 310 |
current_part += 1
|
| 311 |
offset += chunk_size
|
| 312 |
+
retry_count = 0
|
| 313 |
|
| 314 |
if current_part > part_count:
|
| 315 |
break
|
| 316 |
+
except (TimeoutError, AttributeError) as e:
|
| 317 |
+
logger.error(f"Error while yielding file: {e}")
|
|
|
|
| 318 |
finally:
|
| 319 |
work_loads[index] -= 1
|
| 320 |
|
|
|
|
| 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)
|
|
|
|
| 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()
|
|
|
|
| 368 |
break
|
| 369 |
except Exception as e:
|
| 370 |
logger.error(f"Error in media session cleaning task: {e}")
|
| 371 |
+
await asyncio.sleep(60)
|
Thunder/utils/database.py
CHANGED
|
@@ -1,15 +1,14 @@
|
|
| 1 |
-
|
|
|
|
| 2 |
import datetime
|
| 3 |
from typing import Optional, Dict, Any
|
| 4 |
from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
|
| 5 |
-
from Thunder.utils.logger import logger
|
| 6 |
from Thunder.vars import Var
|
|
|
|
|
|
|
| 7 |
|
| 8 |
class Database:
|
| 9 |
-
# Database class for handling user data using MongoDB
|
| 10 |
-
|
| 11 |
def __init__(self, uri: str, database_name: str, *args, **kwargs):
|
| 12 |
-
# Initialize database connection
|
| 13 |
self._client = AsyncIOMotorClient(uri, *args, **kwargs)
|
| 14 |
self.db = self._client[database_name]
|
| 15 |
self.col: AsyncIOMotorCollection = self.db.users
|
|
@@ -17,212 +16,175 @@ class Database:
|
|
| 17 |
self.token_col: AsyncIOMotorCollection = self.db.tokens
|
| 18 |
self.authorized_users_col: AsyncIOMotorCollection = self.db.authorized_users
|
| 19 |
self.restart_message_col: AsyncIOMotorCollection = self.db.restart_message
|
| 20 |
-
|
|
|
|
| 21 |
async def ensure_indexes(self):
|
| 22 |
-
# Ensure necessary indexes are created
|
| 23 |
await self.banned_users_col.create_index("user_id", unique=True)
|
| 24 |
await self.token_col.create_index("token", unique=True)
|
| 25 |
await self.authorized_users_col.create_index("user_id", unique=True)
|
| 26 |
-
# Add unique index on users.id
|
| 27 |
await self.col.create_index("id", unique=True)
|
| 28 |
-
|
| 29 |
-
# TTL index to auto-remove expired tokens
|
| 30 |
await self.token_col.create_index("expires_at", expireAfterSeconds=0)
|
| 31 |
await self.restart_message_col.create_index("message_id", unique=True)
|
| 32 |
-
# TTL index to auto-remove expired restart messages
|
| 33 |
await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600)
|
|
|
|
| 34 |
|
|
|
|
| 35 |
def new_user(self, user_id: int) -> dict:
|
| 36 |
-
# Create new user document
|
| 37 |
return {
|
| 38 |
'id': user_id,
|
| 39 |
'join_date': datetime.datetime.utcnow()
|
| 40 |
}
|
| 41 |
-
|
|
|
|
| 42 |
async def add_user(self, user_id: int):
|
| 43 |
-
# Add new user if not exists
|
| 44 |
if not await self.is_user_exist(user_id):
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
| 48 |
async def add_user_pass(self, user_id: int, ag_pass: str):
|
| 49 |
-
# Add or update user password
|
| 50 |
await self.add_user(user_id)
|
| 51 |
await self.col.update_one({'id': user_id}, {'$set': {'ag_p': ag_pass}})
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
async def get_user_pass(self, user_id: int) -> Optional[str]:
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
async def is_user_exist(self, user_id: int) -> bool:
|
| 59 |
-
# Check if user exists
|
| 60 |
user = await self.col.find_one({'id': user_id}, {'_id': 1})
|
| 61 |
return bool(user)
|
| 62 |
-
|
|
|
|
| 63 |
async def total_users_count(self) -> int:
|
| 64 |
-
# Count total users
|
| 65 |
return await self.col.count_documents({})
|
| 66 |
-
|
|
|
|
| 67 |
async def get_all_users(self):
|
| 68 |
-
# Get all users (returns cursor)
|
| 69 |
return self.col.find({})
|
| 70 |
-
|
|
|
|
| 71 |
async def delete_user(self, user_id: int):
|
| 72 |
-
# Delete user
|
| 73 |
await self.col.delete_one({'id': user_id})
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
async def create_index(self):
|
| 76 |
-
# Create index for faster queries
|
| 77 |
await self.col.create_index("id", unique=True)
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
async def get_active_users(self, days: int = 7):
|
| 80 |
-
# Get users active within specified days
|
| 81 |
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=days)
|
| 82 |
return self.col.find({'join_date': {'$gte': cutoff}})
|
| 83 |
-
|
|
|
|
| 84 |
async def add_banned_user(
|
| 85 |
self, user_id: int, banned_by: Optional[int] = None,
|
| 86 |
reason: Optional[str] = None, ban_time: Optional[str] = None
|
| 87 |
):
|
| 88 |
-
# Add or update banned user with upsert
|
| 89 |
ban_data = {
|
| 90 |
"user_id": user_id,
|
| 91 |
"banned_at": datetime.datetime.utcnow(),
|
| 92 |
"banned_by": banned_by,
|
| 93 |
"reason": reason
|
| 94 |
}
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
except Exception as e:
|
| 102 |
-
logger.error(
|
| 103 |
-
f"Database error in add_banned_user for user {user_id}: {e}"
|
| 104 |
-
)
|
| 105 |
-
raise RuntimeError(f"Failed to ban user {user_id}: {e}")
|
| 106 |
|
|
|
|
| 107 |
async def remove_banned_user(self, user_id: int) -> bool:
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
return result.deleted_count > 0
|
| 114 |
-
except Exception as e:
|
| 115 |
-
logger.error(
|
| 116 |
-
f"Database error in remove_banned_user for user {user_id}: {e}"
|
| 117 |
-
)
|
| 118 |
-
return False
|
| 119 |
|
|
|
|
| 120 |
async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
|
| 121 |
-
|
| 122 |
-
try:
|
| 123 |
-
return await self.banned_users_col.find_one({"user_id": user_id})
|
| 124 |
-
except Exception as e:
|
| 125 |
-
logger.error(
|
| 126 |
-
f"Database error in is_user_banned for user {user_id}: {e}"
|
| 127 |
-
)
|
| 128 |
-
return None
|
| 129 |
|
|
|
|
| 130 |
async def check_user_token(self, user_id: int) -> Dict[str, bool]:
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
"user_id": user_id,
|
| 136 |
-
"
|
|
|
|
|
|
|
| 137 |
})
|
| 138 |
-
|
| 139 |
-
except Exception as e:
|
| 140 |
-
logger.error(f"Database error checking token for {user_id}: {e}")
|
| 141 |
-
return {"has_token": False}
|
| 142 |
-
|
| 143 |
-
async def update_user_token(self, user_id: int, token: str) -> None:
|
| 144 |
-
"""Update user's active token in database."""
|
| 145 |
-
try:
|
| 146 |
-
# Find the token data first
|
| 147 |
-
token_data = await self.token_col.find_one({"token": token})
|
| 148 |
-
|
| 149 |
-
if token_data:
|
| 150 |
-
# Link token to user and refresh expiration
|
| 151 |
-
await self.token_col.update_one(
|
| 152 |
-
{"token": token},
|
| 153 |
-
{"$set": {
|
| 154 |
-
"user_id": user_id,
|
| 155 |
-
"expires_at": datetime.datetime.utcnow() + datetime.timedelta(hours=Var.TOKEN_TTL_HOURS)
|
| 156 |
-
}}
|
| 157 |
-
)
|
| 158 |
-
else:
|
| 159 |
-
# If no token data found, create a basic entry (this is a fallback)
|
| 160 |
-
logger.warning(f"Token {token} not found in database when assigning to user {user_id}")
|
| 161 |
-
await self.token_col.insert_one({
|
| 162 |
-
"user_id": user_id,
|
| 163 |
-
"token": token,
|
| 164 |
-
"created_at": datetime.datetime.utcnow(),
|
| 165 |
-
"expires_at": datetime.datetime.utcnow() + datetime.timedelta(hours=Var.TOKEN_TTL_HOURS)
|
| 166 |
-
})
|
| 167 |
-
except Exception as e:
|
| 168 |
-
logger.error(f"Database error updating token for {user_id}: {e}")
|
| 169 |
-
raise RuntimeError(f"Failed to update token: {e}")
|
| 170 |
|
|
|
|
| 171 |
async def save_broadcast_state(self, broadcast_id, state_data):
|
| 172 |
-
|
| 173 |
-
await broadcast_collection.update_one(
|
| 174 |
{"_id": broadcast_id},
|
| 175 |
{"$set": state_data},
|
| 176 |
upsert=True
|
| 177 |
)
|
|
|
|
| 178 |
|
|
|
|
| 179 |
async def get_broadcast_state(self, broadcast_id):
|
| 180 |
-
|
| 181 |
-
state = await broadcast_collection.find_one({"_id": broadcast_id})
|
| 182 |
-
return state
|
| 183 |
|
|
|
|
| 184 |
async def list_active_broadcasts(self):
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
return await cursor.to_list(length=100)
|
| 188 |
|
|
|
|
| 189 |
async def add_restart_message(self, message_id: int, chat_id: int) -> None:
|
| 190 |
-
"""Store the restart message ID and chat ID."""
|
| 191 |
try:
|
| 192 |
-
await self.restart_message_col.insert_one(
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
| 196 |
except Exception as e:
|
| 197 |
-
logger.error(f"
|
| 198 |
|
|
|
|
| 199 |
async def get_restart_message(self) -> Optional[Dict[str, Any]]:
|
| 200 |
-
"""Retrieve the restart message from the database."""
|
| 201 |
try:
|
| 202 |
-
|
| 203 |
-
restart_msg = await self.restart_message_col.find_one(sort=[("timestamp", -1)])
|
| 204 |
-
if restart_msg:
|
| 205 |
-
logger.debug(f"Retrieved restart message {restart_msg['message_id']} from DB.")
|
| 206 |
-
return restart_msg
|
| 207 |
except Exception as e:
|
| 208 |
-
logger.error(f"
|
| 209 |
return None
|
| 210 |
|
|
|
|
| 211 |
async def delete_restart_message(self, message_id: int) -> None:
|
| 212 |
-
"""Delete the restart message from the database."""
|
| 213 |
try:
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
logger.debug(f"Restart message {message_id} deleted from DB.")
|
| 217 |
-
else:
|
| 218 |
-
logger.debug(f"Attempted to delete non-existent restart message {message_id}.")
|
| 219 |
except Exception as e:
|
| 220 |
-
logger.error(f"
|
| 221 |
|
| 222 |
async def close(self):
|
| 223 |
-
# Close database connection
|
| 224 |
if self._client:
|
| 225 |
self._client.close()
|
| 226 |
|
| 227 |
-
# Initialize the Database instance
|
| 228 |
db = Database(Var.DATABASE_URL, Var.NAME)
|
|
|
|
| 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
|
|
|
|
| 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.restart_message_col.create_index("message_id", unique=True)
|
|
|
|
| 28 |
await self.restart_message_col.create_index("timestamp", expireAfterSeconds=3600)
|
| 29 |
+
logger.info("Database indexes ensured.")
|
| 30 |
|
| 31 |
+
@log_errors
|
| 32 |
def new_user(self, user_id: int) -> dict:
|
|
|
|
| 33 |
return {
|
| 34 |
'id': user_id,
|
| 35 |
'join_date': datetime.datetime.utcnow()
|
| 36 |
}
|
| 37 |
+
|
| 38 |
+
@log_errors
|
| 39 |
async def add_user(self, user_id: int):
|
|
|
|
| 40 |
if not await self.is_user_exist(user_id):
|
| 41 |
+
await self.col.insert_one(self.new_user(user_id))
|
| 42 |
+
logger.debug(f"Added new user {user_id} to database.")
|
| 43 |
+
|
| 44 |
+
@log_errors
|
| 45 |
async def add_user_pass(self, user_id: int, ag_pass: str):
|
|
|
|
| 46 |
await self.add_user(user_id)
|
| 47 |
await self.col.update_one({'id': user_id}, {'$set': {'ag_p': ag_pass}})
|
| 48 |
+
logger.debug(f"Updated password for user {user_id}.")
|
| 49 |
+
|
| 50 |
+
@log_errors
|
| 51 |
async def get_user_pass(self, user_id: int) -> Optional[str]:
|
| 52 |
+
user_data = await self.col.find_one({'id': user_id}, {'ag_p': 1})
|
| 53 |
+
return user_data.get('ag_p') if user_data else None
|
| 54 |
+
|
| 55 |
+
@log_errors
|
| 56 |
async def is_user_exist(self, user_id: int) -> bool:
|
|
|
|
| 57 |
user = await self.col.find_one({'id': user_id}, {'_id': 1})
|
| 58 |
return bool(user)
|
| 59 |
+
|
| 60 |
+
@log_errors
|
| 61 |
async def total_users_count(self) -> int:
|
|
|
|
| 62 |
return await self.col.count_documents({})
|
| 63 |
+
|
| 64 |
+
@log_errors
|
| 65 |
async def get_all_users(self):
|
|
|
|
| 66 |
return self.col.find({})
|
| 67 |
+
|
| 68 |
+
@log_errors
|
| 69 |
async def delete_user(self, user_id: int):
|
|
|
|
| 70 |
await self.col.delete_one({'id': user_id})
|
| 71 |
+
logger.info(f"Deleted user {user_id}.")
|
| 72 |
+
|
| 73 |
+
@log_errors
|
| 74 |
async def create_index(self):
|
|
|
|
| 75 |
await self.col.create_index("id", unique=True)
|
| 76 |
+
logger.info("Created index for 'id' on users collection.")
|
| 77 |
+
|
| 78 |
+
@log_errors
|
| 79 |
async def get_active_users(self, days: int = 7):
|
|
|
|
| 80 |
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=days)
|
| 81 |
return self.col.find({'join_date': {'$gte': cutoff}})
|
| 82 |
+
|
| 83 |
+
@log_errors
|
| 84 |
async def add_banned_user(
|
| 85 |
self, user_id: int, banned_by: Optional[int] = None,
|
| 86 |
reason: Optional[str] = None, ban_time: Optional[str] = None
|
| 87 |
):
|
|
|
|
| 88 |
ban_data = {
|
| 89 |
"user_id": user_id,
|
| 90 |
"banned_at": datetime.datetime.utcnow(),
|
| 91 |
"banned_by": banned_by,
|
| 92 |
"reason": reason
|
| 93 |
}
|
| 94 |
+
await self.banned_users_col.update_one(
|
| 95 |
+
{"user_id": user_id},
|
| 96 |
+
{"$set": ban_data},
|
| 97 |
+
upsert=True
|
| 98 |
+
)
|
| 99 |
+
logger.info(f"Added/Updated banned user {user_id}. Reason: {reason}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
|
| 101 |
+
@log_errors
|
| 102 |
async def remove_banned_user(self, user_id: int) -> bool:
|
| 103 |
+
result = await self.banned_users_col.delete_one({"user_id": user_id})
|
| 104 |
+
if result.deleted_count > 0:
|
| 105 |
+
logger.info(f"Removed banned user {user_id}.")
|
| 106 |
+
return True
|
| 107 |
+
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
+
@log_errors
|
| 110 |
async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
|
| 111 |
+
return await self.banned_users_col.find_one({"user_id": user_id})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
|
| 113 |
+
@log_errors
|
| 114 |
async def check_user_token(self, user_id: int) -> Dict[str, bool]:
|
| 115 |
+
token_data = await self.token_col.find_one({
|
| 116 |
+
"user_id": user_id,
|
| 117 |
+
"expires_at": {"$gt": datetime.datetime.utcnow()}
|
| 118 |
+
})
|
| 119 |
+
return {"has_token": bool(token_data)}
|
| 120 |
+
|
| 121 |
+
@log_errors
|
| 122 |
+
async def update_user_token(self, user_id: int, token: str) -> None:
|
| 123 |
+
token_data = await self.token_col.find_one({"token": token})
|
| 124 |
+
expires_at = datetime.datetime.utcnow() + datetime.timedelta(hours=Var.TOKEN_TTL_HOURS)
|
| 125 |
+
if token_data:
|
| 126 |
+
await self.token_col.update_one(
|
| 127 |
+
{"token": token},
|
| 128 |
+
{"$set": {"user_id": user_id, "expires_at": expires_at}}
|
| 129 |
+
)
|
| 130 |
+
logger.info(f"Updated token for user {user_id}.")
|
| 131 |
+
else:
|
| 132 |
+
await self.token_col.insert_one({
|
| 133 |
"user_id": user_id,
|
| 134 |
+
"token": token,
|
| 135 |
+
"created_at": datetime.datetime.utcnow(),
|
| 136 |
+
"expires_at": expires_at
|
| 137 |
})
|
| 138 |
+
logger.info(f"Inserted new token for user {user_id}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 139 |
|
| 140 |
+
@log_errors
|
| 141 |
async def save_broadcast_state(self, broadcast_id, state_data):
|
| 142 |
+
await self.db.broadcasts.update_one(
|
|
|
|
| 143 |
{"_id": broadcast_id},
|
| 144 |
{"$set": state_data},
|
| 145 |
upsert=True
|
| 146 |
)
|
| 147 |
+
logger.debug(f"Saved broadcast state for ID {broadcast_id}.")
|
| 148 |
|
| 149 |
+
@log_errors
|
| 150 |
async def get_broadcast_state(self, broadcast_id):
|
| 151 |
+
return await self.db.broadcasts.find_one({"_id": broadcast_id})
|
|
|
|
|
|
|
| 152 |
|
| 153 |
+
@log_errors
|
| 154 |
async def list_active_broadcasts(self):
|
| 155 |
+
cursor = self.db.broadcasts.find({"is_cancelled": False})
|
| 156 |
+
return await cursor.to_list(length=None)
|
|
|
|
| 157 |
|
| 158 |
+
@log_errors
|
| 159 |
async def add_restart_message(self, message_id: int, chat_id: int) -> None:
|
|
|
|
| 160 |
try:
|
| 161 |
+
await self.restart_message_col.insert_one({
|
| 162 |
+
"message_id": message_id,
|
| 163 |
+
"chat_id": chat_id,
|
| 164 |
+
"timestamp": datetime.datetime.utcnow()
|
| 165 |
+
})
|
| 166 |
+
logger.info(f"Added restart message {message_id} for chat {chat_id}.")
|
| 167 |
except Exception as e:
|
| 168 |
+
logger.error(f"Error adding restart message {message_id}: {e}")
|
| 169 |
|
| 170 |
+
@log_errors
|
| 171 |
async def get_restart_message(self) -> Optional[Dict[str, Any]]:
|
|
|
|
| 172 |
try:
|
| 173 |
+
return await self.restart_message_col.find_one(sort=[("timestamp", -1)])
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
except Exception as e:
|
| 175 |
+
logger.error(f"Error getting restart message: {e}")
|
| 176 |
return None
|
| 177 |
|
| 178 |
+
@log_errors
|
| 179 |
async def delete_restart_message(self, message_id: int) -> None:
|
|
|
|
| 180 |
try:
|
| 181 |
+
await self.restart_message_col.delete_one({"message_id": message_id})
|
| 182 |
+
logger.info(f"Deleted restart message {message_id}.")
|
|
|
|
|
|
|
|
|
|
| 183 |
except Exception as e:
|
| 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)
|
Thunder/utils/decorators.py
CHANGED
|
@@ -1,119 +1,147 @@
|
|
|
|
|
|
|
|
| 1 |
from functools import wraps
|
| 2 |
from pyrogram.types import Message, InlineKeyboardButton, InlineKeyboardMarkup
|
| 3 |
from Thunder.vars import Var
|
|
|
|
| 4 |
from Thunder.utils.messages import (
|
| 5 |
MSG_DECORATOR_BANNED,
|
| 6 |
MSG_TOKEN_INVALID,
|
| 7 |
MSG_ERROR_UNAUTHORIZED
|
| 8 |
)
|
| 9 |
from Thunder.utils.database import db
|
| 10 |
-
from Thunder.utils.logger import logger
|
| 11 |
from Thunder.utils.tokens import check, allowed, generate
|
| 12 |
from Thunder.utils.shortener import shorten
|
| 13 |
|
| 14 |
-
def _check_message_user(message):
|
| 15 |
-
"""Helper to validate message and user."""
|
| 16 |
-
if not message.from_user:
|
| 17 |
-
return None
|
| 18 |
-
return message.from_user.id
|
| 19 |
|
| 20 |
def check_banned(func):
|
| 21 |
@wraps(func)
|
| 22 |
async def wrapper(client, message: Message, *args, **kwargs):
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
return await func(client, message, *args, **kwargs)
|
| 29 |
-
|
| 30 |
-
ban_details = await db.is_user_banned(user_id)
|
| 31 |
-
if ban_details:
|
| 32 |
-
banned_at = ban_details.get('banned_at')
|
| 33 |
-
if banned_at and hasattr(banned_at, 'strftime'):
|
| 34 |
-
ban_time = banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
|
| 35 |
-
else:
|
| 36 |
-
ban_time = str(banned_at) if banned_at else 'N/A'
|
| 37 |
-
|
| 38 |
-
ban_message = MSG_DECORATOR_BANNED.format(
|
| 39 |
-
reason=ban_details.get('reason', 'Not specified'),
|
| 40 |
-
ban_time=ban_time
|
| 41 |
-
)
|
| 42 |
-
await message.reply_text(ban_message, quote=True)
|
| 43 |
-
return
|
| 44 |
-
|
| 45 |
-
return await func(client, message, *args, **kwargs)
|
| 46 |
return wrapper
|
| 47 |
|
| 48 |
def require_token(func):
|
| 49 |
@wraps(func)
|
| 50 |
async def wrapper(client, message: Message, *args, **kwargs):
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
if not await check(user_id):
|
| 65 |
token_data = await generate(user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
me = await client.get_me()
|
| 67 |
deep_link = f"https://t.me/{me.username}?start={token_data['token']}"
|
| 68 |
-
|
| 69 |
try:
|
| 70 |
-
|
|
|
|
|
|
|
| 71 |
except Exception as e:
|
| 72 |
-
logger.
|
| 73 |
-
|
| 74 |
-
|
| 75 |
await message.reply_text(
|
| 76 |
MSG_TOKEN_INVALID,
|
| 77 |
-
reply_markup=InlineKeyboardMarkup(
|
| 78 |
-
[
|
| 79 |
-
),
|
| 80 |
quote=True
|
| 81 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
return
|
| 83 |
-
|
| 84 |
-
return await func(client, message, *args, **kwargs)
|
| 85 |
return wrapper
|
| 86 |
|
| 87 |
def shorten_link(func):
|
| 88 |
@wraps(func)
|
| 89 |
async def wrapper(client, message: Message, *args, **kwargs):
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
return wrapper
|
| 101 |
|
| 102 |
-
|
| 103 |
-
def get_owner_ids():
|
| 104 |
-
"""Helper function to safely get owner IDs."""
|
| 105 |
-
owner_ids = Var.OWNER_ID if hasattr(Var, 'OWNER_ID') and Var.OWNER_ID is not None else []
|
| 106 |
-
if isinstance(owner_ids, int):
|
| 107 |
-
owner_ids = [owner_ids]
|
| 108 |
-
return owner_ids
|
| 109 |
-
|
| 110 |
-
|
| 111 |
def owner_only(func):
|
| 112 |
-
|
| 113 |
async def wrapper(client, callback_query):
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
return
|
| 118 |
-
await func(client, callback_query)
|
| 119 |
return wrapper
|
|
|
|
|
|
| 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 |
+
if not getattr(Var, "TOKEN_ENABLED", False):
|
| 59 |
+
return await func(client, message, *args, **kwargs)
|
| 60 |
+
|
| 61 |
+
user_id = message.from_user.id
|
| 62 |
+
is_owner = (isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID) or \
|
| 63 |
+
(isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID)
|
| 64 |
+
|
| 65 |
+
if is_owner or await allowed(user_id) or await check(user_id):
|
| 66 |
+
return await func(client, message, *args, **kwargs)
|
| 67 |
+
|
|
|
|
| 68 |
token_data = await generate(user_id)
|
| 69 |
+
if not token_data or 'token' not in token_data:
|
| 70 |
+
logger.error(f"Failed to generate token for user {user_id} in require_token.")
|
| 71 |
+
await message.reply_text("Sorry, could not generate an access token. Please try again later.", quote=True)
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
me = await client.get_me()
|
| 75 |
deep_link = f"https://t.me/{me.username}?start={token_data['token']}"
|
| 76 |
+
short_url = deep_link
|
| 77 |
try:
|
| 78 |
+
short_url_result = await shorten(deep_link)
|
| 79 |
+
if short_url_result:
|
| 80 |
+
short_url = short_url_result
|
| 81 |
except Exception as e:
|
| 82 |
+
logger.warning(f"Failed to shorten token link for user {user_id}: {e}. Using full link.")
|
| 83 |
+
|
|
|
|
| 84 |
await message.reply_text(
|
| 85 |
MSG_TOKEN_INVALID,
|
| 86 |
+
reply_markup=InlineKeyboardMarkup([
|
| 87 |
+
[InlineKeyboardButton("Get Token", url=short_url)]
|
| 88 |
+
]),
|
| 89 |
quote=True
|
| 90 |
)
|
| 91 |
+
logger.info(f"Sent token request to user {user_id} for {func.__name__}.")
|
| 92 |
+
return
|
| 93 |
+
except Exception as e:
|
| 94 |
+
logger.error(f"Error in require_token decorator for {func.__name__}: {e}")
|
| 95 |
+
try:
|
| 96 |
+
await message.reply_text("An error occurred while checking your authorization. Please try again.", quote=True)
|
| 97 |
+
except Exception as inner_e:
|
| 98 |
+
logger.error(f"Failed to send error message to user in require_token: {inner_e}")
|
| 99 |
return
|
|
|
|
|
|
|
| 100 |
return wrapper
|
| 101 |
|
| 102 |
def shorten_link(func):
|
| 103 |
@wraps(func)
|
| 104 |
async def wrapper(client, message: Message, *args, **kwargs):
|
| 105 |
+
try:
|
| 106 |
+
user_id = message.from_user.id if message.from_user else None
|
| 107 |
+
use_shortener = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
|
| 108 |
+
if user_id:
|
| 109 |
+
try:
|
| 110 |
+
is_owner = (isinstance(Var.OWNER_ID, int) and user_id == Var.OWNER_ID) or \
|
| 111 |
+
(isinstance(Var.OWNER_ID, (list, tuple, set)) and user_id in Var.OWNER_ID)
|
| 112 |
+
if is_owner or await allowed(user_id):
|
| 113 |
+
use_shortener = False
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.warning(f"Error checking allowed status for user {user_id} in shorten_link: {e}. Defaulting shortener behavior.")
|
| 116 |
+
|
| 117 |
+
kwargs['shortener'] = use_shortener
|
| 118 |
+
return await func(client, message, *args, **kwargs)
|
| 119 |
+
except Exception as e:
|
| 120 |
+
logger.error(f"Error in shorten_link decorator for {func.__name__}: {e}")
|
| 121 |
+
kwargs['shortener'] = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
|
| 122 |
+
return await func(client, message, *args, **kwargs)
|
| 123 |
return wrapper
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
def owner_only(func):
|
| 126 |
+
_cached_owner_ids = None
|
| 127 |
async def wrapper(client, callback_query):
|
| 128 |
+
nonlocal _cached_owner_ids
|
| 129 |
+
try:
|
| 130 |
+
if _cached_owner_ids is None:
|
| 131 |
+
owner_ids_config = getattr(Var, 'OWNER_ID', [])
|
| 132 |
+
_cached_owner_ids = [owner_ids_config] if isinstance(owner_ids_config, int) else list(owner_ids_config)
|
| 133 |
+
|
| 134 |
+
if callback_query.from_user.id not in _cached_owner_ids:
|
| 135 |
+
await callback_query.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True)
|
| 136 |
+
logger.warning(f"Unauthorized access attempt by {callback_query.from_user.id} to owner_only function {func.__name__}.")
|
| 137 |
+
return
|
| 138 |
+
await func(client, callback_query)
|
| 139 |
+
except Exception as e:
|
| 140 |
+
logger.error(f"Error in owner_only decorator for {func.__name__}: {e}")
|
| 141 |
+
try:
|
| 142 |
+
await callback_query.answer("An error occurred. Please try again.", show_alert=True)
|
| 143 |
+
except Exception as inner_e:
|
| 144 |
+
logger.error(f"Failed to send error answer in owner_only: {inner_e}")
|
| 145 |
return
|
|
|
|
| 146 |
return wrapper
|
| 147 |
+
|
Thunder/utils/error_handling.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Thunder/utils/error_handling.py
|
| 2 |
+
|
| 3 |
+
from functools import wraps
|
| 4 |
+
from Thunder.utils.logger import logger
|
| 5 |
+
|
| 6 |
+
def log_errors(func):
|
| 7 |
+
@wraps(func)
|
| 8 |
+
def wrapper(*args, **kwargs):
|
| 9 |
+
try:
|
| 10 |
+
return func(*args, **kwargs)
|
| 11 |
+
except Exception as e:
|
| 12 |
+
logger.error(f"Error in {func.__name__}: {e}", exc_info=True)
|
| 13 |
+
raise
|
| 14 |
+
return wrapper
|
Thunder/utils/file_properties.py
CHANGED
|
@@ -1,75 +1,59 @@
|
|
| 1 |
# Thunder/utils/file_properties.py
|
| 2 |
|
| 3 |
from pyrogram import Client
|
| 4 |
-
from pyrogram.types import Message
|
| 5 |
from pyrogram.file_id import FileId
|
| 6 |
from Thunder.server.exceptions import FileNotFound
|
| 7 |
-
from Thunder.utils.
|
| 8 |
from typing import Any, Optional
|
| 9 |
|
| 10 |
def get_media_from_message(message: Message) -> Optional[Any]:
|
| 11 |
-
|
| 12 |
-
media_types = ("audio", "document", "photo", "sticker",
|
| 13 |
-
"animation", "video", "voice", "video_note")
|
| 14 |
-
|
| 15 |
-
for attr in media_types:
|
| 16 |
if media := getattr(message, attr, None):
|
| 17 |
return media
|
| 18 |
return None
|
| 19 |
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
return None
|
| 33 |
-
|
| 34 |
-
async def get_file_ids(client: Client, chat_id: int, message_id: int) -> FileId:
|
| 35 |
-
# Fetch and process media file details from message
|
| 36 |
-
try:
|
| 37 |
-
message = await client.get_messages(chat_id, message_id)
|
| 38 |
-
if not message or message.empty:
|
| 39 |
-
logger.debug("Message not found or empty")
|
| 40 |
-
raise FileNotFound("Message not found/empty")
|
| 41 |
-
|
| 42 |
-
if not (media := get_media_from_message(message)):
|
| 43 |
-
logger.debug("Message contains no media")
|
| 44 |
-
raise FileNotFound("No media in message")
|
| 45 |
-
|
| 46 |
-
file_id = parse_file_id(message)
|
| 47 |
-
if not file_id:
|
| 48 |
-
logger.debug("Failed to parse file ID")
|
| 49 |
-
raise FileNotFound("Invalid file ID")
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
|
|
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
|
|
|
| 62 |
|
|
|
|
| 63 |
def get_hash(media_msg: Message) -> str:
|
| 64 |
-
|
| 65 |
-
if media
|
| 66 |
-
return media.file_unique_id[:6]
|
| 67 |
-
return ""
|
| 68 |
|
|
|
|
| 69 |
def get_name(media_msg: Message) -> str:
|
| 70 |
-
|
| 71 |
-
return getattr(
|
| 72 |
|
|
|
|
| 73 |
def get_media_file_size(message: Message) -> int:
|
| 74 |
-
|
| 75 |
-
return getattr(
|
|
|
|
| 1 |
# Thunder/utils/file_properties.py
|
| 2 |
|
| 3 |
from pyrogram import Client
|
| 4 |
+
from pyrogram.types import Message
|
| 5 |
from pyrogram.file_id import FileId
|
| 6 |
from Thunder.server.exceptions import FileNotFound
|
| 7 |
+
from Thunder.utils.error_handling import log_errors
|
| 8 |
from typing import Any, Optional
|
| 9 |
|
| 10 |
def get_media_from_message(message: Message) -> Optional[Any]:
|
| 11 |
+
for attr in ("audio", "document", "photo", "sticker", "animation", "video", "voice", "video_note"):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
if media := getattr(message, attr, None):
|
| 13 |
return media
|
| 14 |
return None
|
| 15 |
|
| 16 |
+
@log_errors
|
| 17 |
+
async def get_file_ids(client: Client, chat_id: int, message_id: int) -> Optional[FileId]: # Return Optional
|
| 18 |
+
message = await client.get_messages(chat_id, message_id)
|
| 19 |
+
if not message or message.empty:
|
| 20 |
+
raise FileNotFound("Message not found/empty")
|
| 21 |
+
|
| 22 |
+
media = get_media_from_message(message)
|
| 23 |
+
if not media:
|
| 24 |
+
raise FileNotFound("No media in message")
|
| 25 |
+
|
| 26 |
+
if not hasattr(media, 'file_id') or not hasattr(media, 'file_unique_id'):
|
| 27 |
+
raise FileNotFound("Media metadata incomplete")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
+
file_id_obj = FileId.decode(media.file_id)
|
| 30 |
+
file_id_obj.file_size = getattr(media, "file_size", 0)
|
| 31 |
+
file_id_obj.mime_type = getattr(media, "mime_type", "")
|
| 32 |
+
file_id_obj.file_name = getattr(media, "file_name", "")
|
| 33 |
+
file_id_obj.unique_id = media.file_unique_id
|
| 34 |
+
return file_id_obj
|
| 35 |
|
| 36 |
+
@log_errors
|
| 37 |
+
def parse_file_id(message: Message) -> Optional[FileId]:
|
| 38 |
+
media = get_media_from_message(message)
|
| 39 |
+
return FileId.decode(media.file_id) if media and hasattr(media, 'file_id') else None
|
| 40 |
|
| 41 |
+
@log_errors
|
| 42 |
+
def parse_file_unique_id(message: Message) -> Optional[str]:
|
| 43 |
+
media = get_media_from_message(message)
|
| 44 |
+
return media.file_unique_id if media and hasattr(media, 'file_unique_id') else None
|
| 45 |
|
| 46 |
+
@log_errors
|
| 47 |
def get_hash(media_msg: Message) -> str:
|
| 48 |
+
media = get_media_from_message(media_msg)
|
| 49 |
+
return media.file_unique_id[:6] if media and hasattr(media, 'file_unique_id') and media.file_unique_id else ''
|
|
|
|
|
|
|
| 50 |
|
| 51 |
+
@log_errors
|
| 52 |
def get_name(media_msg: Message) -> str:
|
| 53 |
+
media = get_media_from_message(media_msg)
|
| 54 |
+
return getattr(media, 'file_name', '') if media and hasattr(media, 'file_name') else ''
|
| 55 |
|
| 56 |
+
@log_errors
|
| 57 |
def get_media_file_size(message: Message) -> int:
|
| 58 |
+
media = get_media_from_message(message)
|
| 59 |
+
return getattr(media, 'file_size', 0) if media and hasattr(media, 'file_size') else 0
|
Thunder/utils/force_channel.py
CHANGED
|
@@ -1,99 +1,34 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Decorator to enforce channel membership before allowing command execution in Pyrogram bots.
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
from typing import Any, Callable, Coroutine
|
| 6 |
-
import asyncio
|
| 7 |
-
import uuid
|
| 8 |
-
|
| 9 |
from pyrogram import Client
|
| 10 |
from pyrogram.enums import ChatMemberStatus
|
| 11 |
-
from pyrogram.errors import UserNotParticipant, ChatAdminRequired,
|
| 12 |
from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
| 13 |
-
|
| 14 |
from Thunder.vars import Var
|
| 15 |
-
from Thunder.utils.
|
| 16 |
-
from Thunder.utils.messages import (
|
| 17 |
-
MSG_FORCE_CHANNEL_ERROR,
|
| 18 |
-
MSG_FORCE_CHANNEL_RPC_ERROR,
|
| 19 |
-
MSG_FORCE_CHANNEL_GENERIC_ERROR,
|
| 20 |
-
MSG_FORCE_CHANNEL_NO_LINK,
|
| 21 |
-
MSG_FORCE_CHANNEL_MEMBERSHIP_REQUIRED,
|
| 22 |
-
MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION,
|
| 23 |
-
MSG_FORCE_CHANNEL_ACCESS_REQUIRED
|
| 24 |
-
)
|
| 25 |
|
| 26 |
def force_channel_check(
|
| 27 |
func: Callable[[Client, Message], Coroutine[Any, Any, Any]]
|
| 28 |
) -> Callable[[Client, Message], Coroutine[Any, Any, Any]]:
|
| 29 |
-
|
|
|
|
| 30 |
async def wrapper(client: Client, message: Message, *args, **kwargs) -> None:
|
| 31 |
-
if not Var.FORCE_CHANNEL_ID:
|
| 32 |
-
await func(client, message, *args, **kwargs)
|
| 33 |
-
return
|
| 34 |
-
|
| 35 |
-
if message.chat.id == Var.FORCE_CHANNEL_ID:
|
| 36 |
-
await func(client, message, *args, **kwargs)
|
| 37 |
-
return
|
| 38 |
-
|
| 39 |
-
user_id = message.from_user.id
|
| 40 |
try:
|
| 41 |
-
member = await client.get_chat_member(
|
| 42 |
-
chat_id=Var.FORCE_CHANNEL_ID,
|
| 43 |
-
user_id=user_id
|
| 44 |
-
)
|
| 45 |
if member.status in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]:
|
| 46 |
-
await func(client, message, *args, **kwargs)
|
| 47 |
-
return
|
| 48 |
except UserNotParticipant:
|
| 49 |
-
|
| 50 |
-
except (ChatAdminRequired,
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
error_id = uuid.uuid4().hex[:8]
|
| 56 |
-
logger.error(f"[ForceChannelCheck] TimeoutError (ID: {error_id}) for user {user_id} while checking membership in {Var.FORCE_CHANNEL_ID}.")
|
| 57 |
-
await message.reply(MSG_FORCE_CHANNEL_RPC_ERROR.format(error_id=error_id))
|
| 58 |
-
return
|
| 59 |
-
except RPCError as e:
|
| 60 |
-
logger.error(f"[ForceChannelCheck] RPCError for user {user_id} in {Var.FORCE_CHANNEL_ID}: {e}")
|
| 61 |
-
await message.reply(MSG_FORCE_CHANNEL_RPC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
|
| 62 |
-
return
|
| 63 |
-
except FloodWait as e:
|
| 64 |
-
wait_time: int = e.value
|
| 65 |
-
logger.debug(f"[ForceChannelCheck] Flood wait for {wait_time} seconds")
|
| 66 |
-
await asyncio.sleep(wait_time)
|
| 67 |
-
return await wrapper(client, message, *args, **kwargs)
|
| 68 |
-
except Exception as e:
|
| 69 |
-
logger.error(f"[ForceChannelCheck] Unexpected error for user {user_id} in {Var.FORCE_CHANNEL_ID}: {e}")
|
| 70 |
-
await message.reply(MSG_FORCE_CHANNEL_GENERIC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
|
| 71 |
-
return
|
| 72 |
-
|
| 73 |
-
try:
|
| 74 |
-
chat = await client.get_chat(Var.FORCE_CHANNEL_ID)
|
| 75 |
-
invite_link = f"https://t.me/{chat.username}" if chat.username else chat.invite_link
|
| 76 |
-
if not invite_link:
|
| 77 |
-
logger.warning(f"[ForceChannelCheck] No invite link for {Var.FORCE_CHANNEL_ID} ({chat.title})")
|
| 78 |
-
await message.reply(MSG_FORCE_CHANNEL_NO_LINK)
|
| 79 |
-
return
|
| 80 |
-
|
| 81 |
await message.reply(
|
| 82 |
-
|
| 83 |
-
reply_markup=InlineKeyboardMarkup([
|
| 84 |
-
|
| 85 |
-
])
|
| 86 |
)
|
| 87 |
-
except asyncio.TimeoutError:
|
| 88 |
-
error_id = uuid.uuid4().hex[:8]
|
| 89 |
-
logger.error(f"[ForceChannelCheck] TimeoutError (ID: {error_id}) getting chat/invite link for {Var.FORCE_CHANNEL_ID}.")
|
| 90 |
-
await message.reply(MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION.format(error_id=error_id))
|
| 91 |
-
except RPCError as e:
|
| 92 |
-
logger.error(f"[ForceChannelCheck] RPCError getting chat/invite link for {Var.FORCE_CHANNEL_ID}: {e}")
|
| 93 |
-
await message.reply(MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION.format(error_id=uuid.uuid4().hex[:8]))
|
| 94 |
-
except Exception as e:
|
| 95 |
-
logger.error(f"[ForceChannelCheck] Unexpected error getting chat/invite link for {Var.FORCE_CHANNEL_ID}: {e}")
|
| 96 |
-
await message.reply(MSG_FORCE_CHANNEL_GENERIC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
|
| 97 |
return wrapper
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from typing import Any, Callable, Coroutine
|
|
|
|
|
|
|
|
|
|
| 2 |
from pyrogram import Client
|
| 3 |
from pyrogram.enums import ChatMemberStatus
|
| 4 |
+
from pyrogram.errors import UserNotParticipant, ChatAdminRequired, PeerIdInvalid
|
| 5 |
from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
|
|
|
|
| 6 |
from Thunder.vars import Var
|
| 7 |
+
from Thunder.utils.error_handling import log_errors
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
def force_channel_check(
|
| 10 |
func: Callable[[Client, Message], Coroutine[Any, Any, Any]]
|
| 11 |
) -> Callable[[Client, Message], Coroutine[Any, Any, Any]]:
|
| 12 |
+
|
| 13 |
+
@log_errors
|
| 14 |
async def wrapper(client: Client, message: Message, *args, **kwargs) -> None:
|
| 15 |
+
if not Var.FORCE_CHANNEL_ID or message.chat.id == Var.FORCE_CHANNEL_ID:
|
| 16 |
+
return await func(client, message, *args, **kwargs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
try:
|
| 18 |
+
member = await client.get_chat_member(Var.FORCE_CHANNEL_ID, message.from_user.id)
|
|
|
|
|
|
|
|
|
|
| 19 |
if member.status in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]:
|
| 20 |
+
return await func(client, message, *args, **kwargs)
|
|
|
|
| 21 |
except UserNotParticipant:
|
| 22 |
+
pass
|
| 23 |
+
except (ChatAdminRequired, PeerIdInvalid):
|
| 24 |
+
return await func(client, message, *args, **kwargs)
|
| 25 |
+
chat = await client.get_chat(Var.FORCE_CHANNEL_ID)
|
| 26 |
+
invite_link = f"https://t.me/{chat.username}" if chat.username else chat.invite_link
|
| 27 |
+
if invite_link:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
await message.reply(
|
| 29 |
+
f"Please join {chat.title} to use this bot.",
|
| 30 |
+
reply_markup=InlineKeyboardMarkup([[
|
| 31 |
+
InlineKeyboardButton("Join Channel", url=invite_link)
|
| 32 |
+
]])
|
| 33 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
return wrapper
|
|
|
|
|
|
Thunder/utils/human_readable.py
CHANGED
|
@@ -1,16 +1,14 @@
|
|
| 1 |
# Thunder/utils/human_readable.py
|
|
|
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
def humanbytes(size: int, decimal_places: int = 2) -> str:
|
| 4 |
-
# Converts bytes to human-readable format (KB, MB, GB, etc.)
|
| 5 |
if not size:
|
| 6 |
return "0 B"
|
| 7 |
-
|
| 8 |
-
power = 2 ** 10
|
| 9 |
n = 0
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
while size >= power and n < len(units) - 1:
|
| 13 |
-
size /= power
|
| 14 |
n += 1
|
| 15 |
-
|
| 16 |
-
return f"{round(size, decimal_places)} {units[n]}B"
|
|
|
|
| 1 |
# Thunder/utils/human_readable.py
|
| 2 |
+
from Thunder.utils.error_handling import log_errors
|
| 3 |
|
| 4 |
+
_UNITS = ('', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y')
|
| 5 |
+
|
| 6 |
+
@log_errors
|
| 7 |
def humanbytes(size: int, decimal_places: int = 2) -> str:
|
|
|
|
| 8 |
if not size:
|
| 9 |
return "0 B"
|
|
|
|
|
|
|
| 10 |
n = 0
|
| 11 |
+
while size >= 1024 and n < len(_UNITS) - 1:
|
| 12 |
+
size /= 1024
|
|
|
|
|
|
|
| 13 |
n += 1
|
| 14 |
+
return f"{round(size, decimal_places)} {_UNITS[n]}B"
|
|
|
Thunder/utils/keepalive.py
CHANGED
|
@@ -4,19 +4,15 @@ import asyncio
|
|
| 4 |
import aiohttp
|
| 5 |
from Thunder.vars import Var
|
| 6 |
from Thunder.utils.logger import logger
|
|
|
|
| 7 |
|
|
|
|
| 8 |
async def ping_server():
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
async with session.get(Var.URL) as resp:
|
| 18 |
-
pass
|
| 19 |
-
except asyncio.TimeoutError:
|
| 20 |
-
logger.warning("Server ping timeout")
|
| 21 |
-
except Exception as e:
|
| 22 |
-
logger.error(f"Ping error: {str(e)}")
|
|
|
|
| 4 |
import aiohttp
|
| 5 |
from Thunder.vars import Var
|
| 6 |
from Thunder.utils.logger import logger
|
| 7 |
+
from Thunder.utils.error_handling import log_errors
|
| 8 |
|
| 9 |
+
@log_errors
|
| 10 |
async def ping_server():
|
| 11 |
+
async with aiohttp.ClientSession(
|
| 12 |
+
timeout=aiohttp.ClientTimeout(total=10)
|
| 13 |
+
) as session:
|
| 14 |
+
while True:
|
| 15 |
+
await asyncio.sleep(Var.PING_INTERVAL)
|
| 16 |
+
async with session.get(Var.URL) as resp:
|
| 17 |
+
if resp.status != 200:
|
| 18 |
+
logger.warning(f"Ping to {Var.URL} returned status {resp.status}.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Thunder/utils/logger.py
CHANGED
|
@@ -1,42 +1,38 @@
|
|
| 1 |
# Thunder/utils/logger.py
|
| 2 |
|
| 3 |
import logging
|
| 4 |
-
from logging.handlers import RotatingFileHandler
|
| 5 |
-
import sys
|
| 6 |
import os
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
#
|
| 9 |
-
|
| 10 |
-
LOG_DIR = os.path.join(PROJECT_ROOT, 'logs')
|
| 11 |
os.makedirs(LOG_DIR, exist_ok=True)
|
| 12 |
LOG_FILE = os.path.join(LOG_DIR, 'bot.txt')
|
| 13 |
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
| 18 |
|
| 19 |
-
|
| 20 |
-
log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
| 21 |
|
| 22 |
-
|
| 23 |
-
file_handler
|
| 24 |
-
LOG_FILE,
|
| 25 |
-
maxBytes=10 * 1024 * 1024,
|
| 26 |
-
backupCount=5
|
| 27 |
-
)
|
| 28 |
-
file_handler.setFormatter(log_formatter)
|
| 29 |
-
file_handler.setLevel(logging.INFO)
|
| 30 |
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
console_handler.setFormatter(log_formatter)
|
| 35 |
-
console_handler.setLevel(logging.INFO)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
-
|
| 39 |
-
logger.addHandler(file_handler)
|
| 40 |
-
logger.addHandler(console_handler)
|
| 41 |
|
| 42 |
__all__ = ['logger', 'LOG_FILE']
|
|
|
|
| 1 |
# Thunder/utils/logger.py
|
| 2 |
|
| 3 |
import logging
|
| 4 |
+
from logging.handlers import RotatingFileHandler, QueueHandler, QueueListener
|
|
|
|
| 5 |
import os
|
| 6 |
+
import queue
|
| 7 |
+
import atexit
|
| 8 |
|
| 9 |
+
# Setup paths
|
| 10 |
+
LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'logs')
|
|
|
|
| 11 |
os.makedirs(LOG_DIR, exist_ok=True)
|
| 12 |
LOG_FILE = os.path.join(LOG_DIR, 'bot.txt')
|
| 13 |
|
| 14 |
+
logging._srcfile = None
|
| 15 |
+
logging.logThreads = 0
|
| 16 |
+
logging.logProcesses = 0
|
| 17 |
+
|
| 18 |
+
log_queue = queue.Queue(maxsize=10000)
|
| 19 |
|
| 20 |
+
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
|
|
|
| 21 |
|
| 22 |
+
file_handler = RotatingFileHandler(LOG_FILE, maxBytes=10*1024*1024, backupCount=5)
|
| 23 |
+
file_handler.setFormatter(formatter)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
console_handler = logging.StreamHandler()
|
| 26 |
+
console_handler.setFormatter(formatter)
|
| 27 |
|
| 28 |
+
listener = QueueListener(log_queue, file_handler, console_handler, respect_handler_level=True)
|
| 29 |
+
listener.start()
|
|
|
|
|
|
|
| 30 |
|
| 31 |
+
logger = logging.getLogger('ThunderBot')
|
| 32 |
+
logger.setLevel(logging.INFO)
|
| 33 |
+
logger.propagate = False
|
| 34 |
+
logger.addHandler(QueueHandler(log_queue))
|
| 35 |
|
| 36 |
+
atexit.register(listener.stop)
|
|
|
|
|
|
|
| 37 |
|
| 38 |
__all__ = ['logger', 'LOG_FILE']
|
Thunder/utils/messages.py
CHANGED
|
@@ -1,7 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
Centralized message constants for the Thunder File to Link Bot.
|
| 3 |
-
All user-facing strings should be defined here to maintain consistency and ease of maintenance.
|
| 4 |
-
"""
|
| 5 |
|
| 6 |
# =====================================================================================
|
| 7 |
# ====== ERROR MESSAGES ======
|
|
@@ -300,8 +297,8 @@ MSG_PROCESSING_REQUEST = "⏳ **Processing your request...**"
|
|
| 300 |
MSG_PROCESSING_FILE = "⏳ **Processing your file...**"
|
| 301 |
MSG_DEFAULT_FILENAME = "Untitled File"
|
| 302 |
MSG_NEW_FILE_REQUEST = (
|
| 303 |
-
"> 👤 **Source:** [{
|
| 304 |
-
"> 🆔 **ID:** `{
|
| 305 |
"🔗 **Download:** `{online_link}`\n\n"
|
| 306 |
"🖥️ **Stream:** `{stream_link}`"
|
| 307 |
)
|
|
|
|
| 1 |
+
# Thunder/utils/messages.py
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
# =====================================================================================
|
| 4 |
# ====== ERROR MESSAGES ======
|
|
|
|
| 297 |
MSG_PROCESSING_FILE = "⏳ **Processing your file...**"
|
| 298 |
MSG_DEFAULT_FILENAME = "Untitled File"
|
| 299 |
MSG_NEW_FILE_REQUEST = (
|
| 300 |
+
"> 👤 **Source:** [{source_info}](tg://user?id={id_})\n"
|
| 301 |
+
"> 🆔 **ID:** `{id_}`\n\n"
|
| 302 |
"🔗 **Download:** `{online_link}`\n\n"
|
| 303 |
"🖥️ **Stream:** `{stream_link}`"
|
| 304 |
)
|
Thunder/utils/render_template.py
CHANGED
|
@@ -7,41 +7,36 @@ from Thunder.vars import Var
|
|
| 7 |
from Thunder.bot import StreamBot
|
| 8 |
from Thunder.utils.file_properties import get_file_ids
|
| 9 |
from Thunder.server.exceptions import InvalidHash
|
| 10 |
-
from Thunder.utils.
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
| 16 |
file_data = await get_file_ids(StreamBot, int(Var.BIN_CHANNEL), id)
|
| 17 |
if file_data.unique_id[:6] != secure_hash:
|
| 18 |
-
logger.debug(f'Link hash: {secure_hash} - Expected: {file_data.unique_id[:6]}')
|
| 19 |
-
logger.debug(f"Invalid hash for message ID {id}")
|
| 20 |
raise InvalidHash
|
| 21 |
-
|
| 22 |
quoted_filename = urllib.parse.quote(file_data.file_name.replace('/', '_'))
|
| 23 |
-
src = urllib.parse.urljoin(Var.URL, f'{secure_hash}{
|
| 24 |
-
|
| 25 |
safe_filename = html_module.escape(file_data.file_name)
|
| 26 |
-
|
| 27 |
-
template_name: str
|
| 28 |
-
context: dict
|
| 29 |
-
|
| 30 |
if requested_action == 'stream':
|
| 31 |
-
|
| 32 |
context = {
|
| 33 |
'tag': "video",
|
| 34 |
'heading': f"View {safe_filename}",
|
| 35 |
'file_name': safe_filename,
|
| 36 |
'src': src
|
| 37 |
}
|
| 38 |
-
else:
|
| 39 |
-
|
| 40 |
context = {
|
| 41 |
'file_name': safe_filename,
|
| 42 |
'src': src
|
| 43 |
}
|
| 44 |
-
|
| 45 |
-
template = template_env.get_template(template_name)
|
| 46 |
-
html_content = await template.render_async(**context)
|
| 47 |
-
return html_content
|
|
|
|
| 7 |
from Thunder.bot import StreamBot
|
| 8 |
from Thunder.utils.file_properties import get_file_ids
|
| 9 |
from Thunder.server.exceptions import InvalidHash
|
| 10 |
+
from Thunder.utils.error_handling import log_errors
|
| 11 |
|
| 12 |
+
template_env = Environment(
|
| 13 |
+
loader=FileSystemLoader('Thunder/template'),
|
| 14 |
+
enable_async=True,
|
| 15 |
+
cache_size=200,
|
| 16 |
+
auto_reload=False,
|
| 17 |
+
optimized=True
|
| 18 |
+
)
|
| 19 |
|
| 20 |
+
@log_errors
|
| 21 |
+
async def render_page(id: int, secure_hash: str, requested_action: str | None = None) -> str:
|
| 22 |
file_data = await get_file_ids(StreamBot, int(Var.BIN_CHANNEL), id)
|
| 23 |
if file_data.unique_id[:6] != secure_hash:
|
|
|
|
|
|
|
| 24 |
raise InvalidHash
|
|
|
|
| 25 |
quoted_filename = urllib.parse.quote(file_data.file_name.replace('/', '_'))
|
| 26 |
+
src = urllib.parse.urljoin(Var.URL, f'{secure_hash}{id}/{quoted_filename}')
|
|
|
|
| 27 |
safe_filename = html_module.escape(file_data.file_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
if requested_action == 'stream':
|
| 29 |
+
template = template_env.get_template('req.html')
|
| 30 |
context = {
|
| 31 |
'tag': "video",
|
| 32 |
'heading': f"View {safe_filename}",
|
| 33 |
'file_name': safe_filename,
|
| 34 |
'src': src
|
| 35 |
}
|
| 36 |
+
else:
|
| 37 |
+
template = template_env.get_template('dl.html')
|
| 38 |
context = {
|
| 39 |
'file_name': safe_filename,
|
| 40 |
'src': src
|
| 41 |
}
|
| 42 |
+
return await template.render_async(**context)
|
|
|
|
|
|
|
|
|
Thunder/utils/retry_api.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Thunder/utils/retry_api.py
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import random
|
| 5 |
+
from typing import Callable, TypeVar, Any, Awaitable
|
| 6 |
+
from pyrogram.errors import FloodWait, RPCError
|
| 7 |
+
from Thunder.utils.logger import logger
|
| 8 |
+
|
| 9 |
+
T = TypeVar('T')
|
| 10 |
+
DEFAULT_RETRY_COUNT = 3
|
| 11 |
+
BASE_RETRY_DELAY = 1.0
|
| 12 |
+
MAX_FLOOD_WAIT = 30
|
| 13 |
+
MAX_JITTER_FACTOR = 0.1
|
| 14 |
+
|
| 15 |
+
async def retry_api_call(
|
| 16 |
+
func: Callable[[], Awaitable[T]],
|
| 17 |
+
max_retries: int = DEFAULT_RETRY_COUNT,
|
| 18 |
+
base_delay: float = BASE_RETRY_DELAY,
|
| 19 |
+
operation_name: str = "API call"
|
| 20 |
+
) -> T:
|
| 21 |
+
for attempt in range(1, max_retries + 1):
|
| 22 |
+
try:
|
| 23 |
+
if attempt > 1:
|
| 24 |
+
logger.debug(f"Retry attempt {attempt} for {operation_name}.")
|
| 25 |
+
return await func()
|
| 26 |
+
except FloodWait as e:
|
| 27 |
+
if attempt == max_retries:
|
| 28 |
+
logger.error(f"Max retries reached for {operation_name} due to FloodWait: {e}")
|
| 29 |
+
raise
|
| 30 |
+
wait_time = min(e.value, MAX_FLOOD_WAIT)
|
| 31 |
+
logger.warning(f"FloodWait ({e.value}s) for {operation_name}, sleeping {wait_time}s before retry.")
|
| 32 |
+
await asyncio.sleep(wait_time)
|
| 33 |
+
except (asyncio.TimeoutError, RPCError) as e:
|
| 34 |
+
if attempt == max_retries:
|
| 35 |
+
logger.error(f"Max retries reached for {operation_name} due to {e.__class__.__name__}: {e}")
|
| 36 |
+
raise
|
| 37 |
+
delay = base_delay * (2 ** (attempt - 1))
|
| 38 |
+
jitter = random.uniform(0, delay * MAX_JITTER_FACTOR)
|
| 39 |
+
logger.warning(f"{e.__class__.__name__} during {operation_name}: {e}. Retrying in {delay + jitter:.2f}s.")
|
| 40 |
+
await asyncio.sleep(delay + jitter)
|
| 41 |
+
except Exception as e:
|
| 42 |
+
if attempt == max_retries:
|
| 43 |
+
logger.error(f"Max retries reached for {operation_name} due to unexpected error: {e}")
|
| 44 |
+
raise
|
| 45 |
+
delay = base_delay * (2 ** (attempt - 1))
|
| 46 |
+
logger.warning(f"Unexpected error {e.__class__.__name__} during {operation_name}: {e}. Retrying in {delay}s.")
|
| 47 |
+
await asyncio.sleep(delay)
|
| 48 |
+
|
| 49 |
+
async def retry_api_operation(
|
| 50 |
+
client: Any,
|
| 51 |
+
operation: str,
|
| 52 |
+
*args: Any,
|
| 53 |
+
max_retries: int = DEFAULT_RETRY_COUNT,
|
| 54 |
+
base_delay: float = BASE_RETRY_DELAY,
|
| 55 |
+
**kwargs: Any
|
| 56 |
+
) -> Any:
|
| 57 |
+
kwargs.pop("operation_name", None)
|
| 58 |
+
return await retry_api_call(
|
| 59 |
+
lambda: getattr(client, operation)(*args, **kwargs),
|
| 60 |
+
max_retries=max_retries,
|
| 61 |
+
base_delay=base_delay
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
async def retry_get_chat_member(client: Any, chat_id: int, user_id: int, **kwargs) -> Any:
|
| 65 |
+
kwargs.pop("operation_name", None)
|
| 66 |
+
return await retry_api_operation(
|
| 67 |
+
client,
|
| 68 |
+
"get_chat_member",
|
| 69 |
+
chat_id,
|
| 70 |
+
user_id,
|
| 71 |
+
**kwargs
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
async def retry_get_chat(client: Any, chat_id: int, **kwargs) -> Any:
|
| 75 |
+
kwargs.pop("operation_name", None)
|
| 76 |
+
return await retry_api_operation(
|
| 77 |
+
client,
|
| 78 |
+
"get_chat",
|
| 79 |
+
chat_id,
|
| 80 |
+
**kwargs
|
| 81 |
+
)
|
| 82 |
+
|
| 83 |
+
async def retry_send_message(client: Any, chat_id: int, text: str, **kwargs) -> Any:
|
| 84 |
+
kwargs.pop("operation_name", None)
|
| 85 |
+
current_max_retries = kwargs.pop("max_retries", DEFAULT_RETRY_COUNT)
|
| 86 |
+
current_base_delay = kwargs.pop("base_delay", BASE_RETRY_DELAY)
|
| 87 |
+
return await retry_api_call(
|
| 88 |
+
lambda: client.send_message(chat_id=chat_id, text=text, **kwargs),
|
| 89 |
+
max_retries=current_max_retries,
|
| 90 |
+
base_delay=current_base_delay
|
| 91 |
+
)
|
Thunder/utils/shortener.py
CHANGED
|
@@ -1,243 +1,146 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
from
|
| 4 |
from base64 import b64encode
|
| 5 |
from random import random, choice
|
| 6 |
-
from urllib.parse import quote
|
| 7 |
-
from warnings import filterwarnings
|
| 8 |
import cloudscraper
|
| 9 |
-
|
| 10 |
from Thunder.vars import Var
|
| 11 |
from Thunder.utils.logger import logger
|
| 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 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
try:
|
| 47 |
-
|
| 48 |
browser={
|
| 49 |
'browser': 'chrome',
|
| 50 |
-
'platform': '
|
| 51 |
-
'desktop':
|
| 52 |
-
|
|
|
|
|
|
|
| 53 |
)
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
return True
|
| 57 |
except Exception as e:
|
| 58 |
-
logger.error(f"Failed to
|
| 59 |
-
# Reset to ensure clean state on session creation failure
|
| 60 |
-
shortener_dict = {}
|
| 61 |
-
_scraper_session = None
|
| 62 |
-
_shortener_system_ready = False
|
| 63 |
return False
|
| 64 |
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
def _validate_url(url: str) -> bool:
|
| 70 |
-
"""Checks if a string is a well-formed URL."""
|
| 71 |
-
try:
|
| 72 |
-
result = urlparse(url)
|
| 73 |
-
return all([result.scheme, result.netloc])
|
| 74 |
-
except Exception:
|
| 75 |
-
return False
|
| 76 |
-
|
| 77 |
-
async def short_url(longurl: str, attempt: int = 0) -> str:
|
| 78 |
-
"""Shortens a URL using a configured service, with retries and fallback."""
|
| 79 |
-
global _shortener_system_ready, _scraper_session # Ensure globals are accessible
|
| 80 |
-
|
| 81 |
-
if not _shortener_system_ready: # First check (no lock)
|
| 82 |
-
async with _init_lock:
|
| 83 |
-
if not _shortener_system_ready: # Double check (with lock)
|
| 84 |
-
_init_shorteners() # Attempt initialization
|
| 85 |
-
|
| 86 |
-
if not _shortener_system_ready:
|
| 87 |
-
# Initialization failed or not enabled, _init_shorteners logs details
|
| 88 |
-
return longurl
|
| 89 |
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
"
|
| 94 |
-
|
| 95 |
-
async with _init_lock:
|
| 96 |
-
_init_shorteners()
|
| 97 |
-
|
| 98 |
-
if not _shortener_system_ready or _scraper_session is None:
|
| 99 |
-
logger.error(
|
| 100 |
-
"Critical: Scraper session remains uninitialized after re-attempt. "
|
| 101 |
-
"URL shortening will be skipped for this request."
|
| 102 |
-
)
|
| 103 |
-
return longurl
|
| 104 |
|
| 105 |
-
|
| 106 |
-
logger.warning(f"Max attempts reached for {longurl}. Returning original.")
|
| 107 |
-
return longurl
|
| 108 |
-
|
| 109 |
-
if not _validate_url(longurl):
|
| 110 |
-
logger.warning(f"Invalid URL format: {longurl}. Returning original.")
|
| 111 |
-
return longurl
|
| 112 |
-
|
| 113 |
-
try:
|
| 114 |
-
session = _scraper_session
|
| 115 |
-
|
| 116 |
-
# Ensure shortener_dict is not empty before trying to access its items
|
| 117 |
-
if not shortener_dict:
|
| 118 |
-
logger.error("Shortener dictionary is empty. Cannot proceed with shortening.")
|
| 119 |
-
return longurl # Or raise an exception if this state is unexpected
|
| 120 |
-
|
| 121 |
-
_shortener, _shortener_api = choice(list(shortener_dict.items()))
|
| 122 |
-
|
| 123 |
-
# --- Shorte.st ---
|
| 124 |
-
if "shorte.st" in _shortener:
|
| 125 |
-
headers = {"public-api-token": _shortener_api}
|
| 126 |
-
data = {"urlToShorten": quote(longurl)}
|
| 127 |
-
response = session.put(
|
| 128 |
-
"https://api.shorte.st/v1/data/url",
|
| 129 |
-
headers=headers,
|
| 130 |
-
data=data
|
| 131 |
-
)
|
| 132 |
-
|
| 133 |
-
if response.status_code != 200:
|
| 134 |
-
raise Exception(f"HTTP error {response.status_code}")
|
| 135 |
-
|
| 136 |
-
return response.json()["shortenedUrl"]
|
| 137 |
-
|
| 138 |
-
# --- Linkvertise ---
|
| 139 |
-
elif "linkvertise" in _shortener:
|
| 140 |
-
url = quote(b64encode(longurl.encode("utf-8")))
|
| 141 |
-
linkvertise = [
|
| 142 |
-
f"https://link-to.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
|
| 143 |
-
f"https://up-to-down.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
|
| 144 |
-
f"https://direct-link.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
|
| 145 |
-
f"https://file-link.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
|
| 146 |
-
]
|
| 147 |
-
return choice(linkvertise)
|
| 148 |
-
|
| 149 |
-
# --- Bitly.com ---
|
| 150 |
-
elif "bitly.com" in _shortener:
|
| 151 |
-
headers = {"Authorization": f"Bearer {_shortener_api}"}
|
| 152 |
-
response = session.post(
|
| 153 |
-
"https://api-ssl.bit.ly/v4/shorten",
|
| 154 |
-
json={"long_url": longurl},
|
| 155 |
-
headers=headers
|
| 156 |
-
)
|
| 157 |
-
|
| 158 |
-
if response.status_code != 200:
|
| 159 |
-
raise Exception(f"HTTP error {response.status_code}")
|
| 160 |
-
|
| 161 |
-
return response.json()["link"]
|
| 162 |
-
|
| 163 |
-
# --- Ouo.io ---
|
| 164 |
-
elif "ouo.io" in _shortener:
|
| 165 |
-
response = session.get(
|
| 166 |
-
f"http://ouo.io/api/{_shortener_api}?s={longurl}",
|
| 167 |
-
verify=False
|
| 168 |
-
)
|
| 169 |
-
|
| 170 |
-
if not response.text or response.status_code != 200:
|
| 171 |
-
raise Exception(f"HTTP error {response.status_code}")
|
| 172 |
-
|
| 173 |
-
return response.text
|
| 174 |
-
|
| 175 |
-
# --- Cutt.ly ---
|
| 176 |
-
elif "cutt.ly" in _shortener:
|
| 177 |
-
response = session.get(
|
| 178 |
-
f"http://cutt.ly/api/api.php?key={_shortener_api}&short={longurl}"
|
| 179 |
-
)
|
| 180 |
-
|
| 181 |
-
if response.status_code != 200:
|
| 182 |
-
raise Exception(f"HTTP error {response.status_code}")
|
| 183 |
-
|
| 184 |
-
return response.json()["url"]["shortLink"]
|
| 185 |
-
|
| 186 |
-
# --- Generic & Fallback ---
|
| 187 |
-
else:
|
| 188 |
-
# Generic shortener handling
|
| 189 |
-
response = session.get(
|
| 190 |
-
f"https://{_shortener}/api?api={_shortener_api}&url={quote(longurl)}"
|
| 191 |
-
)
|
| 192 |
-
|
| 193 |
-
if response.status_code != 200:
|
| 194 |
-
raise Exception(f"Generic API error {response.status_code}")
|
| 195 |
-
|
| 196 |
-
result = response.json()
|
| 197 |
-
shorted = result.get("shortenedUrl")
|
| 198 |
-
|
| 199 |
-
# Fallback to shrtco.de if generic failed or no result
|
| 200 |
-
if not shorted:
|
| 201 |
-
try:
|
| 202 |
-
shrtco_response = session.get(
|
| 203 |
-
f"https://api.shrtco.de/v2/shorten?url={quote(longurl)}"
|
| 204 |
-
)
|
| 205 |
-
|
| 206 |
-
if shrtco_response.status_code != 200:
|
| 207 |
-
logger.warning(f"shrtco.de fallback failed with status {shrtco_response.status_code}")
|
| 208 |
-
return longurl # Return original if fallback also fails badly
|
| 209 |
-
|
| 210 |
-
shrtco_result = shrtco_response.json()
|
| 211 |
-
if not shrtco_result.get("ok") or "result" not in shrtco_result or "full_short_link" not in shrtco_result["result"]:
|
| 212 |
-
logger.warning(f"shrtco.de fallback response malformed: {shrtco_result}")
|
| 213 |
-
return longurl
|
| 214 |
-
|
| 215 |
-
shrtco_link = shrtco_result["result"]["full_short_link"]
|
| 216 |
-
|
| 217 |
-
# Try the configured generic shortener again with the shrtco.de link
|
| 218 |
-
response = session.get(
|
| 219 |
-
f"https://{_shortener}/api?api={_shortener_api}&url={shrtco_link}"
|
| 220 |
-
)
|
| 221 |
-
|
| 222 |
-
if response.status_code != 200:
|
| 223 |
-
logger.warning(f"Generic shortener retry with shrtco.de link failed: status {response.status_code}")
|
| 224 |
-
return longurl # Return original if retry fails
|
| 225 |
-
|
| 226 |
-
result = response.json()
|
| 227 |
-
shorted = result.get("shortenedUrl")
|
| 228 |
-
except Exception as fallback_e:
|
| 229 |
-
logger.error(f"shrtco.de fallback exception: {fallback_e}")
|
| 230 |
-
return longurl # Return original on fallback exception
|
| 231 |
-
|
| 232 |
-
return shorted if shorted else longurl
|
| 233 |
-
|
| 234 |
-
except Exception as e:
|
| 235 |
-
shortener_name = _shortener if '_shortener' in locals() else 'unknown'
|
| 236 |
-
logger.error(f"URL shortener error ({shortener_name} for {longurl}): {e}, attempt {attempt + 1}")
|
| 237 |
-
|
| 238 |
-
await asyncio.sleep(1) # Wait before retrying
|
| 239 |
-
return await short_url(longurl, attempt + 1)
|
| 240 |
|
| 241 |
async def shorten(url: str) -> str:
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
| 1 |
+
# Thunder/utils/shortener.py
|
| 2 |
+
|
| 3 |
+
from abc import ABC, abstractmethod
|
| 4 |
from base64 import b64encode
|
| 5 |
from random import random, choice
|
| 6 |
+
from urllib.parse import quote
|
|
|
|
| 7 |
import cloudscraper
|
|
|
|
| 8 |
from Thunder.vars import Var
|
| 9 |
from Thunder.utils.logger import logger
|
| 10 |
|
| 11 |
+
class ShortenerPlugin(ABC):
|
| 12 |
+
@classmethod
|
| 13 |
+
@abstractmethod
|
| 14 |
+
def matches(cls, domain: str) -> bool:
|
| 15 |
+
pass
|
| 16 |
+
|
| 17 |
+
@abstractmethod
|
| 18 |
+
async def shorten(self, url: str, api_key: str) -> str:
|
| 19 |
+
pass
|
| 20 |
|
| 21 |
+
class LinkvertisePlugin(ShortenerPlugin):
|
| 22 |
+
@classmethod
|
| 23 |
+
def matches(cls, domain: str) -> bool:
|
| 24 |
+
return "linkvertise" in domain
|
| 25 |
+
|
| 26 |
+
async def shorten(self, url: str, api_key: str) -> str:
|
| 27 |
+
encoded_url = quote(b64encode(url.encode("utf-8")))
|
| 28 |
+
return choice([
|
| 29 |
+
f"https://link-to.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
|
| 30 |
+
f"https://up-to-down.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
|
| 31 |
+
f"https://direct-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
|
| 32 |
+
f"https://file-link.net/{api_key}/{random() * 1000}/dynamic?r={encoded_url}",
|
| 33 |
+
])
|
| 34 |
|
| 35 |
+
class BitlyPlugin(ShortenerPlugin):
|
| 36 |
+
@classmethod
|
| 37 |
+
def matches(cls, domain: str) -> bool:
|
| 38 |
+
return "bitly.com" in domain
|
| 39 |
|
| 40 |
+
async def shorten(self, url: str, api_key: str) -> str:
|
| 41 |
+
response = self.session.post(
|
| 42 |
+
"https://api-ssl.bit.ly/v4/shorten",
|
| 43 |
+
json={"long_url": url},
|
| 44 |
+
headers={"Authorization": f"Bearer {api_key}"}
|
| 45 |
+
)
|
| 46 |
+
if response.status_code == 200:
|
| 47 |
+
return response.json()["link"]
|
| 48 |
+
return url
|
| 49 |
+
|
| 50 |
+
class OuoIoPlugin(ShortenerPlugin):
|
| 51 |
+
@classmethod
|
| 52 |
+
def matches(cls, domain: str) -> bool:
|
| 53 |
+
return "ouo.io" in domain
|
| 54 |
|
| 55 |
+
async def shorten(self, url: str, api_key: str) -> str:
|
| 56 |
+
response = self.session.get(f"http://ouo.io/api/{api_key}?s={url}")
|
| 57 |
+
if response.status_code == 200 and response.text:
|
| 58 |
+
return response.text
|
| 59 |
+
return url
|
| 60 |
+
|
| 61 |
+
class CuttLyPlugin(ShortenerPlugin):
|
| 62 |
+
@classmethod
|
| 63 |
+
def matches(cls, domain: str) -> bool:
|
| 64 |
+
return "cutt.ly" in domain
|
| 65 |
+
|
| 66 |
+
async def shorten(self, url: str, api_key: str) -> str:
|
| 67 |
+
response = self.session.get(f"http://cutt.ly/api/api.php?key={api_key}&short={url}")
|
| 68 |
+
if response.status_code == 200:
|
| 69 |
+
return response.json()["url"]["shortLink"]
|
| 70 |
+
return url
|
| 71 |
+
|
| 72 |
+
class GenericShortenerPlugin(ShortenerPlugin):
|
| 73 |
+
@classmethod
|
| 74 |
+
def matches(cls, domain: str) -> bool:
|
| 75 |
+
return True
|
| 76 |
|
| 77 |
+
async def shorten(self, url: str, api_key: str) -> str:
|
| 78 |
+
response = self.session.get(f"https://{self.domain}/api?api={api_key}&url={quote(url)}")
|
| 79 |
+
if response.status_code == 200:
|
| 80 |
+
return response.json().get("shortenedUrl", url)
|
| 81 |
+
return url
|
| 82 |
+
|
| 83 |
+
class ShortenerSystem:
|
| 84 |
+
def __init__(self):
|
| 85 |
+
self.session = None
|
| 86 |
+
self.plugin = None
|
| 87 |
+
self.ready = False
|
| 88 |
+
|
| 89 |
+
def _get_plugin_class(self, domain: str):
|
| 90 |
+
for plugin_class in ShortenerPlugin.__subclasses__():
|
| 91 |
+
if plugin_class.matches(domain):
|
| 92 |
+
return plugin_class
|
| 93 |
+
|
| 94 |
+
return GenericShortenerPlugin
|
| 95 |
|
| 96 |
+
async def initialize(self) -> bool:
|
| 97 |
+
if self.ready:
|
| 98 |
+
return True
|
| 99 |
+
|
| 100 |
+
if not (getattr(Var, "SHORTEN_ENABLED", False) or
|
| 101 |
+
getattr(Var, "SHORTEN_MEDIA_LINKS", False)):
|
| 102 |
+
return False
|
| 103 |
+
|
| 104 |
+
site = getattr(Var, "URL_SHORTENER_SITE", "")
|
| 105 |
+
api_key = getattr(Var, "URL_SHORTENER_API_KEY", "")
|
| 106 |
+
|
| 107 |
+
if not (site and api_key):
|
| 108 |
+
return False
|
| 109 |
+
|
| 110 |
try:
|
| 111 |
+
self.session = cloudscraper.create_scraper(
|
| 112 |
browser={
|
| 113 |
'browser': 'chrome',
|
| 114 |
+
'platform': 'windows',
|
| 115 |
+
'desktop': True,
|
| 116 |
+
'mobile': False
|
| 117 |
+
},
|
| 118 |
+
delay=1
|
| 119 |
)
|
| 120 |
+
|
| 121 |
+
plugin_class = self._get_plugin_class(site)
|
| 122 |
+
self.plugin = plugin_class()
|
| 123 |
+
self.plugin.session = self.session
|
| 124 |
+
self.plugin.domain = site
|
| 125 |
+
self.ready = True
|
| 126 |
return True
|
| 127 |
except Exception as e:
|
| 128 |
+
logger.error(f"Failed to initialize ShortenerSystem: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
return False
|
| 130 |
|
| 131 |
+
async def short_url(self, url: str) -> str:
|
| 132 |
+
if not self.ready:
|
| 133 |
+
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
+
try:
|
| 136 |
+
return await self.plugin.shorten(url, Var.URL_SHORTENER_API_KEY)
|
| 137 |
+
except Exception as e:
|
| 138 |
+
logger.error(f"Error shortening URL {url}: {e}")
|
| 139 |
+
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
+
_system = ShortenerSystem()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
|
| 143 |
async def shorten(url: str) -> str:
|
| 144 |
+
if not _system.ready:
|
| 145 |
+
await _system.initialize()
|
| 146 |
+
return await _system.short_url(url)
|
Thunder/utils/time_format.py
CHANGED
|
@@ -1,18 +1,12 @@
|
|
| 1 |
-
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
def get_readable_time(seconds: int) -> str:
|
| 4 |
-
# Convert seconds to human-readable time format (e.g., "2d 5h 30m 10s")
|
| 5 |
-
periods = [
|
| 6 |
-
('d', 86400), # Days
|
| 7 |
-
('h', 3600), # Hours
|
| 8 |
-
('m', 60), # Minutes
|
| 9 |
-
('s', 1) # Seconds
|
| 10 |
-
]
|
| 11 |
result = []
|
| 12 |
-
|
| 13 |
-
for suffix, period in periods:
|
| 14 |
if seconds >= period:
|
| 15 |
value, seconds = divmod(seconds, period)
|
| 16 |
-
result.append(f"{
|
| 17 |
-
|
| 18 |
return ' '.join(result) if result else '0s'
|
|
|
|
| 1 |
+
from Thunder.utils.error_handling import log_errors
|
| 2 |
|
| 3 |
+
_TIME_PERIODS = (('d', 86400), ('h', 3600), ('m', 60), ('s', 1))
|
| 4 |
+
|
| 5 |
+
@log_errors
|
| 6 |
def get_readable_time(seconds: int) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
result = []
|
| 8 |
+
for suffix, period in _TIME_PERIODS:
|
|
|
|
| 9 |
if seconds >= period:
|
| 10 |
value, seconds = divmod(seconds, period)
|
| 11 |
+
result.append(f"{value}{suffix}")
|
|
|
|
| 12 |
return ' '.join(result) if result else '0s'
|
Thunder/utils/tokens.py
CHANGED
|
@@ -1,265 +1,139 @@
|
|
| 1 |
-
|
| 2 |
-
Token generation, verification, and authorization management for the Thunder bot.
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
-
import uuid
|
| 6 |
from datetime import datetime, timedelta
|
| 7 |
-
from typing import Optional, Dict, Any,
|
| 8 |
-
from motor.motor_asyncio import AsyncIOMotorCursor
|
| 9 |
-
|
| 10 |
from Thunder.utils.database import db
|
| 11 |
-
from Thunder.utils.logger import logger
|
| 12 |
from Thunder.vars import Var
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
|
|
|
| 14 |
async def check(user_id: int) -> bool:
|
| 15 |
-
""
|
| 16 |
-
Verify if a user has a valid token.
|
| 17 |
-
|
| 18 |
-
Args:
|
| 19 |
-
user_id: User's Telegram ID
|
| 20 |
-
|
| 21 |
-
Returns:
|
| 22 |
-
bool: True if user has valid token, False otherwise
|
| 23 |
-
"""
|
| 24 |
-
try:
|
| 25 |
-
# Check if token system is enabled
|
| 26 |
-
if not getattr(Var, "TOKEN_ENABLED", False):
|
| 27 |
-
return True # Token system disabled
|
| 28 |
-
|
| 29 |
-
# Check if user is owner
|
| 30 |
-
if isinstance(Var.OWNER_ID, (list, tuple, set)):
|
| 31 |
-
is_owner = user_id in Var.OWNER_ID
|
| 32 |
-
else:
|
| 33 |
-
is_owner = user_id == Var.OWNER_ID
|
| 34 |
-
|
| 35 |
-
# Check if user is owner or authorized
|
| 36 |
-
if is_owner or await allowed(user_id):
|
| 37 |
-
return True
|
| 38 |
-
|
| 39 |
-
# Check for valid token
|
| 40 |
-
token_data = await db.token_col.find_one({"user_id": user_id})
|
| 41 |
-
if not token_data:
|
| 42 |
-
return False
|
| 43 |
-
|
| 44 |
-
# Check if token is expired
|
| 45 |
-
if token_data["expires_at"] <= datetime.utcnow():
|
| 46 |
-
return False
|
| 47 |
-
|
| 48 |
return True
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
|
|
|
| 53 |
async def generate(user_id: int) -> Dict[str, Any]:
|
| 54 |
-
""
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
"user_id": user_id,
|
| 71 |
-
"token": token,
|
| 72 |
-
"created_at": created_at,
|
| 73 |
-
"expires_at": expires_at
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
# Store or update token in database
|
| 77 |
-
await db.token_col.update_one(
|
| 78 |
-
{"user_id": user_id},
|
| 79 |
-
{"$set": token_data},
|
| 80 |
-
upsert=True
|
| 81 |
-
)
|
| 82 |
-
|
| 83 |
-
return token_data
|
| 84 |
-
except Exception as e:
|
| 85 |
-
logger.error(f"Failed to generate token for user {user_id}: {e}")
|
| 86 |
-
raise
|
| 87 |
|
|
|
|
| 88 |
async def allowed(user_id: int) -> bool:
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
Returns:
|
| 96 |
-
bool: True if user is authorized, False otherwise
|
| 97 |
-
"""
|
| 98 |
-
try:
|
| 99 |
-
auth_data = await db.authorized_users_col.find_one({"user_id": user_id})
|
| 100 |
-
return bool(auth_data)
|
| 101 |
-
except Exception as e:
|
| 102 |
-
logger.error(f"Error checking authorization for user {user_id}: {e}")
|
| 103 |
-
return False
|
| 104 |
|
|
|
|
| 105 |
async def authorize(user_id: int, authorized_by: int) -> bool:
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
auth_data = {
|
| 118 |
-
"user_id": user_id,
|
| 119 |
-
"authorized_by": authorized_by,
|
| 120 |
-
"authorized_at": datetime.utcnow()
|
| 121 |
-
}
|
| 122 |
-
|
| 123 |
-
await db.authorized_users_col.update_one(
|
| 124 |
-
{"user_id": user_id},
|
| 125 |
-
{"$set": auth_data},
|
| 126 |
-
upsert=True
|
| 127 |
-
)
|
| 128 |
-
|
| 129 |
-
return True
|
| 130 |
-
except Exception as e:
|
| 131 |
-
logger.error(f"Failed to authorize user {user_id}: {e}")
|
| 132 |
-
return False
|
| 133 |
|
|
|
|
| 134 |
async def deauthorize(user_id: int) -> bool:
|
| 135 |
-
""
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
Args:
|
| 139 |
-
user_id: User's Telegram ID to deauthorize
|
| 140 |
-
|
| 141 |
-
Returns:
|
| 142 |
-
bool: True if successful, False otherwise
|
| 143 |
-
"""
|
| 144 |
-
try:
|
| 145 |
-
result = await db.authorized_users_col.delete_one({"user_id": user_id})
|
| 146 |
-
return result.deleted_count > 0
|
| 147 |
-
except Exception as e:
|
| 148 |
-
logger.error(f"Failed to deauthorize user {user_id}: {e}")
|
| 149 |
-
return False
|
| 150 |
|
|
|
|
| 151 |
async def get_user(user_id: int) -> Optional[Dict[str, Any]]:
|
| 152 |
-
""
|
| 153 |
-
Get information about a user's token.
|
| 154 |
-
|
| 155 |
-
Args:
|
| 156 |
-
user_id: User's Telegram ID
|
| 157 |
-
|
| 158 |
-
Returns:
|
| 159 |
-
dict or None: Token data if found, None otherwise
|
| 160 |
-
"""
|
| 161 |
-
try:
|
| 162 |
-
return await db.token_col.find_one({"user_id": user_id})
|
| 163 |
-
except Exception as e:
|
| 164 |
-
logger.error(f"Failed to get token data for user {user_id}: {e}")
|
| 165 |
-
return None
|
| 166 |
|
|
|
|
| 167 |
async def get(token: str) -> Optional[Dict[str, Any]]:
|
| 168 |
-
""
|
| 169 |
-
Retrieve token record by token string.
|
| 170 |
-
|
| 171 |
-
Args:
|
| 172 |
-
token: Token string to look up
|
| 173 |
-
|
| 174 |
-
Returns:
|
| 175 |
-
dict or None: Token data if found, None otherwise
|
| 176 |
-
"""
|
| 177 |
-
try:
|
| 178 |
-
return await db.token_col.find_one({"token": token})
|
| 179 |
-
except Exception as e:
|
| 180 |
-
logger.error(f"Failed to get token data: {e}")
|
| 181 |
-
return None
|
| 182 |
|
|
|
|
| 183 |
async def list_allowed() -> List[Dict[str, Any]]:
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
"""
|
| 190 |
-
try:
|
| 191 |
-
cursor = db.authorized_users_col.find({})
|
| 192 |
-
return await cursor.to_list(length=None)
|
| 193 |
-
except Exception as e:
|
| 194 |
-
logger.error(f"Failed to list authorized users: {e}")
|
| 195 |
-
return []
|
| 196 |
|
|
|
|
| 197 |
async def list_tokens() -> List[Dict[str, Any]]:
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
try:
|
| 205 |
-
cursor = db.token_col.find({})
|
| 206 |
-
return await cursor.to_list(length=None)
|
| 207 |
-
except Exception as e:
|
| 208 |
-
logger.error(f"Failed to list tokens: {e}")
|
| 209 |
-
return []
|
| 210 |
|
|
|
|
| 211 |
async def validate_activation_token(token: str) -> Dict[str, Any]:
|
| 212 |
-
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
Returns:
|
| 219 |
-
dict: Validation result with status and details
|
| 220 |
-
"""
|
| 221 |
-
try:
|
| 222 |
-
# Remove 'token' prefix if present
|
| 223 |
-
clean_token = token
|
| 224 |
-
if token.startswith("token"):
|
| 225 |
-
# Handle different possible separators
|
| 226 |
-
if len(token) > 5 and token[5] in ('-', '_'):
|
| 227 |
-
clean_token = token[6:] # Skip prefix and separator
|
| 228 |
-
else:
|
| 229 |
-
clean_token = token[5:] # Skip just the prefix
|
| 230 |
-
|
| 231 |
-
# Check if token exists in database
|
| 232 |
-
token_record = await get(clean_token)
|
| 233 |
-
if not token_record:
|
| 234 |
-
return {
|
| 235 |
-
"valid": False,
|
| 236 |
-
"reason": "Token does not exist or has been revoked",
|
| 237 |
-
"token": clean_token
|
| 238 |
-
}
|
| 239 |
-
|
| 240 |
-
# Check if token is expired
|
| 241 |
-
if token_record["expires_at"] <= datetime.utcnow():
|
| 242 |
-
return {
|
| 243 |
-
"valid": False,
|
| 244 |
-
"reason": "Token has expired",
|
| 245 |
-
"token": clean_token,
|
| 246 |
-
"expiry_date": token_record["expires_at"].strftime("%Y-%m-%d %H:%M:%S UTC")
|
| 247 |
-
}
|
| 248 |
-
|
| 249 |
-
# Token is valid
|
| 250 |
return {
|
| 251 |
-
"valid":
|
| 252 |
-
"
|
| 253 |
-
"
|
| 254 |
-
"expiry_date": token_record["expires_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
|
| 255 |
-
"created_at": token_record["created_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
|
| 256 |
-
"description": "Access token"
|
| 257 |
}
|
| 258 |
-
|
| 259 |
-
except Exception as e:
|
| 260 |
-
logger.error(f"Error validating token: {e}")
|
| 261 |
return {
|
| 262 |
"valid": False,
|
| 263 |
-
"reason":
|
| 264 |
-
"token":
|
|
|
|
| 265 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
_OWNER_IDS_CACHE = None
|
| 9 |
+
|
| 10 |
+
def _get_owner_ids():
|
| 11 |
+
global _OWNER_IDS_CACHE
|
| 12 |
+
if _OWNER_IDS_CACHE is None:
|
| 13 |
+
owner_id = Var.OWNER_ID
|
| 14 |
+
_OWNER_IDS_CACHE = set(owner_id if isinstance(owner_id, (list, tuple, set)) else [owner_id])
|
| 15 |
+
return _OWNER_IDS_CACHE
|
| 16 |
|
| 17 |
+
@log_errors
|
| 18 |
async def check(user_id: int) -> bool:
|
| 19 |
+
if not getattr(Var, "TOKEN_ENABLED", False):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
return True
|
| 21 |
+
if user_id in _get_owner_ids():
|
| 22 |
+
return True
|
| 23 |
+
current_time = datetime.utcnow()
|
| 24 |
+
auth_result = await db.authorized_users_col.find_one(
|
| 25 |
+
{"user_id": user_id},
|
| 26 |
+
{"_id": 1}
|
| 27 |
+
)
|
| 28 |
+
if auth_result:
|
| 29 |
+
return True
|
| 30 |
+
token_result = await db.token_col.find_one(
|
| 31 |
+
{"user_id": user_id, "expires_at": {"$gt": current_time}},
|
| 32 |
+
{"_id": 1}
|
| 33 |
+
)
|
| 34 |
+
return bool(token_result)
|
| 35 |
|
| 36 |
+
@log_errors
|
| 37 |
async def generate(user_id: int) -> Dict[str, Any]:
|
| 38 |
+
duration = getattr(Var, "TOKEN_TTL_HOURS", 24)
|
| 39 |
+
token = secrets.token_urlsafe(32)
|
| 40 |
+
created_at = datetime.utcnow()
|
| 41 |
+
expires_at = created_at + timedelta(hours=duration)
|
| 42 |
+
token_data = {
|
| 43 |
+
"user_id": user_id,
|
| 44 |
+
"token": token,
|
| 45 |
+
"created_at": created_at,
|
| 46 |
+
"expires_at": expires_at
|
| 47 |
+
}
|
| 48 |
+
await db.token_col.update_one(
|
| 49 |
+
{"user_id": user_id},
|
| 50 |
+
{"$set": token_data},
|
| 51 |
+
upsert=True
|
| 52 |
+
)
|
| 53 |
+
return token_data
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
+
@log_errors
|
| 56 |
async def allowed(user_id: int) -> bool:
|
| 57 |
+
result = await db.authorized_users_col.find_one(
|
| 58 |
+
{"user_id": user_id},
|
| 59 |
+
{"_id": 1}
|
| 60 |
+
)
|
| 61 |
+
return bool(result)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
+
@log_errors
|
| 64 |
async def authorize(user_id: int, authorized_by: int) -> bool:
|
| 65 |
+
auth_data = {
|
| 66 |
+
"user_id": user_id,
|
| 67 |
+
"authorized_by": authorized_by,
|
| 68 |
+
"authorized_at": datetime.utcnow()
|
| 69 |
+
}
|
| 70 |
+
await db.authorized_users_col.update_one(
|
| 71 |
+
{"user_id": user_id},
|
| 72 |
+
{"$set": auth_data},
|
| 73 |
+
upsert=True
|
| 74 |
+
)
|
| 75 |
+
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
+
@log_errors
|
| 78 |
async def deauthorize(user_id: int) -> bool:
|
| 79 |
+
result = await db.authorized_users_col.delete_one({"user_id": user_id})
|
| 80 |
+
return result.deleted_count > 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
|
| 82 |
+
@log_errors
|
| 83 |
async def get_user(user_id: int) -> Optional[Dict[str, Any]]:
|
| 84 |
+
return await db.token_col.find_one({"user_id": user_id})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
+
@log_errors
|
| 87 |
async def get(token: str) -> Optional[Dict[str, Any]]:
|
| 88 |
+
return await db.token_col.find_one({"token": token})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
@log_errors
|
| 91 |
async def list_allowed() -> List[Dict[str, Any]]:
|
| 92 |
+
cursor = db.authorized_users_col.find(
|
| 93 |
+
{},
|
| 94 |
+
{"user_id": 1, "authorized_by": 1, "authorized_at": 1}
|
| 95 |
+
)
|
| 96 |
+
return await cursor.to_list(length=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
|
| 98 |
+
@log_errors
|
| 99 |
async def list_tokens() -> List[Dict[str, Any]]:
|
| 100 |
+
current_time = datetime.utcnow()
|
| 101 |
+
cursor = db.token_col.find(
|
| 102 |
+
{"expires_at": {"$gt": current_time}},
|
| 103 |
+
{"user_id": 1, "expires_at": 1, "created_at": 1}
|
| 104 |
+
)
|
| 105 |
+
return await cursor.to_list(length=None)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
+
@log_errors
|
| 108 |
async def validate_activation_token(token: str) -> Dict[str, Any]:
|
| 109 |
+
clean_token = token
|
| 110 |
+
if token.startswith("token"):
|
| 111 |
+
clean_token = token[6:] if len(token) > 5 and token[5] in ('-', '_') else token[5:]
|
| 112 |
+
current_time = datetime.utcnow()
|
| 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
|
Thunder/vars.py
CHANGED
|
@@ -1,7 +1,3 @@
|
|
| 1 |
-
"""
|
| 2 |
-
Thunder/vars.py - Configuration variables for the Thunder bot.
|
| 3 |
-
"""
|
| 4 |
-
|
| 5 |
from typing import Set, Optional, List, Dict
|
| 6 |
import os
|
| 7 |
from dotenv import load_dotenv
|
|
@@ -10,54 +6,37 @@ from Thunder.utils.logger import logger
|
|
| 10 |
# Load environment variables from config.env
|
| 11 |
load_dotenv("config.env")
|
| 12 |
|
| 13 |
-
# Helper functions for parsing environment variables
|
| 14 |
def str_to_bool(val: str) -> bool:
|
| 15 |
-
"""Convert string value to boolean."""
|
| 16 |
return val.lower() in ("true", "1", "t", "y", "yes")
|
| 17 |
|
| 18 |
def str_to_int_list(val: str) -> List[int]:
|
| 19 |
-
|
| 20 |
-
if not val:
|
| 21 |
-
return []
|
| 22 |
-
return [int(x) for x in val.split() if x.isdigit()]
|
| 23 |
|
| 24 |
def str_to_int_set(val: str) -> Set[int]:
|
| 25 |
-
|
| 26 |
-
if not val:
|
| 27 |
-
return set()
|
| 28 |
-
return set(int(x) for x in val.split() if x.isdigit())
|
| 29 |
|
| 30 |
class Var:
|
| 31 |
-
"""Configuration variables for the Thunder bot."""
|
| 32 |
|
| 33 |
# Telegram API credentials
|
| 34 |
API_ID: int = int(os.getenv("API_ID", ""))
|
| 35 |
-
if not API_ID:
|
| 36 |
-
logger.critical("CRITICAL: API_ID is not configured in config.env!")
|
| 37 |
-
raise ValueError("CRITICAL: API_ID is not configured in config.env!")
|
| 38 |
-
|
| 39 |
API_HASH: str = os.getenv("API_HASH", "")
|
| 40 |
-
if not API_HASH:
|
| 41 |
-
logger.critical("CRITICAL: API_HASH is not configured in config.env!")
|
| 42 |
-
raise ValueError("CRITICAL: API_HASH is not configured in config.env!")
|
| 43 |
-
|
| 44 |
-
# Bot token and identity
|
| 45 |
BOT_TOKEN: str = os.getenv("BOT_TOKEN", "")
|
| 46 |
-
if not BOT_TOKEN:
|
| 47 |
-
logger.critical("CRITICAL: BOT_TOKEN is not configured in config.env!")
|
| 48 |
-
raise ValueError("CRITICAL: BOT_TOKEN is not configured in config.env!")
|
| 49 |
-
NAME: str = os.getenv("NAME", "ThunderF2L")
|
| 50 |
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
WORKERS: int = int(os.getenv("WORKERS", "100"))
|
| 54 |
-
TIMEOUT: int = int(os.getenv("TIMEOUT", "
|
| 55 |
|
| 56 |
# Channel for file storage
|
| 57 |
BIN_CHANNEL: int = int(os.getenv("BIN_CHANNEL", "0"))
|
| 58 |
if not BIN_CHANNEL:
|
| 59 |
-
logger.critical("
|
| 60 |
-
raise ValueError("
|
| 61 |
|
| 62 |
# Web server configuration
|
| 63 |
PORT: int = int(os.getenv("PORT", "8080"))
|
|
@@ -82,8 +61,8 @@ class Var:
|
|
| 82 |
# Database configuration
|
| 83 |
DATABASE_URL: str = os.getenv("DATABASE_URL", "")
|
| 84 |
if not DATABASE_URL:
|
| 85 |
-
logger.critical("
|
| 86 |
-
raise ValueError("
|
| 87 |
|
| 88 |
# Channel configurations
|
| 89 |
BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from typing import Set, Optional, List, Dict
|
| 2 |
import os
|
| 3 |
from dotenv import load_dotenv
|
|
|
|
| 6 |
# Load environment variables from config.env
|
| 7 |
load_dotenv("config.env")
|
| 8 |
|
|
|
|
| 9 |
def str_to_bool(val: str) -> bool:
|
|
|
|
| 10 |
return val.lower() in ("true", "1", "t", "y", "yes")
|
| 11 |
|
| 12 |
def str_to_int_list(val: str) -> List[int]:
|
| 13 |
+
return [int(x) for x in val.split() if x.isdigit()] if val else []
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
def str_to_int_set(val: str) -> Set[int]:
|
| 16 |
+
return {int(x) for x in val.split() if x.isdigit()} if val else set()
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
class Var:
|
|
|
|
| 19 |
|
| 20 |
# Telegram API credentials
|
| 21 |
API_ID: int = int(os.getenv("API_ID", ""))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
API_HASH: str = os.getenv("API_HASH", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
BOT_TOKEN: str = os.getenv("BOT_TOKEN", "")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
+
if not all([API_ID, API_HASH, BOT_TOKEN]):
|
| 26 |
+
logger.critical("Missing required Telegram API configuration")
|
| 27 |
+
raise ValueError("Missing required Telegram API configuration")
|
| 28 |
+
|
| 29 |
+
# Bot identity and performance
|
| 30 |
+
NAME: str = os.getenv("NAME", "ThunderF2L")
|
| 31 |
+
SLEEP_THRESHOLD: int = int(os.getenv("SLEEP_THRESHOLD", "30"))
|
| 32 |
WORKERS: int = int(os.getenv("WORKERS", "100"))
|
| 33 |
+
TIMEOUT: int = int(os.getenv("TIMEOUT", "90"))
|
| 34 |
|
| 35 |
# Channel for file storage
|
| 36 |
BIN_CHANNEL: int = int(os.getenv("BIN_CHANNEL", "0"))
|
| 37 |
if not BIN_CHANNEL:
|
| 38 |
+
logger.critical("BIN_CHANNEL is required")
|
| 39 |
+
raise ValueError("BIN_CHANNEL is required")
|
| 40 |
|
| 41 |
# Web server configuration
|
| 42 |
PORT: int = int(os.getenv("PORT", "8080"))
|
|
|
|
| 61 |
# Database configuration
|
| 62 |
DATABASE_URL: str = os.getenv("DATABASE_URL", "")
|
| 63 |
if not DATABASE_URL:
|
| 64 |
+
logger.critical("DATABASE_URL is required")
|
| 65 |
+
raise ValueError("DATABASE_URL is required")
|
| 66 |
|
| 67 |
# Channel configurations
|
| 68 |
BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", ""))
|