FileToLink / Thunder /bot /plugins /admin.py
Copilot
Harden /listauth display names with safe lookup and Markdown escaping (#47)
8854bde unverified
Raw
History Blame Contribute Delete
22.5 kB
# 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)
@StreamBot.on_message(filters.command("users") & owner_filter)
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)
@StreamBot.on_message(filters.command("broadcast") & owner_filter)
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)
@StreamBot.on_message(filters.command("status") & owner_filter)
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)
@StreamBot.on_message(filters.command("stats") & owner_filter)
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)
@StreamBot.on_message(filters.command("restart") & owner_filter)
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"])
@StreamBot.on_message(filters.command("log") & owner_filter)
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)
@StreamBot.on_message(filters.command("authorize") & owner_filter)
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)
@StreamBot.on_message(filters.command("deauthorize") & owner_filter)
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)
@StreamBot.on_message(filters.command("listauth") & owner_filter)
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")]]))
@StreamBot.on_message(filters.command("ban") & owner_filter)
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)
@StreamBot.on_message(filters.command("unban") & owner_filter)
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)
@StreamBot.on_message(filters.command("shell") & owner_filter)
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)
@StreamBot.on_message(filters.command("speedtest") & owner_filter)
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}"