Spaces:
Running
Running
Copilot
Harden /listauth display names with safe lookup and Markdown escaping (#47)
8854bde unverified | # Thunder/bot/plugins/admin.py | |
| import asyncio | |
| import html | |
| import os | |
| import shutil | |
| import sys | |
| import time | |
| from io import BytesIO | |
| import psutil | |
| from pyrogram import filters | |
| from pyrogram.client import Client | |
| from pyrogram.enums import ParseMode | |
| from pyrogram.errors import FloodWait, MessageNotModified | |
| from pyrogram.types import InlineKeyboardButton, InlineKeyboardMarkup, Message | |
| from Thunder import StartTime, __version__ | |
| from Thunder.bot import StreamBot, multi_clients, work_loads | |
| from Thunder.utils.bot_utils import get_user, reply | |
| from Thunder.utils.broadcast import broadcast_message | |
| from Thunder.utils.database import db | |
| from Thunder.utils.human_readable import humanbytes | |
| from Thunder.utils.logger import LOG_FILE, logger | |
| from Thunder.utils.messages import ( | |
| MSG_ADMIN_AUTH_LIST_HEADER, MSG_ADMIN_NO_BAN_REASON, | |
| MSG_ADMIN_USER_BANNED, MSG_ADMIN_USER_UNBANNED, MSG_AUTHORIZE_FAILED, | |
| MSG_AUTHORIZE_SUCCESS, MSG_AUTHORIZE_USAGE, MSG_AUTH_USER_INFO, | |
| MSG_BAN_REASON_SUFFIX, MSG_BAN_USAGE, MSG_BROADCAST_USAGE, | |
| MSG_BUTTON_CLOSE, MSG_CANNOT_BAN_OWNER, MSG_CHANNEL_BANNED, | |
| MSG_CHANNEL_BANNED_REASON_SUFFIX, MSG_CHANNEL_NOT_BANNED, | |
| MSG_CHANNEL_UNBANNED, MSG_DB_ERROR, MSG_DB_STATS, | |
| MSG_DEAUTHORIZE_FAILED, MSG_DEAUTHORIZE_SUCCESS, | |
| MSG_DEAUTHORIZE_USAGE, MSG_ERROR_GENERIC, MSG_INVALID_BROADCAST_CMD, | |
| MSG_INVALID_USER_ID, MSG_LOG_FILE_CAPTION, MSG_LOG_FILE_EMPTY, | |
| MSG_LOG_FILE_MISSING, MSG_NO_AUTH_USERS, MSG_RESTARTING, MSG_SHELL_ERROR, | |
| MSG_SHELL_EXECUTING, MSG_SHELL_NO_OUTPUT, MSG_SHELL_OUTPUT, | |
| MSG_SHELL_OUTPUT_STDERR, MSG_SHELL_OUTPUT_STDOUT, MSG_SHELL_USAGE, | |
| MSG_SPEEDTEST_ERROR, MSG_SPEEDTEST_INIT, MSG_SPEEDTEST_RESULT, | |
| MSG_STATUS_ERROR, MSG_SYSTEM_STATS, MSG_SYSTEM_STATUS, | |
| MSG_UNBAN_USAGE, MSG_USER_BANNED_NOTIFICATION, | |
| MSG_USER_NOT_IN_BAN_LIST, MSG_USER_UNBANNED_NOTIFICATION, | |
| MSG_WORKLOAD_ITEM | |
| ) | |
| from Thunder.utils.time_format import get_readable_time | |
| from Thunder.utils.tokens import authorize, deauthorize, list_allowed | |
| from Thunder.utils.speedtest import run_speedtest | |
| from Thunder.vars import Var | |
| owner_filter = filters.private & filters.user(Var.OWNER_ID) | |
| _MARKDOWN_ESCAPE_TRANS = str.maketrans({ | |
| "\\": "\\\\", | |
| "_": "\\_", | |
| "*": "\\*", | |
| "[": "\\[", | |
| "]": "\\]", | |
| "`": "\\`", | |
| }) | |
| def _escape_markdown(text: str) -> str: | |
| return text.translate(_MARKDOWN_ESCAPE_TRANS) | |
| async def get_total_users(client: Client, message: Message): | |
| try: | |
| total = await db.total_users_count() | |
| await reply(message, | |
| text=MSG_DB_STATS.format(total_users=total), | |
| parse_mode=ParseMode.MARKDOWN, | |
| reply_markup=InlineKeyboardMarkup( | |
| [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) | |
| except Exception as e: | |
| logger.error(f"Error in get_total_users: {e}", exc_info=True) | |
| await reply(message, text=MSG_DB_ERROR) | |
| async def broadcast_handler(client: Client, message: Message): | |
| mode = "all" | |
| if len(message.command) > 1: | |
| arg = message.command[1].lower().strip() | |
| if arg in ("help", "--help", "-h"): | |
| return await reply(message, text=MSG_BROADCAST_USAGE, parse_mode=ParseMode.MARKDOWN) | |
| if arg == "authorized": | |
| mode = "authorized" | |
| elif arg == "regular": | |
| mode = "regular" | |
| else: | |
| safe_arg = arg.replace("`", "'") | |
| await reply( | |
| message, | |
| text=f"❌ **Invalid argument:** `{safe_arg}`\n\n{MSG_BROADCAST_USAGE}", | |
| parse_mode=ParseMode.MARKDOWN | |
| ) | |
| return | |
| if not message.reply_to_message: | |
| return await reply(message, text=MSG_INVALID_BROADCAST_CMD) | |
| await broadcast_message(client, message, mode=mode) | |
| async def show_status(client: Client, message: Message): | |
| try: | |
| uptime_str = get_readable_time(int(time.time() - StartTime)) | |
| workload_items = "" | |
| sorted_workloads = sorted(work_loads.items(), key=lambda item: item[0]) | |
| for client_id, load_val in sorted_workloads: | |
| workload_items += MSG_WORKLOAD_ITEM.format( | |
| bot_name=f"🔹 Client {client_id}", load=load_val) | |
| total_workload = sum(work_loads.values()) | |
| status_text_str = MSG_SYSTEM_STATUS.format( | |
| uptime=uptime_str, active_bots=len(multi_clients), | |
| total_workload=total_workload, workload_items=workload_items, | |
| version=__version__) | |
| await reply(message, | |
| text=status_text_str, | |
| parse_mode=ParseMode.MARKDOWN, | |
| reply_markup=InlineKeyboardMarkup( | |
| [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) | |
| except Exception as e: | |
| logger.error(f"Error in show_status: {e}", exc_info=True) | |
| await reply(message, text=MSG_STATUS_ERROR) | |
| async def show_stats(client: Client, message: Message): | |
| try: | |
| sys_uptime = await asyncio.to_thread(psutil.boot_time) | |
| sys_uptime_str = get_readable_time(int(time.time() - sys_uptime)) | |
| bot_uptime_str = get_readable_time(int(time.time() - StartTime)) | |
| net_io_counters = await asyncio.to_thread(psutil.net_io_counters) | |
| cpu_percent = await asyncio.to_thread(psutil.cpu_percent, interval=0.5) | |
| cpu_cores = await asyncio.to_thread(psutil.cpu_count, logical=False) | |
| cpu_freq = await asyncio.to_thread(psutil.cpu_freq) | |
| cpu_freq_ghz = f"{cpu_freq.current / 1000:.2f}" if cpu_freq else "N/A" | |
| ram_info = await asyncio.to_thread(psutil.virtual_memory) | |
| ram_total = humanbytes(ram_info.total) | |
| ram_used = humanbytes(ram_info.used) | |
| ram_free = humanbytes(ram_info.free) | |
| total_disk, used_disk, free_disk = await asyncio.to_thread( | |
| shutil.disk_usage, '.') | |
| stats_text_val = MSG_SYSTEM_STATS.format( | |
| sys_uptime=sys_uptime_str, | |
| bot_uptime=bot_uptime_str, | |
| cpu_percent=cpu_percent, | |
| cpu_cores=cpu_cores, | |
| cpu_freq=cpu_freq_ghz, | |
| ram_total=ram_total, | |
| ram_used=ram_used, | |
| ram_free=ram_free, | |
| disk_percent=psutil.disk_usage('.').percent, | |
| total=humanbytes(total_disk), | |
| used=humanbytes(used_disk), | |
| free=humanbytes(free_disk), | |
| upload=humanbytes(net_io_counters.bytes_sent), | |
| download=humanbytes(net_io_counters.bytes_recv) | |
| ) | |
| await reply(message, | |
| text=stats_text_val, | |
| parse_mode=ParseMode.MARKDOWN, | |
| reply_markup=InlineKeyboardMarkup( | |
| [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) | |
| except Exception as e: | |
| logger.error(f"Error in show_stats: {e}", exc_info=True) | |
| await reply(message, text=MSG_STATUS_ERROR) | |
| async def restart_bot(client: Client, message: Message): | |
| msg = await reply(message, text=MSG_RESTARTING) | |
| await db.add_restart_message(msg.id, message.chat.id) | |
| os.execv("/bin/bash", ["bash", "thunder.sh"]) | |
| async def send_logs(client: Client, message: Message): | |
| if not os.path.exists(LOG_FILE) or os.path.getsize(LOG_FILE) == 0: | |
| await reply( | |
| message, | |
| text=(MSG_LOG_FILE_MISSING if not os.path.exists(LOG_FILE) else MSG_LOG_FILE_EMPTY) | |
| ) | |
| return | |
| try: | |
| try: | |
| await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in log file sending, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await message.reply_document(LOG_FILE, caption=MSG_LOG_FILE_CAPTION) | |
| except Exception as e: | |
| logger.error(f"Error sending log file: {e}", exc_info=True) | |
| await reply(message, text=MSG_ERROR_GENERIC) | |
| async def authorize_command(client: Client, message: Message): | |
| if len(message.command) != 2: | |
| return await reply( | |
| message, text=MSG_AUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) | |
| try: | |
| user_id = int(message.command[1]) | |
| success = await authorize(user_id, message.from_user.id) | |
| await reply(message, | |
| text=((MSG_AUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_AUTHORIZE_FAILED.format(user_id=user_id)))) | |
| except ValueError: | |
| await reply(message, text=MSG_INVALID_USER_ID) | |
| except Exception as e: | |
| logger.error(f"Error in authorize_command: {e}", exc_info=True) | |
| await reply(message, text=MSG_ERROR_GENERIC) | |
| async def deauthorize_command(client: Client, message: Message): | |
| if len(message.command) != 2: | |
| return await reply( | |
| message, text=MSG_DEAUTHORIZE_USAGE, parse_mode=ParseMode.MARKDOWN) | |
| try: | |
| user_id = int(message.command[1]) | |
| success = await deauthorize(user_id) | |
| await reply(message, | |
| text=((MSG_DEAUTHORIZE_SUCCESS.format(user_id=user_id) if success else MSG_DEAUTHORIZE_FAILED.format(user_id=user_id)))) | |
| except ValueError: | |
| await reply(message, text=MSG_INVALID_USER_ID) | |
| except Exception as e: | |
| logger.error(f"Error in deauthorize_command: {e}", exc_info=True) | |
| await reply(message, text=MSG_ERROR_GENERIC) | |
| async def list_authorized_command(client: Client, message: Message): | |
| users = await list_allowed() | |
| if not users: | |
| return await reply( | |
| message, text=MSG_NO_AUTH_USERS) | |
| text = MSG_ADMIN_AUTH_LIST_HEADER | |
| for i, user in enumerate(users, 1): | |
| display_name = "Unknown" | |
| try: | |
| tg_user = await get_user(client, user['user_id']) | |
| if tg_user is not None: | |
| raw_display_name = f"@{tg_user.username}" if tg_user.username else tg_user.first_name or "Unknown" | |
| display_name = _escape_markdown(raw_display_name) | |
| except Exception: | |
| logger.error("Failed to fetch tg_user for user_id=%s", user['user_id'], exc_info=True) | |
| text += MSG_AUTH_USER_INFO.format( | |
| i=i, | |
| display_name=display_name, | |
| user_id=user['user_id'], | |
| authorized_by=user['authorized_by'], | |
| auth_time=user['authorized_at'] | |
| ) | |
| await reply(message, | |
| text=text, | |
| parse_mode=ParseMode.MARKDOWN, | |
| reply_markup=InlineKeyboardMarkup( | |
| [[InlineKeyboardButton(MSG_BUTTON_CLOSE, callback_data="close_panel")]])) | |
| async def ban_command(client: Client, message: Message): | |
| if len(message.command) < 2: | |
| return await reply(message, text=MSG_BAN_USAGE) | |
| try: | |
| target_id = int(message.command[1]) | |
| reason = " ".join(message.command[2:]) or MSG_ADMIN_NO_BAN_REASON | |
| banned_by_id = message.from_user.id if message.from_user else None | |
| if target_id == Var.OWNER_ID: | |
| return await reply(message, text=MSG_CANNOT_BAN_OWNER) | |
| if target_id < 0: | |
| await db.add_banned_channel( | |
| channel_id=target_id, | |
| reason=reason, | |
| banned_by=banned_by_id | |
| ) | |
| text = MSG_CHANNEL_BANNED.format(channel_id=target_id) | |
| if reason != MSG_ADMIN_NO_BAN_REASON: | |
| text += MSG_CHANNEL_BANNED_REASON_SUFFIX.format(reason=reason) | |
| await reply(message, text=text) | |
| try: | |
| try: | |
| await client.leave_chat(target_id) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in leave_chat, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await client.leave_chat(target_id) | |
| except Exception as e: | |
| logger.warning(f"Could not leave banned channel {target_id}: {e}", exc_info=True) | |
| else: | |
| await db.add_banned_user( | |
| user_id=target_id, | |
| reason=reason, | |
| banned_by=banned_by_id | |
| ) | |
| text = MSG_ADMIN_USER_BANNED.format(user_id=target_id) | |
| if reason != MSG_ADMIN_NO_BAN_REASON: | |
| text += MSG_BAN_REASON_SUFFIX.format(reason=reason) | |
| await reply(message, text=text) | |
| try: | |
| try: | |
| await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in ban notification, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await client.send_message(target_id, MSG_USER_BANNED_NOTIFICATION) | |
| except Exception as e: | |
| logger.warning(f"Could not notify banned user {target_id}: {e}", exc_info=True) | |
| except ValueError: | |
| await reply(message, text=MSG_INVALID_USER_ID) | |
| except Exception as e: | |
| logger.error(f"Error in ban_command: {e}", exc_info=True) | |
| await reply(message, text=MSG_ERROR_GENERIC) | |
| async def unban_command(client: Client, message: Message): | |
| if len(message.command) != 2: | |
| return await reply(message, text=MSG_UNBAN_USAGE) | |
| try: | |
| target_id = int(message.command[1]) | |
| if target_id < 0: | |
| if await db.remove_banned_channel(channel_id=target_id): | |
| await reply(message, text=MSG_CHANNEL_UNBANNED.format(channel_id=target_id)) | |
| else: | |
| await reply(message, text=MSG_CHANNEL_NOT_BANNED.format(channel_id=target_id)) | |
| else: | |
| if await db.remove_banned_user(user_id=target_id): | |
| await reply(message, text=MSG_ADMIN_USER_UNBANNED.format(user_id=target_id)) | |
| try: | |
| try: | |
| await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in unban notification, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await client.send_message(target_id, MSG_USER_UNBANNED_NOTIFICATION) | |
| except Exception as e: | |
| logger.warning(f"Could not notify unbanned user {target_id}: {e}", exc_info=True) | |
| else: | |
| await reply(message, text=MSG_USER_NOT_IN_BAN_LIST.format(user_id=target_id)) | |
| except ValueError: | |
| await reply(message, text=MSG_INVALID_USER_ID) | |
| except Exception as e: | |
| logger.error(f"Error in unban_command: {e}", exc_info=True) | |
| await reply(message, text=MSG_ERROR_GENERIC) | |
| async def run_shell_command(client: Client, message: Message): | |
| if len(message.command) < 2: | |
| return await reply( | |
| message, text=MSG_SHELL_USAGE, parse_mode=ParseMode.HTML) | |
| command = " ".join(message.command[1:]) | |
| status_msg = await reply(message, | |
| text=MSG_SHELL_EXECUTING.format( | |
| command=html.escape(command)), | |
| parse_mode=ParseMode.HTML) | |
| try: | |
| process = await asyncio.create_subprocess_shell( | |
| command, | |
| stdout=asyncio.subprocess.PIPE, | |
| stderr=asyncio.subprocess.PIPE | |
| ) | |
| stdout, stderr = await process.communicate() | |
| output = "" | |
| if stdout: | |
| output += MSG_SHELL_OUTPUT_STDOUT.format( | |
| output=html.escape(stdout.decode(errors='ignore'))) | |
| if stderr: | |
| output += MSG_SHELL_OUTPUT_STDERR.format( | |
| error=html.escape(stderr.decode(errors='ignore'))) | |
| output = output.strip() or MSG_SHELL_NO_OUTPUT | |
| try: | |
| await status_msg.delete() | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in shell status message delete, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await status_msg.delete() | |
| if len(output) > 4096: | |
| file = BytesIO(output.encode()) | |
| file.name = "shell_output.txt" | |
| try: | |
| await message.reply_document( | |
| file, | |
| caption=MSG_SHELL_OUTPUT.format( | |
| command=html.escape(command))) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in shell output document, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await message.reply_document( | |
| file, | |
| caption=MSG_SHELL_OUTPUT.format( | |
| command=html.escape(command))) | |
| else: | |
| await reply(message, text=output, parse_mode=ParseMode.HTML) | |
| except Exception as e: | |
| try: | |
| try: | |
| await status_msg.edit_text( | |
| MSG_SHELL_ERROR.format(error=html.escape(str(e))), | |
| parse_mode=ParseMode.HTML) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in shell error message edit, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await status_msg.edit_text( | |
| MSG_SHELL_ERROR.format(error=html.escape(str(e))), | |
| parse_mode=ParseMode.HTML) | |
| except MessageNotModified: | |
| pass | |
| except Exception: | |
| await reply( | |
| message, | |
| text=MSG_SHELL_ERROR.format(error=html.escape(str(e))), | |
| parse_mode=ParseMode.HTML) | |
| async def speedtest_command(client: Client, message: Message): | |
| status_msg = await reply(message, text=MSG_SPEEDTEST_INIT) | |
| try: | |
| result_dict, image_url = await run_speedtest() | |
| if result_dict is None: | |
| try: | |
| await status_msg.edit_text(MSG_SPEEDTEST_ERROR) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in speedtest error edit, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await status_msg.edit_text(MSG_SPEEDTEST_ERROR) | |
| except MessageNotModified: | |
| pass | |
| return | |
| result_text = _format_speedtest_result(result_dict) | |
| await _send_result(message, status_msg, result_text, image_url) | |
| except Exception as e: | |
| logger.error(f"Error in speedtest_command: {e}", exc_info=True) | |
| try: | |
| try: | |
| await status_msg.edit_text(MSG_SPEEDTEST_ERROR) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in speedtest exception error edit, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await status_msg.edit_text(MSG_SPEEDTEST_ERROR) | |
| except MessageNotModified: | |
| pass | |
| except Exception: | |
| await reply(message, text=MSG_SPEEDTEST_ERROR) | |
| def _format_speedtest_result(result_dict: dict) -> str: | |
| s, c = result_dict['server'], result_dict['client'] | |
| return MSG_SPEEDTEST_RESULT.format( | |
| download_mbps=_fmt(result_dict['download_mbps']), | |
| upload_mbps=_fmt(result_dict['upload_mbps']), | |
| download_bps=humanbytes(result_dict['download_bps']), | |
| upload_bps=humanbytes(result_dict['upload_bps']), | |
| ping=_fmt(result_dict['ping']), | |
| timestamp=result_dict['timestamp'], | |
| bytes_sent=humanbytes(result_dict['bytes_sent']), | |
| bytes_received=humanbytes(result_dict['bytes_received']), | |
| server_name=s['name'], | |
| server_country=f"{s['country']} ({s['cc']})", | |
| server_sponsor=s['sponsor'], | |
| server_latency=_fmt(s['latency']), | |
| server_lat=_fmt(s['lat'], 4), | |
| server_lon=_fmt(s['lon'], 4), | |
| client_ip=c['ip'], | |
| client_lat=_fmt(c['lat'], 4), | |
| client_lon=_fmt(c['lon'], 4), | |
| client_isp=c['isp'], | |
| client_isprating=c['isprating'], | |
| client_country=c['country'] | |
| ) | |
| async def _send_result(message: Message, status_msg: Message, result_text: str, image_url: str): | |
| if image_url: | |
| try: | |
| await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in speedtest photo reply, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await message.reply_photo(image_url, caption=result_text, parse_mode=ParseMode.MARKDOWN) | |
| try: | |
| await status_msg.delete() | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in speedtest status delete, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await status_msg.delete() | |
| else: | |
| try: | |
| await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) | |
| except FloodWait as e: | |
| logger.debug(f"FloodWait in speedtest result edit, sleeping for {e.value}s") | |
| await asyncio.sleep(e.value) | |
| await status_msg.edit_text(result_text, parse_mode=ParseMode.MARKDOWN) | |
| except MessageNotModified: | |
| pass | |
| def _fmt(value, decimals: int = 2) -> str: | |
| return f"{float(value):.{decimals}f}" | |