Fyaz Mohammed commited on
Commit
29b5c59
·
unverified ·
1 Parent(s): 50a7771

Fixed Timeout

Browse files
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
- MAX_CONCURRENT_TASKS = 10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=100):
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 handle_broadcast_completion(message, output, failures, successes, total_users, start_time, broadcast_id):
69
- elapsed_time = get_readable_time(time.time() - start_time)
70
- try:
71
- await output.delete()
72
- except:
73
- pass
74
- message_text = MSG_BROADCAST_COMPLETE.format(
75
- elapsed_time=elapsed_time,
76
- total_users=total_users,
77
- successes=successes,
78
- failures=failures,
79
- deleted_accounts=broadcast_ids[broadcast_id]['deleted']
80
- )
81
- reply_markup = InlineKeyboardMarkup([
82
- [InlineKeyboardButton(MSG_ADMIN_RESTART_BROADCAST, callback_data="restart_broadcast")]
83
- ])
84
- await message.reply_text(
85
- message_text,
86
- parse_mode=ParseMode.MARKDOWN,
87
- link_preview_options=LinkPreviewOptions(is_disabled=True),
88
- reply_markup=reply_markup
89
- )
90
 
91
  @StreamBot.on_message(filters.command("users") & filters.private & filters.user(Var.OWNER_ID))
92
  async def get_total_users(client, message):
@@ -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
- semaphore = asyncio.Semaphore(MAX_CONCURRENT_TASKS)
135
- broadcast_lock = asyncio.Lock()
136
- processed_lock = asyncio.Lock()
137
 
138
  async def update_progress():
139
- while processed < total_users and not broadcast_ids[broadcast_id]["is_cancelled"]:
140
- try:
141
- await output.edit_text(
142
- MSG_BROADCAST_PROGRESS.format(
143
- total_users=total_users,
144
- processed=processed,
145
- elapsed_time=get_readable_time(int(time.time() - start_time)),
146
- successes=successes,
147
- failures=failures
 
148
  )
149
- )
150
- except:
151
- pass
152
- await asyncio.sleep(3)
 
 
 
 
153
 
154
  progress_task = asyncio.create_task(update_progress())
155
 
156
- async def send_message_to_user(user_id):
157
- nonlocal successes, failures, processed
158
- if not isinstance(user_id, int) or user_id == self_id:
159
  return
160
- async with semaphore:
161
- retries = 0
162
- for attempt in range(3):
163
- if broadcast_ids[broadcast_id]["is_cancelled"]:
164
- return
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  try:
166
- if message.reply_to_message.text or message.reply_to_message.caption:
167
- await client.send_message(
168
- chat_id=user_id,
169
- text=message.reply_to_message.text or message.reply_to_message.caption,
170
- parse_mode=ParseMode.MARKDOWN,
171
- link_preview_options=LinkPreviewOptions(is_disabled=True)
172
- )
173
- elif message.reply_to_message.media:
174
- await message.reply_to_message.copy(chat_id=user_id)
175
- async with broadcast_lock:
176
- successes += 1
177
- async with processed_lock:
178
- processed += 1
179
- broadcast_ids[broadcast_id]["current"] = processed
180
- break
181
- except FloodWait as e:
182
- await asyncio.sleep(e.value + 1)
183
- continue
184
- except (FileReferenceExpired, FileReferenceInvalid):
185
- retries += 1
186
- if retries >= 3:
187
- break
188
- except (UserDeactivated, ChatWriteForbidden, UserIsBlocked, PeerIdInvalid):
189
- try:
190
- await db.delete_user(user_id)
191
- async with broadcast_lock:
192
- failures += 1
193
- broadcast_ids[broadcast_id]["deleted"] += 1
194
- async with processed_lock:
195
- processed += 1
196
- broadcast_ids[broadcast_id]["current"] = processed
197
- except:
198
- pass
199
- break
200
  except Exception:
201
- async with broadcast_lock:
202
- failures += 1
203
- async with processed_lock:
204
- processed += 1
205
- broadcast_ids[broadcast_id]["current"] = processed
206
- if attempt == 2:
207
- break
208
- await asyncio.sleep(1)
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  async for user_batch in get_users_in_batches():
211
- if broadcast_ids[broadcast_id]["is_cancelled"]:
212
  break
213
- batch_tasks = [send_message_to_user(int(user['id'])) for user in user_batch]
214
- await asyncio.gather(*batch_tasks)
 
 
 
 
215
 
216
  progress_task.cancel()
217
  try:
@@ -219,40 +234,61 @@ async def broadcast_message(client, message):
219
  except asyncio.CancelledError:
220
  pass
221
 
222
- await handle_broadcast_completion(
223
- message, output, failures, successes, total_users, start_time, broadcast_id
224
- )
225
- broadcast_ids.pop(broadcast_id, None)
 
 
226
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  except Exception as e:
228
  await message.reply_text(
229
  MSG_BROADCAST_FAILED.format(error=str(e), error_id=uuid.uuid4().hex[:8])
230
  )
 
 
231
 
232
  @StreamBot.on_message(filters.command("cancel_broadcast") & filters.private & filters.user(Var.OWNER_ID))
233
  async def cancel_broadcast(client, message):
234
  if not broadcast_ids:
235
  await message.reply_text(MSG_NO_ACTIVE_BROADCASTS)
236
  return
 
237
  if len(broadcast_ids) == 1:
238
  broadcast_id = list(broadcast_ids.keys())[0]
239
  broadcast_ids[broadcast_id]["is_cancelled"] = True
240
  await message.reply_text(MSG_BROADCAST_CANCEL.format(broadcast_id=broadcast_id))
241
  return
 
242
  keyboard = []
243
  for broadcast_id, info in broadcast_ids.items():
244
- progress = f"{info['current']}/{info['total']}" if info['total'] else "Unknown"
245
  elapsed = get_readable_time(time.time() - info['start_time'])
246
  keyboard.append([
247
  InlineKeyboardButton(
248
- MSG_ADMIN_BROADCAST_PROGRESS_ITEM.format(
249
- broadcast_id=broadcast_id,
250
- progress=progress,
251
- elapsed=elapsed
252
- ),
253
  callback_data=f"cancel_broadcast_{broadcast_id}"
254
  )
255
  ])
 
256
  await message.reply_text(
257
  MSG_MULTIPLE_BROADCASTS,
258
  reply_markup=InlineKeyboardMarkup(keyboard)
@@ -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 as e:
330
- logger.error(f"Error during restart: {e}")
331
  await message.reply_text(MSG_RESTART_FAILED)
332
 
333
  @StreamBot.on_message(filters.command("log") & filters.private & filters.user(Var.OWNER_ID))
334
  async def send_logs(client, message):
335
  try:
336
- if not os.path.exists(LOG_FILE):
337
- await message.reply_text(MSG_LOG_FILE_MISSING)
338
- return
339
- file_size = os.path.getsize(LOG_FILE)
340
- if file_size == 0:
341
- await message.reply_text(MSG_LOG_FILE_EMPTY)
342
  return
 
343
  await client.send_document(
344
  chat_id=message.chat.id,
345
  document=LOG_FILE,
@@ -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
- if authorized:
361
- await message.reply_text(MSG_AUTHORIZE_SUCCESS.format(user_id=user_id_to_authorize))
362
- else:
363
- await message.reply_text(MSG_AUTHORIZE_FAILED.format(user_id=user_id_to_authorize))
364
  except ValueError:
365
  await message.reply_text(MSG_INVALID_USER_ID)
366
- except:
367
  await message.reply_text(MSG_ERROR_GENERIC)
368
 
369
  @StreamBot.on_message(filters.command("deauthorize") & filters.private & filters.user(Var.OWNER_ID))
@@ -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
- if deauthorized:
378
- await message.reply_text(MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id_to_deauthorize))
379
- else:
380
- await message.reply_text(MSG_DEAUTHORIZE_FAILED.format(user_id=user_id_to_deauthorize))
381
  except ValueError:
382
  await message.reply_text(MSG_INVALID_USER_ID)
383
- except:
384
  await message.reply_text(MSG_ERROR_GENERIC)
385
 
386
  @StreamBot.on_message(filters.command("listauth") & filters.private & filters.user(Var.OWNER_ID))
@@ -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
- if len(message.command) < 2:
417
- await message.reply_text(MSG_BAN_USAGE)
418
- return
419
- try:
420
- user_id_to_ban = int(message.command[1])
421
- except ValueError:
422
- await message.reply_text(MSG_INVALID_USER_ID)
423
- return
424
  if user_id_to_ban in (Var.OWNER_ID if isinstance(Var.OWNER_ID, list) else [Var.OWNER_ID]):
425
  await message.reply_text(MSG_CANNOT_BAN_OWNER)
426
  return
 
427
  reason = " ".join(message.command[2:]) if len(message.command) > 2 else MSG_ADMIN_NO_BAN_REASON
428
  ban_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
 
429
  await db.add_banned_user(
430
  user_id=user_id_to_ban,
431
  reason=reason,
432
  ban_time=ban_time,
433
  banned_by=message.from_user.id
434
  )
 
435
  reply_text = MSG_ADMIN_USER_BANNED.format(user_id=user_id_to_ban)
436
  if reason != MSG_ADMIN_NO_BAN_REASON:
437
  reply_text += MSG_BAN_REASON_SUFFIX.format(reason=reason)
438
  await message.reply_text(reply_text)
 
439
  try:
440
- await client.send_message(
441
- chat_id=user_id_to_ban,
442
- text=MSG_USER_BANNED_NOTIFICATION
443
- )
444
- except:
445
  pass
 
 
 
446
  except Exception as e:
447
  await message.reply_text(MSG_BAN_ERROR.format(error=str(e)))
448
 
449
  @StreamBot.on_message(filters.command("unban") & filters.private & filters.user(Var.OWNER_ID))
450
  async def unban_user_command(client, message):
 
 
 
 
451
  try:
452
- if len(message.command) < 2:
453
- await message.reply_text(MSG_UNBAN_USAGE)
454
- return
455
- try:
456
- user_id_to_unban = int(message.command[1])
457
- except ValueError:
458
- await message.reply_text(MSG_INVALID_USER_ID)
459
- return
460
  removed = await db.remove_banned_user(user_id=user_id_to_unban)
 
461
  if removed:
462
  await message.reply_text(MSG_ADMIN_USER_UNBANNED.format(user_id=user_id_to_unban))
463
  try:
464
- await client.send_message(
465
- chat_id=user_id_to_unban,
466
- text=MSG_USER_UNBANNED_NOTIFICATION
467
- )
468
- except:
469
  pass
470
  else:
471
  await message.reply_text(MSG_USER_NOT_IN_BAN_LIST.format(user_id=user_id_to_unban))
 
 
 
472
  except Exception as e:
473
  await message.reply_text(MSG_UNBAN_ERROR.format(error=str(e)))
474
 
@@ -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 as e:
518
- await message.reply_text(
519
- MSG_SHELL_LARGE_OUTPUT.format(error=html.escape(str(e))),
520
- parse_mode=ParseMode.HTML
521
- )
522
  else:
523
  await message.reply_text(output, parse_mode=ParseMode.HTML)
 
524
  try:
525
  await reply_msg.delete()
526
- except:
527
  pass
528
 
529
  @StreamBot.on_callback_query(filters.regex("close_panel"))
530
  async def close_panel(client, callback_query):
531
- """Handler for Close button - deletes current panel and command message."""
532
  try:
533
  await callback_query.answer()
534
  await callback_query.message.delete()
535
- logger.debug(f"User {callback_query.from_user.id} closed panel")
536
  if callback_query.message.reply_to_message:
537
  try:
538
  ctx = callback_query.message.reply_to_message
539
  await ctx.delete()
540
  if ctx.reply_to_message:
541
  await ctx.reply_to_message.delete()
542
- except Exception as e:
543
- logger.warning(f"Error deleting command messages: {e}")
544
- except Exception as e:
545
- logger.error(f"Error in close_panel: {e}")
 
546
  finally:
547
  raise StopPropagation
548
 
@@ -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 client.get_chat(Var.FORCE_CHANNEL_ID)
32
  invite_link = chat.invite_link or (f"https://t.me/{chat.username}" if chat.username else None)
33
-
34
  if invite_link:
35
  return [InlineKeyboardButton(
36
- MSG_BUTTON_JOIN_CHANNEL.format(channel_title=chat.title),
37
  url=invite_link
38
  )]
39
  else:
@@ -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
- InlineKeyboardButton,
16
- InlineKeyboardMarkup,
17
- Message,
18
- User,
19
- CallbackQuery,
20
- LinkPreviewOptions
21
  )
