Fyaz Mohammed commited on
Commit
275aad2
·
unverified ·
1 Parent(s): 29b5c59

Fixed token logic, refactored byte streamer, client selection and workload handling

Browse files

- **New Features**
- Added explicit token activation tracking and validation for improved access control.
- Enhanced error messages for invalid or expired activation links and token requirements.

- **Refactor**
- Simplified and improved performance of media streaming and file handling.
- Streamlined token generation and management with clearer activation logic.
- Optimised client selection and workload handling for streaming requests.
- Refined command handling with improved error logging and message parsing.

- **Bug Fixes**
- Improved error handling and user feedback for invalid commands and payloads.

- **Style**
- Updated user-facing messages for clarity and consistency.

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