22
-
23
  from Thunder.bot import StreamBot
24
  from Thunder.vars import Var
25
  from Thunder.utils.database import db
@@ -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
- notify_channel,
35
- log_new_user,
36
- generate_media_links,
37
- handle_user_error,
38
- generate_dc_text,
39
- get_user_safely
40
  )
41
 
42
  def has_media(message):
43
- """Check if message contains media"""
44
  return any(
45
  hasattr(message, attr) and getattr(message, attr)
46
  for attr in ["document", "photo", "video", "audio", "voice",
47
  "sticker", "animation", "video_note"]
48
  )
49
 
50
- # ====== COMMAND HANDLERS ======
51
-
52
  @check_banned
53
  @StreamBot.on_message(filters.command("start") & filters.private)
54
  async def start_command(bot: Client, message: Message):
55
  try:
56
  user_id_str = str(message.from_user.id) if message.from_user else 'unknown'
57
-
58
  if message.from_user:
59
  await log_new_user(bot, message.from_user.id, message.from_user.first_name)
60
-
61
  if len(message.command) == 2:
62
  token = message.command[1]
63
  try:
@@ -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._remove_item(key)
 
72
  return None
73
-
74
- self._update_access_order(key)
75
- return item
76
-
77
- def _update_access_order(self, key):
78
- try:
79
  self.access_order.remove(key)
80
- except ValueError:
81
- pass
82
- self.access_order.append(key)
83
-
84
- def _remove_item(self, key):
85
- try:
86
- del self.cache[key]
87
- self.access_order.remove(key)
88
- except (KeyError, ValueError):
89
- pass
90
 
91
  async def set(self, key, value):
92
- if key is None or not isinstance(value, dict):
93
- return
94
-
95
  async with self._lock:
96
  if key in self.cache:
97
- self._update_access_order(key)
98
  elif len(self.cache) >= self.max_size:
99
- lru_key = self.access_order[0]
100
- self._remove_item(lru_key)
101
-
102
  self.cache[key] = value
103
- self._update_access_order(key)
 
 
 
 
 
 
104
 
105
  async def clean_expired(self):
106
  count = 0
107
  current_time = time.time()
108
-
109
  async with self._lock:
110
- keys_to_remove = []
111
- for key in self.cache:
112
  if current_time - self.cache[key]['timestamp'] > self.ttl:
113
- keys_to_remove.append(key)
114
-
115
- for key in keys_to_remove:
116
- self._remove_item(key)
117
- count += 1
118
-
119
  return count
120
 
121
-
122
- # Initialize cache
123
  CACHE = LRUCache(max_size=getattr(Var, "CACHE_SIZE", 1000), ttl=86400)
124
 
125
-
126
- async def clean_cache_task():
127
- while True:
128
- try:
129
- cleaned_count = await CACHE.clean_expired()
130
- if cleaned_count > 0:
131
- logger.info(f"Cache cleanup: Removed {cleaned_count} expired items")
132
- await asyncio.sleep(3600)
133
- except Exception as e:
134
- logger.error(f"Error in cache cleaning task: {e}")
135
- await asyncio.sleep(60)
136
-
137
-
138
  class RateLimiter:
139
  def __init__(self, max_calls, time_period):
140
  self.max_calls = max_calls
141
  self.time_period = time_period
142
  self.calls = {}
143
  self._lock = asyncio.Lock()
144
- self._cleanup_task = None
145
- self._stopping = False
146
-
147
- def start_cleanup_task(self):
148
- if self._cleanup_task is None or self._cleanup_task.done():
149
- self._cleanup_task = asyncio.create_task(self._periodic_cleanup())
150
- self._cleanup_task.add_done_callback(self._cleanup_task_done)
151
-
152
- def stop_cleanup_task(self):
153
- self._stopping = True
154
- if self._cleanup_task and not self._cleanup_task.done():
155
- self._cleanup_task.cancel()
156
-
157
- def _cleanup_task_done(self, task):
158
- try:
159
- task.result()
160
- except asyncio.CancelledError:
161
- pass
162
- except Exception as e:
163
- logger.error(f"Error in rate limiter cleanup task: {e}")
164
- if not self._stopping:
165
- asyncio.create_task(self._delayed_restart())
166
-
167
- async def _delayed_restart(self):
168
- await asyncio.sleep(5)
169
- if not self._stopping:
170
- self.start_cleanup_task()
171
-
172
- async def _periodic_cleanup(self):
173
- while not self._stopping:
174
- try:
175
- await asyncio.sleep(60)
176
- await self._cleanup_old_entries()
177
- except asyncio.CancelledError:
178
- break
179
- except Exception as e:
180
- logger.error(f"Error in periodic cleanup: {e}")
181
- await asyncio.sleep(30)
182
-
183
- async def _cleanup_old_entries(self):
184
- now = time.time()
185
- async with self._lock:
186
- for user_id in list(self.calls.keys()):
187
- self.calls[user_id] = [ts for ts in self.calls[user_id]
188
- if now - ts <= self.time_period]
189
- if not self.calls[user_id]:
190
- del self.calls[user_id]
191
 
192
  async def is_rate_limited(self, user_id):
193
- if user_id is None:
194
- return False
195
-
196
  async with self._lock:
197
  now = time.time()
198
  if user_id not in self.calls:
199
  self.calls[user_id] = []
200
-
201
  self.calls[user_id] = [
202
  ts for ts in self.calls[user_id]
203
  if now - ts <= self.time_period
204
  ]
205
-
206
  if len(self.calls[user_id]) >= self.max_calls:
207
  return True
208
-
209
  self.calls[user_id].append(now)
210
  return False
211
 
212
  async def get_reset_time(self, user_id):
213
- if user_id is None:
214
- return 0
215
-
216
  async with self._lock:
217
  if user_id not in self.calls or not self.calls[user_id]:
218
  return 0
219
-
220
  now = time.time()
221
  oldest_call = min(self.calls[user_id])
222
  return max(0, self.time_period - (now - oldest_call))
223
 
224
-
225
- # Initialize rate limiter
226
  rate_limiter = RateLimiter(max_calls=20, time_period=60)
227
 
228
-
229
- # Helper Functions
230
  async def handle_flood_wait(e):
231
  wait_time = e.value
232
- logger.debug(f"FloodWait encountered. Sleeping for {wait_time} seconds.")
233
- jitter = random.uniform(0, 0.05 * wait_time)
234
- await asyncio.sleep(wait_time + jitter)
235
-
236
 
237
  def get_file_unique_id(media_message):
238
  media_types = [
239
  'document', 'video', 'audio', 'photo', 'animation',
240
  'voice', 'video_note', 'sticker'
241
  ]
242
-
243
  for media_type in media_types:
244
  media = getattr(media_message, media_type, None)
245
  if media:
246
  return media.file_unique_id
247
-
248
  return None
249
 
250
-
251
  async def forward_media(media_message):
252
  for retry in range(3):
253
  try:
254
  result = await media_message.copy(chat_id=Var.BIN_CHANNEL)
255
- await asyncio.sleep(0.1)
256
  return result
257
  except Exception:
258
  try:
259
  result = await media_message.forward(chat_id=Var.BIN_CHANNEL)
260
- await asyncio.sleep(0.1)
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
- user_name = user.first_name if hasattr(user, 'first_name') else user.title
 
 
 
 
 
 
 
279
  await log_msg.reply_text(
280
  MSG_NEW_FILE_REQUEST.format(
281
- user_name=user_name,
282
- user_id=user.id,
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
- # Start background tasks
 
 
 
 
 
 
 
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 quote_plus, quote
10
 
11
  from pyrogram import Client, enums
12
- from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton, User, CallbackQuery, LinkPreviewOptions
13
  from pyrogram.enums import ChatMemberStatus
14
- from pyrogram.raw.functions.messages import DeleteHistory
15
- from pyrogram.errors import FloodWait, UserNotParticipant, PeerIdInvalid, ChannelPrivate, ChatAdminRequired, BadRequest
16
 
17
- from Thunder.bot import StreamBot
18
  from Thunder.vars import Var
19
  from Thunder.utils.database import db
20
  from Thunder.utils.human_readable import humanbytes
 
21
  from Thunder.utils.logger import logger
 
22
  from Thunder.utils.messages import (
23
  MSG_BUTTON_GET_HELP,
24
  MSG_NEW_USER,
@@ -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
- try:
38
- if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
39
- await bot.send_message(chat_id=Var.BIN_CHANNEL, text=text)
40
- except Exception:
41
- pass
42
 
 
43
  async def notify_owner(client: Client, text: str):
44
- try:
45
- owner_ids = Var.OWNER_ID
46
- if isinstance(owner_ids, (list, tuple, set)):
47
- tasks = [
48
- client.send_message(chat_id=owner_id, text=text)
49
- for owner_id in owner_ids
50
- ]
51
- results = await asyncio.gather(*tasks)
52
- await asyncio.sleep(0.1)
53
- else:
54
- await client.send_message(chat_id=owner_ids, text=text)
55
- await asyncio.sleep(0.1)
56
-
57
- if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
58
- await client.send_message(chat_id=Var.BIN_CHANNEL, text=text)
59
- await asyncio.sleep(0.1)
60
- except Exception as e:
61
- logger.error(f"Failed to send message to owner: {e}")
62
 
 
63
  async def handle_user_error(message: Message, error_msg: str):
64
- try:
65
- await message.reply_text(
66
- error_msg,
67
- quote=True,
68
- link_preview_options=LinkPreviewOptions(is_disabled=True),
69
- reply_markup=InlineKeyboardMarkup([
70
- [InlineKeyboardButton(MSG_BUTTON_GET_HELP, callback_data="help_command")]
71
- ])
72
- )
73
- except Exception:
74
- pass
75
 
 
76
  async def log_new_user(bot: Client, user_id: int, first_name: str):
77
- try:
78
- if not await db.is_user_exist(user_id):
79
- await db.add_user(user_id)
80
- if hasattr(Var, 'BIN_CHANNEL') and isinstance(Var.BIN_CHANNEL, int) and Var.BIN_CHANNEL != 0:
81
- await bot.send_message(
82
- Var.BIN_CHANNEL,
83
- MSG_NEW_USER.format(
84
- first_name=first_name,
85
- user_id=user_id
86
- )
87
- )
88
- except Exception:
89
- pass
90
 
 
91
  async def generate_media_links(log_msg: Message, shortener: bool = True) -> Tuple[str, str, str, str]:
92
- try:
93
- base_url = Var.URL.rstrip("/")
94
-
95
- file_id = log_msg.id
96
-
97
- media_name = get_name(log_msg)
98
- if isinstance(media_name, bytes):
99
- media_name = media_name.decode('utf-8', errors='replace')
100
- else:
101
- media_name = str(media_name)
102
-
103
- media_size = humanbytes(get_media_file_size(log_msg))
104
- file_name_encoded = quote(media_name)
105
- hash_value = get_hash(log_msg)
106
-
107
- stream_link_raw = f"{base_url}/watch/{hash_value}{file_id}/{file_name_encoded}"
108
- online_link_raw = f"{base_url}/{hash_value}{file_id}/{file_name_encoded}"
109
-
110
- stream_link = stream_link_raw
111
- online_link = online_link_raw
112
-
113
- if shortener and getattr(Var, "SHORTEN_MEDIA_LINKS", False):
114
- stream_link = await shorten(stream_link_raw)
115
- online_link = await shorten(online_link_raw)
116
-
117
- return stream_link, online_link, media_name, media_size
118
- except Exception as e:
119
- logger.error(f"Error generating media links: {e}")
120
- return "", "", "", ""
121
 
 
122
  async def send_links_to_user(client: Client, command_message: Message, media_name: str, media_size: str, stream_link: str, online_link: str):
123
  msg_text = MSG_LINKS.format(
124
  file_name=media_name,
@@ -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
- try:
131
- await command_message.reply_text(
132
- msg_text,
133
- quote=True,
134
- link_preview_options=LinkPreviewOptions(is_disabled=True),
135
- parse_mode=enums.ParseMode.MARKDOWN,
136
- reply_markup=InlineKeyboardMarkup([
137
- [
138
- InlineKeyboardButton(MSG_BUTTON_STREAM_NOW, url=stream_link),
139
- InlineKeyboardButton(MSG_BUTTON_DOWNLOAD, url=online_link)
140
- ]
141
- ]),
142
- )
143
- await asyncio.sleep(0.3)
144
- except Exception as e:
145
- logger.error(f"Error sending links to user: {e}")
146
- raise
147
 
 
148
  async def generate_dc_text(user: User) -> str:
149
  dc_id = user.dc_id if user.dc_id is not None else MSG_DC_UNKNOWN
150
  return MSG_DC_USER_INFO.format(
@@ -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
- try:
158
- if isinstance(query, str):
159
- if query.startswith('@'):
160
- return await bot.get_users(query)
161
- elif query.isdigit():
162
- return await bot.get_users(int(query))
163
- elif isinstance(query, int):
164
  return await bot.get_users(query)
165
- return None
166
- except Exception:
167
- return None
 
 
168
 
 
169
  async def check_admin_privileges(client: Client, chat_id: int) -> bool:
170
- try:
171
- member = await client.get_chat_member(chat_id, client.me.id)
172
- return member.status in [
173
- ChatMemberStatus.ADMINISTRATOR,
174
- ChatMemberStatus.OWNER
175
- ]
176
- except Exception:
177
- return False
178
 
 
179
  async def _execute_command_from_callback(client: Client, callback_query: CallbackQuery, command_name: str, command_func):
180
- try:
181
- await callback_query.answer()
182
- mock_message = callback_query.message
183
- mock_message.from_user = callback_query.from_user
184
- mock_message.chat = callback_query.message.chat
185
- mock_message.text = f"/{command_name}"
186
- mock_message.command = [command_name]
187
- await command_func(client, mock_message)
188
- logger.info(f"User {callback_query.from_user.id} used {command_name} via callback")
189
- except Exception as e:
190
- logger.error(f"Error in {command_name}_callback: {e}")
191
- await callback_query.answer(MSG_ERROR_GENERIC_CALLBACK.format(error_id=uuid.uuid4().hex[:8]), show_alert=True)
 
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.logger import logger
10
-
11
 
 
12
  async def send_msg(user_id: int, message: Message) -> Tuple[int, str]:
13
- # Attempt to forward a message to a user and handle exceptions
14
  try:
15
  await message.forward(chat_id=user_id)
16
- return 200, None # Success code
17
-
18
  except FloodWait as e:
19
- logger.warning(f"FloodWait error: sleeping for {e.value} seconds.")
20
  await asyncio.sleep(e.value + 1)
21
- return await send_msg(user_id, message) # Retry after wait
22
-
23
  except InputUserDeactivated:
24
- error_msg = f"{user_id} : deactivated"
25
- logger.error(error_msg)
26
- return 400, error_msg
27
-
28
  except UserIsBlocked:
29
- error_msg = f"{user_id} : blocked the bot"
30
- logger.error(error_msg)
31
- return 400, error_msg
32
-
33
  except PeerIdInvalid:
34
- error_msg = f"{user_id} : user ID invalid"
35
- logger.error(error_msg)
36
- return 400, error_msg
37
-
38
  except Exception as e:
39
- error_msg = f"{user_id} : {traceback.format_exc()}"
40
- logger.error(f"Unexpected error: {error_msg}", exc_info=True)
41
- return 500, error_msg
 
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.logger import logger
6
-
7
 
8
  class TokenParser:
9
- # Class to parse multiple bot tokens from environment variables
10
-
11
  def __init__(self, config_file: Optional[str] = None):
12
- # Initialize the TokenParser
13
  self.tokens: Dict[int, str] = {}
14
  self.config_file = config_file
 
15
 
 
16
  def parse_from_env(self) -> Dict[int, str]:
17
- # Parse bot tokens from environment variables
18
- # MULTI_TOKEN1=token_1, MULTI_TOKEN2=token_2, etc.
19
 
20
- # Filter environment variables that start with "MULTI_TOKEN"
21
  multi_tokens = {
22
- key: value for key, value in os.environ.items()
 
23
  if key.startswith("MULTI_TOKEN") and value.strip()
24
  }
25
-
26
  if not multi_tokens:
27
- logger.info("No MULTI_TOKEN environment variables found. Running with primary bot only.")
28
  return {}
29
-
30
- # Extract numeric part and sort tokens
31
  sorted_tokens = sorted(
32
  multi_tokens.items(),
33
  key=lambda item: int(''.join(filter(str.isdigit, item[0])) or 0)
34
  )
35
-
36
- # Map to a dictionary with integer keys starting at 1
37
  self.tokens = {
38
- index + 1: token for index, (_, token) in enumerate(sorted_tokens) if token.strip()
 
39
  }
40
-
41
- if not self.tokens:
42
- logger.info("No valid MULTI_TOKEN environment variables found. Running with primary bot only.")
43
- return {}
44
-
45
- return self.tokens
 
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 # Cache clean interval in seconds (30 minutes)
23
  self.cached_file_ids: Dict[int, FileId] = {}
24
  self.file_references_cache: Dict[int, Dict[str, Union[FileId, float]]] = {}
25
  self.cache_lock = asyncio.Lock()
26
  self.media_sessions_lock = asyncio.Lock()
27
 
28
- # Start cache cleaning tasks
29
  self.cache_cleaner_task = asyncio.create_task(self.clean_cache())
30
  self.session_cleaner_task = asyncio.create_task(self.cleanup_media_sessions())
31
 
@@ -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: # Less than 1 hour old
86
  return cached_ref["file_id"]
87
 
88
  try:
89
- # Fetch the message again to get fresh file reference
90
  message = await self.client.get_messages(
91
  chat_id=Var.BIN_CHANNEL,
92
  message_ids=file_id.message_id
@@ -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 for {file_id.message_id}, attempting to refresh")
252
  refreshed_file_id = await self.refresh_file_reference(file_id)
253
  if refreshed_file_id:
254
  location = await self.get_location(refreshed_file_id)
255
  file_id = refreshed_file_id
256
 
257
  if location is None:
258
- logger.error(f"Failed to get location for {file_id.message_id} even after refreshing file reference")
259
- raise FileNotFound(f"File location not found after refresh for {file_id.message_id}")
260
 
261
  max_retries = 5
 
262
 
263
  while current_part <= part_count:
264
  try:
@@ -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.warning(f"File reference for {file_id.message_id} expired/invalid: {e}. Attempting refresh.")
277
  refreshed_file_id = await self.refresh_file_reference(file_id)
278
  if refreshed_file_id:
279
- file_id = refreshed_file_id # Update file_id
280
  location = await self.get_location(file_id)
281
  if location is None:
282
- logger.error(f"Failed to get location for {file_id.message_id} after file reference refresh.")
283
- raise FileNotFoundError(f"File location not found after refresh for {file_id.message_id}")
284
- logger.info(f"Successfully refreshed file reference for {file_id.message_id} and got new location.")
285
  retry_count = 0
286
  continue
287
  else:
288
- logger.error(f"Failed to refresh file reference for {file_id.message_id}.")
289
- raise FileNotFoundError(f"Failed to refresh file reference for {file_id.message_id}")
290
- except asyncio.TimeoutError as e: # Specific handling for asyncio.TimeoutError
291
- retry_count += 1
292
- logger.warning(f"TimeoutError during file fetch for {file_id.message_id} (attempt {retry_count}/{max_retries}): {e}")
293
- if retry_count >= max_retries:
294
- logger.error(f"Max retries reached for TimeoutError on {file_id.message_id}: {e}")
295
- raise TimeoutError(f"Max retries for TimeoutError on {file_id.message_id} after {max_retries} attempts.")
296
- await asyncio.sleep(1 * (2 ** (retry_count - 1)))
297
- continue
298
  except RPCError as e:
299
  retry_count += 1
300
- logger.warning(f"RPC Error during file fetch for {file_id.message_id} (attempt {retry_count}/{max_retries}): {e}")
301
  if retry_count >= max_retries:
302
- logger.error(f"Max retries reached for RPCError on {file_id.message_id}: {e}")
303
- raise RPCError(f"Max retries for RPCError on {file_id.message_id}: {e}")
304
  await asyncio.sleep(1 * (2 ** (retry_count - 1)))
305
  continue
306
  except ConnectionError as e:
307
  retry_count += 1
308
- logger.warning(f"Connection error during file fetch for {file_id.message_id} (attempt {retry_count}/{max_retries}): {e}")
309
  if retry_count >= max_retries:
310
- logger.error(f"Max retries reached for ConnectionError on {file_id.message_id}: {e}")
311
- raise ConnectionError(f"Max retries for ConnectionError on {file_id.message_id}: {e}")
312
- await asyncio.sleep(1) # Brief pause before retry
313
  continue
314
  except Exception as e:
315
- logger.error(f"Unexpected error during file fetch for {file_id.message_id}: {str(e)}", exc_info=True)
316
- raise
317
 
318
  if isinstance(r, raw.types.upload.File):
319
  chunk = r.bytes
@@ -330,13 +309,12 @@ class ByteStreamer:
330
 
331
  current_part += 1
332
  offset += chunk_size
333
- retry_count = 0 # Reset retry count after success
334
 
335
  if current_part > part_count:
336
  break
337
- except (AttributeError) as e:
338
- logger.error(f"Error while yielding file for {file_id.message_id if 'file_id' in locals() else 'unknown file'} (AttributeError): {e}", exc_info=True)
339
- raise
340
  finally:
341
  work_loads[index] -= 1
342
 
@@ -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 # 1 hour expiry
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) # Wait before retrying
369
 
370
  async def cleanup_media_sessions(self) -> None:
371
  while True:
372
  try:
373
- await asyncio.sleep(1800) # 30 minutes
374
 
375
  async with self.media_sessions_lock:
376
  now = time.time()
@@ -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) # Wait before retrying
 
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
- """Database utilities for managing users, bans, and tokens via MongoDB."""
 
2
  import datetime
3
  from typing import Optional, Dict, Any
4
  from motor.motor_asyncio import AsyncIOMotorClient, AsyncIOMotorCollection
5
- from Thunder.utils.logger import logger
6
  from Thunder.vars import Var
 
 
7
 
8
  class Database:
9
- # Database class for handling user data using MongoDB
10
-
11
  def __init__(self, uri: str, database_name: str, *args, **kwargs):
12
- # Initialize database connection
13
  self._client = AsyncIOMotorClient(uri, *args, **kwargs)
14
  self.db = self._client[database_name]
15
  self.col: AsyncIOMotorCollection = self.db.users
@@ -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
- user = self.new_user(user_id)
46
- await self.col.insert_one(user)
47
-
 
48
  async def add_user_pass(self, user_id: int, ag_pass: str):
49
- # Add or update user password
50
  await self.add_user(user_id)
51
  await self.col.update_one({'id': user_id}, {'$set': {'ag_p': ag_pass}})
52
-
 
 
53
  async def get_user_pass(self, user_id: int) -> Optional[str]:
54
- # Get user password
55
- user_pass = await self.col.find_one({'id': user_id}, {'ag_p': 1})
56
- return user_pass.get('ag_p') if user_pass else None
57
-
58
  async def is_user_exist(self, user_id: int) -> bool:
59
- # Check if user exists
60
  user = await self.col.find_one({'id': user_id}, {'_id': 1})
61
  return bool(user)
62
-
 
63
  async def total_users_count(self) -> int:
64
- # Count total users
65
  return await self.col.count_documents({})
66
-
 
67
  async def get_all_users(self):
68
- # Get all users (returns cursor)
69
  return self.col.find({})
70
-
 
71
  async def delete_user(self, user_id: int):
72
- # Delete user
73
  await self.col.delete_one({'id': user_id})
74
-
 
 
75
  async def create_index(self):
76
- # Create index for faster queries
77
  await self.col.create_index("id", unique=True)
78
-
 
 
79
  async def get_active_users(self, days: int = 7):
80
- # Get users active within specified days
81
  cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=days)
82
  return self.col.find({'join_date': {'$gte': cutoff}})
83
-
 
84
  async def add_banned_user(
85
  self, user_id: int, banned_by: Optional[int] = None,
86
  reason: Optional[str] = None, ban_time: Optional[str] = None
87
  ):
88
- # Add or update banned user with upsert
89
  ban_data = {
90
  "user_id": user_id,
91
  "banned_at": datetime.datetime.utcnow(),
92
  "banned_by": banned_by,
93
  "reason": reason
94
  }
95
- try:
96
- await self.banned_users_col.update_one(
97
- {"user_id": user_id},
98
- {"$set": ban_data},
99
- upsert=True
100
- )
101
- except Exception as e:
102
- logger.error(
103
- f"Database error in add_banned_user for user {user_id}: {e}"
104
- )
105
- raise RuntimeError(f"Failed to ban user {user_id}: {e}")
106
 
 
107
  async def remove_banned_user(self, user_id: int) -> bool:
108
- # Remove banned user and return if document was deleted
109
- try:
110
- result = await self.banned_users_col.delete_one(
111
- {"user_id": user_id}
112
- )
113
- return result.deleted_count > 0
114
- except Exception as e:
115
- logger.error(
116
- f"Database error in remove_banned_user for user {user_id}: {e}"
117
- )
118
- return False
119
 
 
120
  async def is_user_banned(self, user_id: int) -> Optional[Dict[str, Any]]:
121
- # Check if user is banned and return ban details
122
- try:
123
- return await self.banned_users_col.find_one({"user_id": user_id})
124
- except Exception as e:
125
- logger.error(
126
- f"Database error in is_user_banned for user {user_id}: {e}"
127
- )
128
- return None
129
 
 
130
  async def check_user_token(self, user_id: int) -> Dict[str, bool]:
131
- """Check if user has valid token."""
132
- try:
133
- # Only return active (not expired) tokens
134
- token_data = await self.token_col.find_one({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
135
  "user_id": user_id,
136
- "expires_at": {"$gt": datetime.datetime.utcnow()}
 
 
137
  })
138
- return {"has_token": bool(token_data)}
139
- except Exception as e:
140
- logger.error(f"Database error checking token for {user_id}: {e}")
141
- return {"has_token": False}
142
-
143
- async def update_user_token(self, user_id: int, token: str) -> None:
144
- """Update user's active token in database."""
145
- try:
146
- # Find the token data first
147
- token_data = await self.token_col.find_one({"token": token})
148
-
149
- if token_data:
150
- # Link token to user and refresh expiration
151
- await self.token_col.update_one(
152
- {"token": token},
153
- {"$set": {
154
- "user_id": user_id,
155
- "expires_at": datetime.datetime.utcnow() + datetime.timedelta(hours=Var.TOKEN_TTL_HOURS)
156
- }}
157
- )
158
- else:
159
- # If no token data found, create a basic entry (this is a fallback)
160
- logger.warning(f"Token {token} not found in database when assigning to user {user_id}")
161
- await self.token_col.insert_one({
162
- "user_id": user_id,
163
- "token": token,
164
- "created_at": datetime.datetime.utcnow(),
165
- "expires_at": datetime.datetime.utcnow() + datetime.timedelta(hours=Var.TOKEN_TTL_HOURS)
166
- })
167
- except Exception as e:
168
- logger.error(f"Database error updating token for {user_id}: {e}")
169
- raise RuntimeError(f"Failed to update token: {e}")
170
 
 
171
  async def save_broadcast_state(self, broadcast_id, state_data):
172
- broadcast_collection = self.db.broadcasts
173
- await broadcast_collection.update_one(
174
  {"_id": broadcast_id},
175
  {"$set": state_data},
176
  upsert=True
177
  )
 
178
 
 
179
  async def get_broadcast_state(self, broadcast_id):
180
- broadcast_collection = self.db.broadcasts
181
- state = await broadcast_collection.find_one({"_id": broadcast_id})
182
- return state
183
 
 
184
  async def list_active_broadcasts(self):
185
- broadcast_collection = self.db.broadcasts
186
- cursor = broadcast_collection.find({"is_cancelled": False})
187
- return await cursor.to_list(length=100)
188
 
 
189
  async def add_restart_message(self, message_id: int, chat_id: int) -> None:
190
- """Store the restart message ID and chat ID."""
191
  try:
192
- await self.restart_message_col.insert_one(
193
- {"message_id": message_id, "chat_id": chat_id, "timestamp": datetime.datetime.utcnow()}
194
- )
195
- logger.debug(f"Restart message {message_id} in chat {chat_id} saved to DB.")
 
 
196
  except Exception as e:
197
- logger.error(f"Database error saving restart message: {e}")
198
 
 
199
  async def get_restart_message(self) -> Optional[Dict[str, Any]]:
200
- """Retrieve the restart message from the database."""
201
  try:
202
- # Get the most recent restart message
203
- restart_msg = await self.restart_message_col.find_one(sort=[("timestamp", -1)])
204
- if restart_msg:
205
- logger.debug(f"Retrieved restart message {restart_msg['message_id']} from DB.")
206
- return restart_msg
207
  except Exception as e:
208
- logger.error(f"Database error retrieving restart message: {e}")
209
  return None
210
 
 
211
  async def delete_restart_message(self, message_id: int) -> None:
212
- """Delete the restart message from the database."""
213
  try:
214
- result = await self.restart_message_col.delete_one({"message_id": message_id})
215
- if result.deleted_count > 0:
216
- logger.debug(f"Restart message {message_id} deleted from DB.")
217
- else:
218
- logger.debug(f"Attempted to delete non-existent restart message {message_id}.")
219
  except Exception as e:
220
- logger.error(f"Database error deleting restart message: {e}")
221
 
222
  async def close(self):
223
- # Close database connection
224
  if self._client:
225
  self._client.close()
226
 
227
- # Initialize the Database instance
228
  db = Database(Var.DATABASE_URL, Var.NAME)
 
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
- user_id = _check_message_user(message)
24
- if not user_id:
25
- return await func(client, message, *args, **kwargs)
 
 
 
 
 
26
 
27
- if user_id in Var.OWNER_ID:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
28
  return await func(client, message, *args, **kwargs)
29
-
30
- ban_details = await db.is_user_banned(user_id)
31
- if ban_details:
32
- banned_at = ban_details.get('banned_at')
33
- if banned_at and hasattr(banned_at, 'strftime'):
34
- ban_time = banned_at.strftime('%B %d, %Y, %I:%M %p UTC')
35
- else:
36
- ban_time = str(banned_at) if banned_at else 'N/A'
37
-
38
- ban_message = MSG_DECORATOR_BANNED.format(
39
- reason=ban_details.get('reason', 'Not specified'),
40
- ban_time=ban_time
41
- )
42
- await message.reply_text(ban_message, quote=True)
43
- return
44
-
45
- return await func(client, message, *args, **kwargs)
46
  return wrapper
47
 
48
  def require_token(func):
49
  @wraps(func)
50
  async def wrapper(client, message: Message, *args, **kwargs):
51
- user_id = _check_message_user(message)
52
- if not user_id:
53
- return await func(client, message, *args, **kwargs)
54
-
55
- if not getattr(Var, "TOKEN_ENABLED", False):
56
- return await func(client, message, *args, **kwargs)
57
-
58
- if user_id in Var.OWNER_ID:
59
- return await func(client, message, *args, **kwargs)
60
-
61
- if await allowed(user_id):
62
- return await func(client, message, *args, **kwargs)
63
-
64
- if not await check(user_id):
65
  token_data = await generate(user_id)
 
 
 
 
 
66
  me = await client.get_me()
67
  deep_link = f"https://t.me/{me.username}?start={token_data['token']}"
68
-
69
  try:
70
- short_url = await shorten(deep_link)
 
 
71
  except Exception as e:
72
- logger.error(f"URL shortening failed: {e}")
73
- short_url = deep_link
74
-
75
  await message.reply_text(
76
  MSG_TOKEN_INVALID,
77
- reply_markup=InlineKeyboardMarkup(
78
- [[InlineKeyboardButton("Get Token", url=short_url)]]
79
- ),
80
  quote=True
81
  )
 
 
 
 
 
 
 
 
82
  return
83
-
84
- return await func(client, message, *args, **kwargs)
85
  return wrapper
86
 
87
  def shorten_link(func):
88
  @wraps(func)
89
  async def wrapper(client, message: Message, *args, **kwargs):
90
- user_id = None
91
- if message.from_user:
92
- user_id = message.from_user.id
93
-
94
- if user_id and (user_id in Var.OWNER_ID or await allowed(user_id)):
95
- kwargs['shortener'] = False
96
- else:
97
- kwargs['shortener'] = getattr(Var, "SHORTEN_MEDIA_LINKS", False)
98
-
99
- return await func(client, message, *args, **kwargs)
 
 
 
 
 
 
 
 
100
  return wrapper
101
 
102
-
103
- def get_owner_ids():
104
- """Helper function to safely get owner IDs."""
105
- owner_ids = Var.OWNER_ID if hasattr(Var, 'OWNER_ID') and Var.OWNER_ID is not None else []
106
- if isinstance(owner_ids, int):
107
- owner_ids = [owner_ids]
108
- return owner_ids
109
-
110
-
111
  def owner_only(func):
112
- """Decorator to restrict access to bot owners."""
113
  async def wrapper(client, callback_query):
114
- owner_ids = get_owner_ids()
115
- if callback_query.from_user.id not in owner_ids:
116
- await callback_query.answer(MSG_ERROR_UNAUTHORIZED, show_alert=True)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
  return
118
- await func(client, callback_query)
119
  return wrapper
 
 
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.logger import logger
8
  from typing import Any, Optional
9
 
10
  def get_media_from_message(message: Message) -> Optional[Any]:
11
- # Extract media from message using known media type attributes
12
- media_types = ("audio", "document", "photo", "sticker",
13
- "animation", "video", "voice", "video_note")
14
-
15
- for attr in media_types:
16
  if media := getattr(message, attr, None):
17
  return media
18
  return None
19
 
20
- def parse_file_id(message: Message) -> Optional[FileId]:
21
- # Decode FileId from media if available
22
- if media := get_media_from_message(message):
23
- return FileId.decode(media.file_id)
24
- logger.warning(f"No media found in message: {message.id}")
25
- return None
26
-
27
- def parse_file_unique_id(message: Message) -> Optional[str]:
28
- # Extract unique file ID from media if available
29
- if media := get_media_from_message(message):
30
- return media.file_unique_id
31
- logger.warning("No media found for unique ID extraction")
32
- return None
33
-
34
- async def get_file_ids(client: Client, chat_id: int, message_id: int) -> FileId:
35
- # Fetch and process media file details from message
36
- try:
37
- message = await client.get_messages(chat_id, message_id)
38
- if not message or message.empty:
39
- logger.debug("Message not found or empty")
40
- raise FileNotFound("Message not found/empty")
41
-
42
- if not (media := get_media_from_message(message)):
43
- logger.debug("Message contains no media")
44
- raise FileNotFound("No media in message")
45
-
46
- file_id = parse_file_id(message)
47
- if not file_id:
48
- logger.debug("Failed to parse file ID")
49
- raise FileNotFound("Invalid file ID")
50
 
51
- # Enhance FileId object with additional metadata
52
- file_id.file_size = getattr(media, "file_size", 0)
53
- file_id.mime_type = getattr(media, "mime_type", "")
54
- file_id.file_name = getattr(media, "file_name", "")
55
- file_id.unique_id = parse_file_unique_id(message)
 
56
 
57
- return file_id
 
 
 
58
 
59
- except Exception as e:
60
- logger.debug(f"File ID processing failed: {str(e)}")
61
- raise
 
62
 
 
63
  def get_hash(media_msg: Message) -> str:
64
- # Generate short hash from file unique ID
65
- if media := get_media_from_message(media_msg):
66
- return media.file_unique_id[:6]
67
- return ""
68
 
 
69
  def get_name(media_msg: Message) -> str:
70
- # Extract filename from media if available
71
- return getattr(get_media_from_message(media_msg), "file_name", "")
72
 
 
73
  def get_media_file_size(message: Message) -> int:
74
- # Get file size from media content
75
- return getattr(get_media_from_message(message), "file_size", 0)
 
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, RightForbidden, RPCError, FloodWait
12
  from pyrogram.types import Message, InlineKeyboardMarkup, InlineKeyboardButton
13
-
14
  from Thunder.vars import Var
15
- from Thunder.utils.logger import logger
16
- from Thunder.utils.messages import (
17
- MSG_FORCE_CHANNEL_ERROR,
18
- MSG_FORCE_CHANNEL_RPC_ERROR,
19
- MSG_FORCE_CHANNEL_GENERIC_ERROR,
20
- MSG_FORCE_CHANNEL_NO_LINK,
21
- MSG_FORCE_CHANNEL_MEMBERSHIP_REQUIRED,
22
- MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION,
23
- MSG_FORCE_CHANNEL_ACCESS_REQUIRED
24
- )
25
 
26
  def force_channel_check(
27
  func: Callable[[Client, Message], Coroutine[Any, Any, Any]]
28
  ) -> Callable[[Client, Message], Coroutine[Any, Any, Any]]:
29
- """Decorator to enforce channel membership before allowing command execution."""
 
30
  async def wrapper(client: Client, message: Message, *args, **kwargs) -> None:
31
- if not Var.FORCE_CHANNEL_ID:
32
- await func(client, message, *args, **kwargs)
33
- return
34
-
35
- if message.chat.id == Var.FORCE_CHANNEL_ID:
36
- await func(client, message, *args, **kwargs)
37
- return
38
-
39
- user_id = message.from_user.id
40
  try:
41
- member = await client.get_chat_member(
42
- chat_id=Var.FORCE_CHANNEL_ID,
43
- user_id=user_id
44
- )
45
  if member.status in [ChatMemberStatus.MEMBER, ChatMemberStatus.ADMINISTRATOR, ChatMemberStatus.OWNER]:
46
- await func(client, message, *args, **kwargs)
47
- return
48
  except UserNotParticipant:
49
- logger.debug(f"[ForceChannelCheck] User {user_id} is not a participant of FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID}. Prompting to join.")
50
- except (ChatAdminRequired, RightForbidden):
51
- logger.error(f"[ForceChannelCheck] Bot lacks permissions in FORCE_CHANNEL_ID {Var.FORCE_CHANNEL_ID}.")
52
- await message.reply(MSG_FORCE_CHANNEL_ERROR.format(error_id=uuid.uuid4().hex[:8]))
53
- return
54
- except asyncio.TimeoutError:
55
- error_id = uuid.uuid4().hex[:8]
56
- logger.error(f"[ForceChannelCheck] TimeoutError (ID: {error_id}) for user {user_id} while checking membership in {Var.FORCE_CHANNEL_ID}.")
57
- await message.reply(MSG_FORCE_CHANNEL_RPC_ERROR.format(error_id=error_id))
58
- return
59
- except RPCError as e:
60
- logger.error(f"[ForceChannelCheck] RPCError for user {user_id} in {Var.FORCE_CHANNEL_ID}: {e}")
61
- await message.reply(MSG_FORCE_CHANNEL_RPC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
62
- return
63
- except FloodWait as e:
64
- wait_time: int = e.value
65
- logger.debug(f"[ForceChannelCheck] Flood wait for {wait_time} seconds")
66
- await asyncio.sleep(wait_time)
67
- return await wrapper(client, message, *args, **kwargs)
68
- except Exception as e:
69
- logger.error(f"[ForceChannelCheck] Unexpected error for user {user_id} in {Var.FORCE_CHANNEL_ID}: {e}")
70
- await message.reply(MSG_FORCE_CHANNEL_GENERIC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
71
- return
72
-
73
- try:
74
- chat = await client.get_chat(Var.FORCE_CHANNEL_ID)
75
- invite_link = f"https://t.me/{chat.username}" if chat.username else chat.invite_link
76
- if not invite_link:
77
- logger.warning(f"[ForceChannelCheck] No invite link for {Var.FORCE_CHANNEL_ID} ({chat.title})")
78
- await message.reply(MSG_FORCE_CHANNEL_NO_LINK)
79
- return
80
-
81
  await message.reply(
82
- MSG_FORCE_CHANNEL_MEMBERSHIP_REQUIRED.format(channel_name=chat.title, error_id=uuid.uuid4().hex[:8]),
83
- reply_markup=InlineKeyboardMarkup([
84
- [InlineKeyboardButton(MSG_FORCE_CHANNEL_ACCESS_REQUIRED, url=invite_link)],
85
- ]),
86
  )
87
- except asyncio.TimeoutError:
88
- error_id = uuid.uuid4().hex[:8]
89
- logger.error(f"[ForceChannelCheck] TimeoutError (ID: {error_id}) getting chat/invite link for {Var.FORCE_CHANNEL_ID}.")
90
- await message.reply(MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION.format(error_id=error_id))
91
- except RPCError as e:
92
- logger.error(f"[ForceChannelCheck] RPCError getting chat/invite link for {Var.FORCE_CHANNEL_ID}: {e}")
93
- await message.reply(MSG_FORCE_CHANNEL_SERVICE_INTERRUPTION.format(error_id=uuid.uuid4().hex[:8]))
94
- except Exception as e:
95
- logger.error(f"[ForceChannelCheck] Unexpected error getting chat/invite link for {Var.FORCE_CHANNEL_ID}: {e}")
96
- await message.reply(MSG_FORCE_CHANNEL_GENERIC_ERROR.format(error_id=uuid.uuid4().hex[:8]))
97
  return wrapper
98
-
99
-
 
 
 
 
 
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
- units = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']
11
-
12
- while size >= power and n < len(units) - 1:
13
- size /= power
14
  n += 1
15
-
16
- return f"{round(size, decimal_places)} {units[n]}B"
 
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
- # Periodically pings server URL to keep it alive
10
- sleep_time = Var.PING_INTERVAL
11
-
12
- while True:
13
- await asyncio.sleep(sleep_time)
14
- try:
15
- async with aiohttp.ClientSession(
16
- timeout=aiohttp.ClientTimeout(total=10) ) as session:
17
- async with session.get(Var.URL) as resp:
18
- pass
19
- except asyncio.TimeoutError:
20
- logger.warning("Server ping timeout")
21
- except Exception as e:
22
- logger.error(f"Ping error: {str(e)}")
 
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
- # Get project root and setup log directory
9
- PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
10
- LOG_DIR = os.path.join(PROJECT_ROOT, 'logs')
11
  os.makedirs(LOG_DIR, exist_ok=True)
12
  LOG_FILE = os.path.join(LOG_DIR, 'bot.txt')
13
 
14
- # Configure logger
15
- logger = logging.getLogger('ThunderBot')
16
- logger.setLevel(logging.INFO)
17
- logger.propagate = False
 
18
 
19
- # Setup log formatting
20
- log_formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
21
 
22
- # Configure rotating file handler (10MB max, 5 backup files)
23
- file_handler = RotatingFileHandler(
24
- LOG_FILE,
25
- maxBytes=10 * 1024 * 1024,
26
- backupCount=5
27
- )
28
- file_handler.setFormatter(log_formatter)
29
- file_handler.setLevel(logging.INFO)
30
 
 
 
31
 
32
- # Configure console output
33
- console_handler = logging.StreamHandler(sys.stdout)
34
- console_handler.setFormatter(log_formatter)
35
- console_handler.setLevel(logging.INFO)
36
 
 
 
 
 
37
 
38
- # Add handlers to our logger instance
39
- logger.addHandler(file_handler)
40
- logger.addHandler(console_handler)
41
 
42
  __all__ = ['logger', 'LOG_FILE']
 
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:** [{user_name}](tg://user?id={user_id})\n"
304
- "> 🆔 **ID:** `{user_id}`\n\n"
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.logger import logger
11
 
12
- # Initialize Jinja2 environment
13
- template_env = Environment(loader=FileSystemLoader('Thunder/template'), enable_async=True)
 
 
 
 
 
14
 
15
- async def render_page(id: int, secure_hash: str, requested_action: str | None = None) -> str: # Re-add requested_action
 
16
  file_data = await get_file_ids(StreamBot, int(Var.BIN_CHANNEL), id)
17
  if file_data.unique_id[:6] != secure_hash:
18
- logger.debug(f'Link hash: {secure_hash} - Expected: {file_data.unique_id[:6]}')
19
- logger.debug(f"Invalid hash for message ID {id}")
20
  raise InvalidHash
21
-
22
  quoted_filename = urllib.parse.quote(file_data.file_name.replace('/', '_'))
23
- src = urllib.parse.urljoin(Var.URL, f'{secure_hash}{str(id)}/{quoted_filename}')
24
-
25
  safe_filename = html_module.escape(file_data.file_name)
26
-
27
- template_name: str
28
- context: dict
29
-
30
  if requested_action == 'stream':
31
- template_name = 'req.html'
32
  context = {
33
  'tag': "video",
34
  'heading': f"View {safe_filename}",
35
  'file_name': safe_filename,
36
  'src': src
37
  }
38
- else: # Default to download
39
- template_name = 'dl.html'
40
  context = {
41
  'file_name': safe_filename,
42
  'src': src
43
  }
44
-
45
- template = template_env.get_template(template_name)
46
- html_content = await template.render_async(**context)
47
- return html_content
 
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
- '''URL shortening utility.'''
2
- import asyncio
3
- from typing import Dict, Optional
4
  from base64 import b64encode
5
  from random import random, choice
6
- from urllib.parse import quote, urlparse
7
- from warnings import filterwarnings
8
  import cloudscraper
9
-
10
  from Thunder.vars import Var
11
  from Thunder.utils.logger import logger
12
 
13
- # Global shortener dictionary, scraper session, lock, and status flag
14
- shortener_dict: Dict[str, str] = {}
15
- _scraper_session: Optional[cloudscraper.CloudScraper] = None
16
- _init_lock = asyncio.Lock()
17
- _shortener_system_ready = False
 
 
 
 
18
 
19
- # Suppress insecure HTTPS warnings
20
- try:
21
- import urllib3
22
- urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
23
- except (ImportError, AttributeError):
24
- filterwarnings('ignore', message='Unverified HTTPS request')
 
 
 
 
 
 
 
25
 
26
- def _init_shorteners() -> bool:
27
- """Initializes shortener config and the global scraper session."""
28
- global shortener_dict, _scraper_session, _shortener_system_ready
 
29
 
30
- _shortener_system_ready = False # Assume failure until success
31
- shortener_dict = {}
32
- _scraper_session = None
 
 
 
 
 
 
 
 
 
 
 
33
 
34
- # Check if shortening is enabled via Var
35
- is_enabled = getattr(Var, "SHORTEN_ENABLED", False) or getattr(Var, "SHORTEN_MEDIA_LINKS", False)
36
- if not is_enabled:
37
- logger.info("ⓘ URL shortening is disabled")
38
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
 
40
- # Get URL shortener configuration
41
- site = getattr(Var, "URL_SHORTENER_SITE", "")
42
- api_key = getattr(Var, "URL_SHORTENER_API_KEY", "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
 
44
- if site and api_key:
45
- shortener_dict[site] = api_key
 
 
 
 
 
 
 
 
 
 
 
 
46
  try:
47
- _scraper_session = cloudscraper.create_scraper(
48
  browser={
49
  'browser': 'chrome',
50
- 'platform': 'android',
51
- 'desktop': False
52
- }
 
 
53
  )
54
- logger.info(f"✓ Shortener initialized: {site} (session created)")
55
- _shortener_system_ready = True
 
 
 
 
56
  return True
57
  except Exception as e:
58
- logger.error(f"Failed to create scraper session: {e}")
59
- # Reset to ensure clean state on session creation failure
60
- shortener_dict = {}
61
- _scraper_session = None
62
- _shortener_system_ready = False
63
  return False
64
 
65
- logger.warning("ⓘ URL shortener not configured or API key missing")
66
- _shortener_system_ready = False # Explicitly ensure it's false
67
- return False
68
-
69
- def _validate_url(url: str) -> bool:
70
- """Checks if a string is a well-formed URL."""
71
- try:
72
- result = urlparse(url)
73
- return all([result.scheme, result.netloc])
74
- except Exception:
75
- return False
76
-
77
- async def short_url(longurl: str, attempt: int = 0) -> str:
78
- """Shortens a URL using a configured service, with retries and fallback."""
79
- global _shortener_system_ready, _scraper_session # Ensure globals are accessible
80
-
81
- if not _shortener_system_ready: # First check (no lock)
82
- async with _init_lock:
83
- if not _shortener_system_ready: # Double check (with lock)
84
- _init_shorteners() # Attempt initialization
85
-
86
- if not _shortener_system_ready:
87
- # Initialization failed or not enabled, _init_shorteners logs details
88
- return longurl
89
 
90
- if _scraper_session is None:
91
- logger.error(
92
- "Critical state: _shortener_system_ready is True, but _scraper_session is None. "
93
- "Attempting a locked re-initialization."
94
- )
95
- async with _init_lock:
96
- _init_shorteners()
97
-
98
- if not _shortener_system_ready or _scraper_session is None:
99
- logger.error(
100
- "Critical: Scraper session remains uninitialized after re-attempt. "
101
- "URL shortening will be skipped for this request."
102
- )
103
- return longurl
104
 
105
- if attempt >= 3:
106
- logger.warning(f"Max attempts reached for {longurl}. Returning original.")
107
- return longurl
108
-
109
- if not _validate_url(longurl):
110
- logger.warning(f"Invalid URL format: {longurl}. Returning original.")
111
- return longurl
112
-
113
- try:
114
- session = _scraper_session
115
-
116
- # Ensure shortener_dict is not empty before trying to access its items
117
- if not shortener_dict:
118
- logger.error("Shortener dictionary is empty. Cannot proceed with shortening.")
119
- return longurl # Or raise an exception if this state is unexpected
120
-
121
- _shortener, _shortener_api = choice(list(shortener_dict.items()))
122
-
123
- # --- Shorte.st ---
124
- if "shorte.st" in _shortener:
125
- headers = {"public-api-token": _shortener_api}
126
- data = {"urlToShorten": quote(longurl)}
127
- response = session.put(
128
- "https://api.shorte.st/v1/data/url",
129
- headers=headers,
130
- data=data
131
- )
132
-
133
- if response.status_code != 200:
134
- raise Exception(f"HTTP error {response.status_code}")
135
-
136
- return response.json()["shortenedUrl"]
137
-
138
- # --- Linkvertise ---
139
- elif "linkvertise" in _shortener:
140
- url = quote(b64encode(longurl.encode("utf-8")))
141
- linkvertise = [
142
- f"https://link-to.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
143
- f"https://up-to-down.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
144
- f"https://direct-link.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
145
- f"https://file-link.net/{_shortener_api}/{random() * 1000}/dynamic?r={url}",
146
- ]
147
- return choice(linkvertise)
148
-
149
- # --- Bitly.com ---
150
- elif "bitly.com" in _shortener:
151
- headers = {"Authorization": f"Bearer {_shortener_api}"}
152
- response = session.post(
153
- "https://api-ssl.bit.ly/v4/shorten",
154
- json={"long_url": longurl},
155
- headers=headers
156
- )
157
-
158
- if response.status_code != 200:
159
- raise Exception(f"HTTP error {response.status_code}")
160
-
161
- return response.json()["link"]
162
-
163
- # --- Ouo.io ---
164
- elif "ouo.io" in _shortener:
165
- response = session.get(
166
- f"http://ouo.io/api/{_shortener_api}?s={longurl}",
167
- verify=False
168
- )
169
-
170
- if not response.text or response.status_code != 200:
171
- raise Exception(f"HTTP error {response.status_code}")
172
-
173
- return response.text
174
-
175
- # --- Cutt.ly ---
176
- elif "cutt.ly" in _shortener:
177
- response = session.get(
178
- f"http://cutt.ly/api/api.php?key={_shortener_api}&short={longurl}"
179
- )
180
-
181
- if response.status_code != 200:
182
- raise Exception(f"HTTP error {response.status_code}")
183
-
184
- return response.json()["url"]["shortLink"]
185
-
186
- # --- Generic & Fallback ---
187
- else:
188
- # Generic shortener handling
189
- response = session.get(
190
- f"https://{_shortener}/api?api={_shortener_api}&url={quote(longurl)}"
191
- )
192
-
193
- if response.status_code != 200:
194
- raise Exception(f"Generic API error {response.status_code}")
195
-
196
- result = response.json()
197
- shorted = result.get("shortenedUrl")
198
-
199
- # Fallback to shrtco.de if generic failed or no result
200
- if not shorted:
201
- try:
202
- shrtco_response = session.get(
203
- f"https://api.shrtco.de/v2/shorten?url={quote(longurl)}"
204
- )
205
-
206
- if shrtco_response.status_code != 200:
207
- logger.warning(f"shrtco.de fallback failed with status {shrtco_response.status_code}")
208
- return longurl # Return original if fallback also fails badly
209
-
210
- shrtco_result = shrtco_response.json()
211
- if not shrtco_result.get("ok") or "result" not in shrtco_result or "full_short_link" not in shrtco_result["result"]:
212
- logger.warning(f"shrtco.de fallback response malformed: {shrtco_result}")
213
- return longurl
214
-
215
- shrtco_link = shrtco_result["result"]["full_short_link"]
216
-
217
- # Try the configured generic shortener again with the shrtco.de link
218
- response = session.get(
219
- f"https://{_shortener}/api?api={_shortener_api}&url={shrtco_link}"
220
- )
221
-
222
- if response.status_code != 200:
223
- logger.warning(f"Generic shortener retry with shrtco.de link failed: status {response.status_code}")
224
- return longurl # Return original if retry fails
225
-
226
- result = response.json()
227
- shorted = result.get("shortenedUrl")
228
- except Exception as fallback_e:
229
- logger.error(f"shrtco.de fallback exception: {fallback_e}")
230
- return longurl # Return original on fallback exception
231
-
232
- return shorted if shorted else longurl
233
-
234
- except Exception as e:
235
- shortener_name = _shortener if '_shortener' in locals() else 'unknown'
236
- logger.error(f"URL shortener error ({shortener_name} for {longurl}): {e}, attempt {attempt + 1}")
237
-
238
- await asyncio.sleep(1) # Wait before retrying
239
- return await short_url(longurl, attempt + 1)
240
 
241
  async def shorten(url: str) -> str:
242
- """Async public wrapper for short_url."""
243
- return await short_url(url)
 
 
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
- # Thunder/utils/time_format.py
2
 
 
 
 
3
  def get_readable_time(seconds: int) -> str:
4
- # Convert seconds to human-readable time format (e.g., "2d 5h 30m 10s")
5
- periods = [
6
- ('d', 86400), # Days
7
- ('h', 3600), # Hours
8
- ('m', 60), # Minutes
9
- ('s', 1) # Seconds
10
- ]
11
  result = []
12
-
13
- for suffix, period in periods:
14
  if seconds >= period:
15
  value, seconds = divmod(seconds, period)
16
- result.append(f"{int(value)}{suffix}")
17
-
18
  return ' '.join(result) if result else '0s'
 
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, AsyncIterator, List, Union
8
- from motor.motor_asyncio import AsyncIOMotorCursor
9
-
10
  from Thunder.utils.database import db
11
- from Thunder.utils.logger import logger
12
  from Thunder.vars import Var
 
 
 
 
 
 
 
 
 
 
13
 
 
14
  async def check(user_id: int) -> bool:
15
- """
16
- Verify if a user has a valid token.
17
-
18
- Args:
19
- user_id: User's Telegram ID
20
-
21
- Returns:
22
- bool: True if user has valid token, False otherwise
23
- """
24
- try:
25
- # Check if token system is enabled
26
- if not getattr(Var, "TOKEN_ENABLED", False):
27
- return True # Token system disabled
28
-
29
- # Check if user is owner
30
- if isinstance(Var.OWNER_ID, (list, tuple, set)):
31
- is_owner = user_id in Var.OWNER_ID
32
- else:
33
- is_owner = user_id == Var.OWNER_ID
34
-
35
- # Check if user is owner or authorized
36
- if is_owner or await allowed(user_id):
37
- return True
38
-
39
- # Check for valid token
40
- token_data = await db.token_col.find_one({"user_id": user_id})
41
- if not token_data:
42
- return False
43
-
44
- # Check if token is expired
45
- if token_data["expires_at"] <= datetime.utcnow():
46
- return False
47
-
48
  return True
49
- except Exception as e:
50
- logger.error(f"Error in token check for user {user_id}: {e}")
51
- return False
 
 
 
 
 
 
 
 
 
 
 
52
 
 
53
  async def generate(user_id: int) -> Dict[str, Any]:
54
- """
55
- Generate a new token for a user.
56
-
57
- Args:
58
- user_id: User's Telegram ID
59
-
60
- Returns:
61
- dict: Token data with expiration time
62
- """
63
- try:
64
- duration = getattr(Var, "TOKEN_TTL_HOURS", 24)
65
- token = str(uuid.uuid4())
66
- created_at = datetime.utcnow()
67
- expires_at = created_at + timedelta(hours=duration)
68
-
69
- token_data = {
70
- "user_id": user_id,
71
- "token": token,
72
- "created_at": created_at,
73
- "expires_at": expires_at
74
- }
75
-
76
- # Store or update token in database
77
- await db.token_col.update_one(
78
- {"user_id": user_id},
79
- {"$set": token_data},
80
- upsert=True
81
- )
82
-
83
- return token_data
84
- except Exception as e:
85
- logger.error(f"Failed to generate token for user {user_id}: {e}")
86
- raise
87
 
 
88
  async def allowed(user_id: int) -> bool:
89
- """
90
- Check if a user is authorized (permanently allowed).
91
-
92
- Args:
93
- user_id: User's Telegram ID
94
-
95
- Returns:
96
- bool: True if user is authorized, False otherwise
97
- """
98
- try:
99
- auth_data = await db.authorized_users_col.find_one({"user_id": user_id})
100
- return bool(auth_data)
101
- except Exception as e:
102
- logger.error(f"Error checking authorization for user {user_id}: {e}")
103
- return False
104
 
 
105
  async def authorize(user_id: int, authorized_by: int) -> bool:
106
- """
107
- Authorize a user to use the bot permanently.
108
-
109
- Args:
110
- user_id: User's Telegram ID to authorize
111
- authorized_by: Admin's Telegram ID who authorized
112
-
113
- Returns:
114
- bool: True if successful, False otherwise
115
- """
116
- try:
117
- auth_data = {
118
- "user_id": user_id,
119
- "authorized_by": authorized_by,
120
- "authorized_at": datetime.utcnow()
121
- }
122
-
123
- await db.authorized_users_col.update_one(
124
- {"user_id": user_id},
125
- {"$set": auth_data},
126
- upsert=True
127
- )
128
-
129
- return True
130
- except Exception as e:
131
- logger.error(f"Failed to authorize user {user_id}: {e}")
132
- return False
133
 
 
134
  async def deauthorize(user_id: int) -> bool:
135
- """
136
- Remove a user's authorization.
137
-
138
- Args:
139
- user_id: User's Telegram ID to deauthorize
140
-
141
- Returns:
142
- bool: True if successful, False otherwise
143
- """
144
- try:
145
- result = await db.authorized_users_col.delete_one({"user_id": user_id})
146
- return result.deleted_count > 0
147
- except Exception as e:
148
- logger.error(f"Failed to deauthorize user {user_id}: {e}")
149
- return False
150
 
 
151
  async def get_user(user_id: int) -> Optional[Dict[str, Any]]:
152
- """
153
- Get information about a user's token.
154
-
155
- Args:
156
- user_id: User's Telegram ID
157
-
158
- Returns:
159
- dict or None: Token data if found, None otherwise
160
- """
161
- try:
162
- return await db.token_col.find_one({"user_id": user_id})
163
- except Exception as e:
164
- logger.error(f"Failed to get token data for user {user_id}: {e}")
165
- return None
166
 
 
167
  async def get(token: str) -> Optional[Dict[str, Any]]:
168
- """
169
- Retrieve token record by token string.
170
-
171
- Args:
172
- token: Token string to look up
173
-
174
- Returns:
175
- dict or None: Token data if found, None otherwise
176
- """
177
- try:
178
- return await db.token_col.find_one({"token": token})
179
- except Exception as e:
180
- logger.error(f"Failed to get token data: {e}")
181
- return None
182
 
 
183
  async def list_allowed() -> List[Dict[str, Any]]:
184
- """
185
- Get all authorized users.
186
-
187
- Returns:
188
- List[Dict[str, Any]]: List of authorized users data
189
- """
190
- try:
191
- cursor = db.authorized_users_col.find({})
192
- return await cursor.to_list(length=None)
193
- except Exception as e:
194
- logger.error(f"Failed to list authorized users: {e}")
195
- return []
196
 
 
197
  async def list_tokens() -> List[Dict[str, Any]]:
198
- """
199
- Get all active tokens.
200
-
201
- Returns:
202
- List[Dict[str, Any]]: List of active tokens data
203
- """
204
- try:
205
- cursor = db.token_col.find({})
206
- return await cursor.to_list(length=None)
207
- except Exception as e:
208
- logger.error(f"Failed to list tokens: {e}")
209
- return []
210
 
 
211
  async def validate_activation_token(token: str) -> Dict[str, Any]:
212
- """
213
- Validates an activation token and returns validation status.
214
-
215
- Args:
216
- token: The token string to validate
217
-
218
- Returns:
219
- dict: Validation result with status and details
220
- """
221
- try:
222
- # Remove 'token' prefix if present
223
- clean_token = token
224
- if token.startswith("token"):
225
- # Handle different possible separators
226
- if len(token) > 5 and token[5] in ('-', '_'):
227
- clean_token = token[6:] # Skip prefix and separator
228
- else:
229
- clean_token = token[5:] # Skip just the prefix
230
-
231
- # Check if token exists in database
232
- token_record = await get(clean_token)
233
- if not token_record:
234
- return {
235
- "valid": False,
236
- "reason": "Token does not exist or has been revoked",
237
- "token": clean_token
238
- }
239
-
240
- # Check if token is expired
241
- if token_record["expires_at"] <= datetime.utcnow():
242
- return {
243
- "valid": False,
244
- "reason": "Token has expired",
245
- "token": clean_token,
246
- "expiry_date": token_record["expires_at"].strftime("%Y-%m-%d %H:%M:%S UTC")
247
- }
248
-
249
- # Token is valid
250
  return {
251
- "valid": True,
252
- "token": clean_token,
253
- "user_id": token_record["user_id"],
254
- "expiry_date": token_record["expires_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
255
- "created_at": token_record["created_at"].strftime("%Y-%m-%d %H:%M:%S UTC"),
256
- "description": "Access token"
257
  }
258
-
259
- except Exception as e:
260
- logger.error(f"Error validating token: {e}")
261
  return {
262
  "valid": False,
263
- "reason": f"An error occurred during validation: {str(e)}",
264
- "token": token
 
265
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- """Convert space-separated string to list of integers."""
20
- if not val:
21
- return []
22
- return [int(x) for x in val.split() if x.isdigit()]
23
 
24
  def str_to_int_set(val: str) -> Set[int]:
25
- """Convert space-separated string to set of integers."""
26
- if not val:
27
- return set()
28
- return set(int(x) for x in val.split() if x.isdigit())
29
 
30
  class Var:
31
- """Configuration variables for the Thunder bot."""
32
 
33
  # Telegram API credentials
34
  API_ID: int = int(os.getenv("API_ID", ""))
35
- if not API_ID:
36
- logger.critical("CRITICAL: API_ID is not configured in config.env!")
37
- raise ValueError("CRITICAL: API_ID is not configured in config.env!")
38
-
39
  API_HASH: str = os.getenv("API_HASH", "")
40
- if not API_HASH:
41
- logger.critical("CRITICAL: API_HASH is not configured in config.env!")
42
- raise ValueError("CRITICAL: API_HASH is not configured in config.env!")
43
-
44
- # Bot token and identity
45
  BOT_TOKEN: str = os.getenv("BOT_TOKEN", "")
46
- if not BOT_TOKEN:
47
- logger.critical("CRITICAL: BOT_TOKEN is not configured in config.env!")
48
- raise ValueError("CRITICAL: BOT_TOKEN is not configured in config.env!")
49
- NAME: str = os.getenv("NAME", "ThunderF2L")
50
 
51
- # Performance settings
52
- SLEEP_THRESHOLD: int = int(os.getenv("SLEEP_THRESHOLD", "60"))
 
 
 
 
 
53
  WORKERS: int = int(os.getenv("WORKERS", "100"))
54
- TIMEOUT: int = int(os.getenv("TIMEOUT", "30"))
55
 
56
  # Channel for file storage
57
  BIN_CHANNEL: int = int(os.getenv("BIN_CHANNEL", "0"))
58
  if not BIN_CHANNEL:
59
- logger.critical("CRITICAL: BIN_CHANNEL is not configured in config.env!")
60
- raise ValueError("CRITICAL: BIN_CHANNEL is not configured in config.env!")
61
 
62
  # Web server configuration
63
  PORT: int = int(os.getenv("PORT", "8080"))
@@ -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("CRITICAL: DATABASE_URL is not configured in config.env!")
86
- raise ValueError("CRITICAL: DATABASE_URL is not configured in config.env!")
87
 
88
  # Channel configurations
89
  BANNED_CHANNELS: Set[int] = str_to_int_set(os.getenv("BANNED_CHANNELS", ""))
 
 
 
 
 
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", ""))