Lacie/lilac-tools/admin.py

1391 lines
57 KiB
Python

import discord
from discord.ext import commands
import aiosqlite
import asyncio
import os
import glob
from typing import Optional
import traceback
import re
ADMIN_USER_ID = 252130669919076352
# Security configuration
BLOCKED_PATHS = [
'.env',
'.env.local',
'.env.production',
'config.json',
'secrets.json',
'credentials.json',
'.git',
'id_rsa',
'id_ed25519',
'.pem',
'.key',
'private_key',
'secret_key',
]
BLOCKED_PATTERNS = [
r'.*\.env.*',
r'.*secret.*',
r'.*password.*',
r'.*token.*',
r'.*api[_-]?key.*',
r'.*private[_-]?key.*',
r'.*\.pem$',
r'.*\.key$',
r'.*/\.ssh/.*',
r'.*/\.aws/.*',
]
BLOCKED_COMMANDS = [
'rm -rf',
'dd if=',
'mkfs',
'fdisk',
':(){ :|:& };:', # Fork bomb
'chmod 777',
'chmod -R 777',
]
def is_owner():
"""Check if the user is the bot owner"""
async def predicate(ctx):
return ctx.author.id == ADMIN_USER_ID
return commands.check(predicate)
def is_path_safe(path):
"""Check if a path is safe to access"""
# Normalize the path
normalized = os.path.normpath(path)
# Check against blocked paths
for blocked in BLOCKED_PATHS:
if blocked.lower() in normalized.lower():
return False
# Check against blocked patterns
for pattern in BLOCKED_PATTERNS:
if re.match(pattern, normalized.lower()):
return False
return True
def is_command_safe(command):
"""Check if a command is safe to execute"""
command_lower = command.lower()
for blocked in BLOCKED_COMMANDS:
if blocked.lower() in command_lower:
return False
return True
class AdminCommands(commands.Cog):
"""Admin-only commands for database queries and terminal access"""
def __init__(self, bot):
self.bot = bot
self.db_connections = {}
asyncio.create_task(self._discover_databases())
async def _discover_databases(self):
"""Discover all .db files in the project"""
loop = asyncio.get_event_loop()
def _glob():
all_dbs = glob.glob("**/*.db", recursive=True)
# Filter out backup directories
return [f for f in all_dbs if not any(x in f for x in ['backups/', 'old_backups/'])]
db_files = await loop.run_in_executor(None, _glob)
self.db_connections = {os.path.basename(f).replace('.db', ''): f for f in db_files}
print(f"Discovered databases: {list(self.db_connections.keys())}")
@commands.command(name="admin_help", aliases=["adminhelp", "ahelp"])
@is_owner()
async def admin_help(self, ctx, category: Optional[str] = None):
"""Show all admin commands. Usage: !admin_help [category]
Categories: code, db, terminal, files, dev"""
if category and category.lower() == "code":
embed = discord.Embed(
title="📝 Code Management Commands",
description="Commands for viewing and editing code files",
color=discord.Color.blue()
)
commands_list = [
("!code view `<file>`", "View file with syntax highlighting and line numbers"),
("!code search `<pattern>` `[path]`", "Search for code patterns across files (regex supported)"),
("!code find `<file>` `<text>`", "Find specific text in a file and show line numbers"),
("!code edit `<file>` `<line>` `[end]`", "Edit line(s) with interactive popup editor"),
("!code insert `<file>` `<line>` `<text>`", "Insert new line at position"),
("!code delete `<file>` `<line>`", "Delete specific line"),
("!code replace `<file>` `\"find\"` `\"replace\"` `[--all]`", "Find and replace text"),
("!code backup `<file>`", "Create timestamped backup"),
("!code diff `<file1>` `<file2>`", "Compare two files"),
]
for cmd, desc in commands_list:
embed.add_field(name=cmd, value=desc, inline=False)
embed.set_footer(text="Example: !code edit bot.py 42")
await ctx.send(embed=embed)
return
elif category and category.lower() == "db":
embed = discord.Embed(
title="🗄️ Database Commands",
description="Commands for querying and managing SQLite databases",
color=discord.Color.green()
)
commands_list = [
("!dblist", "List all discovered .db files"),
("!dbtables `[db_name]`", "List all tables in a database"),
("!dbinfo `<db>` `<table>`", "Show table schema and column info"),
("!dbquery `<db>` `<sql>`", "Execute SELECT query (read-only)"),
("!dbexec `<db>` `<sql>`", "Execute UPDATE/DELETE/INSERT (write operations)"),
]
for cmd, desc in commands_list:
embed.add_field(name=cmd, value=desc, inline=False)
embed.set_footer(text="Example: !dbquery mydb SELECT * FROM users LIMIT 10")
await ctx.send(embed=embed)
return
elif category and category.lower() == "terminal":
embed = discord.Embed(
title="🖥️ Terminal Commands",
description="Commands for executing shell commands",
color=discord.Color.red()
)
commands_list = [
("!terminal `<command>`", "Execute shell command (30s timeout, blocks dangerous commands)"),
("!term `<command>`", "Alias for !terminal"),
("!sh `<command>`", "Alias for !terminal"),
]
for cmd, desc in commands_list:
embed.add_field(name=cmd, value=desc, inline=False)
embed.add_field(name="⚠️ Blocked Commands", value="rm -rf, dd, mkfs, fdisk, fork bombs, chmod 777", inline=False)
embed.set_footer(text="Example: !terminal git status")
await ctx.send(embed=embed)
return
elif category and category.lower() == "files":
embed = discord.Embed(
title="📁 File System Commands",
description="Commands for navigating and viewing files",
color=discord.Color.purple()
)
commands_list = [
("!pwd", "Show current working directory"),
("!ls `[path]`", "List files and directories"),
]
for cmd, desc in commands_list:
embed.add_field(name=cmd, value=desc, inline=False)
embed.set_footer(text="Example: !ls cogs/")
await ctx.send(embed=embed)
return
elif category and category.lower() == "dev":
embed = discord.Embed(
title="🔧 Development Commands",
description="Commands for testing and debugging",
color=discord.Color.orange()
)
commands_list = [
("!eval `<code>`", "Evaluate Python code with bot context"),
]
for cmd, desc in commands_list:
embed.add_field(name=cmd, value=desc, inline=False)
embed.add_field(name="Available in eval context", value="`bot`, `ctx`, `discord`, `commands`, `asyncio`, `os`, `aiosqlite`", inline=False)
embed.set_footer(text="Example: !eval await ctx.send('Hello!')")
await ctx.send(embed=embed)
return
# Main help menu
embed = discord.Embed(
title="🛡️ Admin Commands Help",
description="All commands available to the bot owner",
color=discord.Color.gold()
)
categories = [
("📝 Code Management", "`!admin_help code`", "View, edit, search, and manage code files"),
("🗄️ Database", "`!admin_help db`", "Query and manage SQLite databases"),
("🖥️ Terminal", "`!admin_help terminal`", "Execute shell commands"),
("📁 File System", "`!admin_help files`", "Navigate and view files"),
("🔧 Development", "`!admin_help dev`", "Testing and debugging tools"),
]
for title, command, description in categories:
embed.add_field(name=f"{title}", value=f"{description}\nUse {command} for details", inline=False)
embed.add_field(
name="🔒 Security Features",
value="• Blocks access to .env, secrets, keys\n• Prevents dangerous commands\n• File size limits (1MB)\n• Command timeouts (30s)",
inline=False
)
embed.set_footer(text=f"Bot Owner: {ADMIN_USER_ID} • All commands require owner permissions")
await ctx.send(embed=embed)
async def _format_results(self, results, description):
"""Format SQL results into a readable string"""
if not results:
return "No results found."
loop = asyncio.get_event_loop()
def _format():
# Get column names
cols = [desc[0] for desc in description]
# Calculate column widths
widths = [len(col) for col in cols]
for row in results:
for i, val in enumerate(row):
widths[i] = max(widths[i], len(str(val)))
# Build table
lines = []
header = " | ".join(col.ljust(widths[i]) for i, col in enumerate(cols))
lines.append(header)
lines.append("-" * len(header))
for row in results:
line = " | ".join(str(val).ljust(widths[i]) for i, val in enumerate(row))
lines.append(line)
return "\n".join(lines)
return await loop.run_in_executor(None, _format)
@commands.command(name="dblist")
@is_owner()
async def db_list(self, ctx):
"""List all available databases"""
await self._discover_databases()
if not self.db_connections:
await ctx.send("❌ No databases found!")
return
# Split databases into chunks to avoid embed size limit
db_items = sorted(self.db_connections.items())
chunk_size = 20 # 20 databases per page
total_dbs = len(db_items)
total_pages = (total_dbs + chunk_size - 1) // chunk_size # Ceiling division
if total_pages == 1:
# Just send one embed if it fits
db_list = "\n".join(f"• **{name}**: `{path}`" for name, path in db_items)
embed = discord.Embed(
title=f"📊 Available Databases ({total_dbs} total)",
description=db_list,
color=discord.Color.blue()
)
await ctx.send(embed=embed)
return
# Multi-page view
current_page = 0
def get_page_embed(page):
start = page * chunk_size
end = min(start + chunk_size, total_dbs)
chunk = db_items[start:end]
db_list = "\n".join(f"• **{name}**: `{path}`" for name, path in chunk)
embed = discord.Embed(
title=f"📊 Available Databases",
description=db_list,
color=discord.Color.blue()
)
embed.set_footer(text=f"Page {page + 1}/{total_pages}{total_dbs} databases total")
return embed
# Create view with buttons
view = discord.ui.View(timeout=180)
async def update_message(interaction, new_page):
nonlocal current_page
current_page = new_page
# Update button states
first_button.disabled = (current_page == 0)
prev_button.disabled = (current_page == 0)
next_button.disabled = (current_page == total_pages - 1)
last_button.disabled = (current_page == total_pages - 1)
await interaction.response.edit_message(embed=get_page_embed(current_page), view=view)
# Button callbacks
async def first_callback(interaction):
await update_message(interaction, 0)
async def prev_callback(interaction):
await update_message(interaction, max(0, current_page - 1))
async def next_callback(interaction):
await update_message(interaction, min(total_pages - 1, current_page + 1))
async def last_callback(interaction):
await update_message(interaction, total_pages - 1)
# Create buttons
first_button = discord.ui.Button(label="⏮️", style=discord.ButtonStyle.gray, disabled=True)
first_button.callback = first_callback
prev_button = discord.ui.Button(label="◀️", style=discord.ButtonStyle.primary, disabled=True)
prev_button.callback = prev_callback
next_button = discord.ui.Button(label="▶️", style=discord.ButtonStyle.primary)
next_button.callback = next_callback
last_button = discord.ui.Button(label="⏭️", style=discord.ButtonStyle.gray)
last_button.callback = last_callback
view.add_item(first_button)
view.add_item(prev_button)
view.add_item(next_button)
view.add_item(last_button)
await ctx.send(embed=get_page_embed(current_page), view=view)
@commands.command(name="dbtables")
@is_owner()
async def db_tables(self, ctx, db_name: Optional[str] = None):
"""List all tables in a database. Usage: !dbtables [db_name]"""
if db_name is None:
if len(self.db_connections) == 1:
db_name = list(self.db_connections.keys())[0]
else:
await ctx.send("❌ Please specify a database name. Use `!dblist` to see available databases.")
return
if db_name not in self.db_connections:
await ctx.send(f"❌ Database '{db_name}' not found. Use `!dblist` to see available databases.")
return
try:
async with aiosqlite.connect(self.db_connections[db_name]) as conn:
async with conn.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name;") as cur:
tables = await cur.fetchall()
if not tables:
await ctx.send(f"No tables found in database '{db_name}'")
return
table_list = "\n".join(f"• `{table[0]}`" for table in tables)
embed = discord.Embed(
title=f"📋 Tables in {db_name}",
description=table_list,
color=discord.Color.green()
)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@commands.command(name="dbinfo")
@is_owner()
async def db_info(self, ctx, db_name: str, table_name: str):
"""Get schema info for a table. Usage: !dbinfo <db_name> <table_name>"""
if db_name not in self.db_connections:
await ctx.send(f"❌ Database '{db_name}' not found. Use `!dblist` to see available databases.")
return
try:
async with aiosqlite.connect(self.db_connections[db_name]) as conn:
async with conn.execute(f"PRAGMA table_info({table_name});") as cur:
info = await cur.fetchall()
if not info:
await ctx.send(f"❌ Table '{table_name}' not found in database '{db_name}'")
return
# Format schema info
schema = []
for col in info:
col_id, name, dtype, not_null, default, pk = col
parts = [f"`{name}`", dtype]
if pk:
parts.append("PRIMARY KEY")
if not_null:
parts.append("NOT NULL")
if default is not None:
parts.append(f"DEFAULT {default}")
schema.append(" ".join(parts))
embed = discord.Embed(
title=f"🔍 Schema: {db_name}.{table_name}",
description="\n".join(schema),
color=discord.Color.purple()
)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@commands.command(name="dbquery")
@is_owner()
async def db_query(self, ctx, db_name: str, *, query: str):
"""Execute a SQL query. Usage: !dbquery <db_name> <sql_query>"""
if db_name not in self.db_connections:
await ctx.send(f"❌ Database '{db_name}' not found. Use `!dblist` to see available databases.")
return
# Safety check - prevent destructive operations by default
query_upper = query.strip().upper()
dangerous_keywords = ["DROP", "DELETE", "TRUNCATE", "ALTER"]
if any(keyword in query_upper.split()[0:2] for keyword in dangerous_keywords):
await ctx.send(f"⚠️ Destructive operation detected. Use `!dbexec` for DELETE/DROP/ALTER commands.")
return
try:
async with aiosqlite.connect(self.db_connections[db_name]) as conn:
async with conn.execute(query) as cur:
results = await cur.fetchall()
description = cur.description
# Format results
output = await self._format_results(results, description)
# Handle long outputs
if len(output) > 1900:
# Save to file
filename = f"query_results_{ctx.message.id}.txt"
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._write_file, filename, output)
await ctx.send(f"📊 Results ({len(results)} rows):", file=discord.File(filename))
await loop.run_in_executor(None, os.remove, filename)
else:
await ctx.send(f"```\n{output}\n```")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
def _write_file(self, filename, content):
"""Helper to write file synchronously in executor"""
with open(filename, "w") as f:
f.write(content)
@commands.command(name="dbexec")
@is_owner()
async def db_exec(self, ctx, db_name: str, *, query: str):
"""Execute a SQL statement (including destructive ones). Usage: !dbexec <db_name> <sql_statement>"""
if db_name not in self.db_connections:
await ctx.send(f"❌ Database '{db_name}' not found. Use `!dblist` to see available databases.")
return
try:
async with aiosqlite.connect(self.db_connections[db_name]) as conn:
async with conn.execute(query) as cur:
affected_rows = cur.rowcount
await conn.commit()
await ctx.send(f"✅ Query executed successfully. Rows affected: {affected_rows}")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@commands.command(name="terminal", aliases=["term", "sh"])
@is_owner()
async def terminal(self, ctx, *, command: str):
"""Execute a shell command. Usage: !terminal <command>"""
# Security check
if not is_command_safe(command):
await ctx.send("❌ Command blocked for security reasons.")
return
try:
# Execute command with timeout using async subprocess
process = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=os.getcwd()
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=30.0)
except asyncio.TimeoutError:
process.kill()
await ctx.send("❌ Command timed out (30 second limit)")
return
output = stdout.decode() if stdout else stderr.decode()
if not output:
output = "Command executed with no output."
# Handle long outputs
if len(output) > 1900:
filename = f"terminal_output_{ctx.message.id}.txt"
loop = asyncio.get_event_loop()
content = f"Command: {command}\nExit code: {process.returncode}\n{'-' * 50}\n{output}"
await loop.run_in_executor(None, self._write_file, filename, content)
await ctx.send(f"🖥️ Output (exit code: {process.returncode}):", file=discord.File(filename))
await loop.run_in_executor(None, os.remove, filename)
else:
await ctx.send(f"```\n{output}\n```\nExit code: {process.returncode}")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@commands.command(name="eval")
@is_owner()
async def eval_code(self, ctx, *, code: str):
"""Evaluate Python code. Usage: !eval <code>"""
# Clean up code block formatting if present
if code.startswith("```python") and code.endswith("```"):
code = code[9:-3].strip()
elif code.startswith("```") and code.endswith("```"):
code = code[3:-3].strip()
try:
# Create a safe local environment with useful imports
env = {
'bot': self.bot,
'ctx': ctx,
'discord': discord,
'commands': commands,
'asyncio': asyncio,
'os': os,
'aiosqlite': __import__('aiosqlite'),
'__import__': __import__,
}
# Check if code contains await or multiple lines - if so, wrap in async function
if 'await' in code or '\n' in code:
# Indent all lines properly
indented_lines = []
for line in code.split('\n'):
indented_lines.append(' ' + line)
indented_code = '\n'.join(indented_lines)
# Wrap in async function
wrapped_code = f"async def _eval_func():\n{indented_code}"
exec(wrapped_code, env)
result = await env['_eval_func']()
else:
# Single line expression without await
try:
result = eval(code, env)
# If result is a coroutine, await it
if asyncio.iscoroutine(result):
result = await result
except SyntaxError:
# If that fails, try to exec it
exec(code, env)
result = env.get('result', 'Code executed successfully (no return value)')
# Format the result
if result is None:
output = "✅ Code executed successfully (returned None)"
else:
output = f"```python\n{repr(result)}\n```"
if len(output) > 1900:
filename = f"eval_output_{ctx.message.id}.txt"
loop = asyncio.get_event_loop()
await loop.run_in_executor(None, self._write_file, filename, str(result))
await ctx.send("📊 Result:", file=discord.File(filename))
await loop.run_in_executor(None, os.remove, filename)
return
await ctx.send(output)
except Exception as e:
error_msg = f"```python\n{type(e).__name__}: {str(e)}\n```"
await ctx.send(f"❌ Error:\n{error_msg}")
traceback.print_exc()
@commands.command(name="pwd")
@is_owner()
async def pwd(self, ctx):
"""Show current working directory"""
cwd = os.getcwd()
await ctx.send(f"📁 Current directory: `{cwd}`")
@commands.command(name="ls")
@is_owner()
async def ls(self, ctx, path: str = "."):
"""List files in a directory. Usage: !ls [path]"""
try:
loop = asyncio.get_event_loop()
items = await loop.run_in_executor(None, os.listdir, path)
if not items:
await ctx.send(f"📁 `{path}` is empty")
return
# Build dirs and files lists
def _categorize():
dirs = []
files = []
for item in items:
full_path = os.path.join(path, item)
if os.path.isdir(full_path):
dirs.append(f"📁 {item}/")
elif os.path.isfile(full_path):
files.append(f"📄 {item}")
return sorted(dirs) + sorted(files)
items_list = await loop.run_in_executor(None, _categorize)
output = "\n".join(items_list)
if len(output) > 1900:
filename = f"ls_output_{ctx.message.id}.txt"
await loop.run_in_executor(None, self._write_file, filename, output)
await ctx.send(f"📁 Contents of `{path}`:", file=discord.File(filename))
await loop.run_in_executor(None, os.remove, filename)
else:
embed = discord.Embed(
title=f"📁 Contents of `{path}`",
description=output,
color=discord.Color.blue()
)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
@commands.group(name="code", invoke_without_command=True)
@is_owner()
async def code(self, ctx):
"""Code management commands. Use !code help for more info."""
await ctx.send("Use `!code help` to see all available code commands.")
@code.command(name="help")
@is_owner()
async def code_help(self, ctx):
"""Show all code commands"""
embed = discord.Embed(
title="📝 Code Commands",
description="All commands for viewing and editing code files",
color=discord.Color.blue()
)
commands_list = [
("**view** `<file>`", "View file contents with syntax highlighting"),
("**search** `<pattern>` `[path]`", "Search for code patterns in files"),
("**edit** `<file>` `<line>` `[end_line]`", "Edit line(s) interactively with popup editor"),
("**insert** `<file>` `<line>` `<content>`", "Insert a new line at position"),
("**delete** `<file>` `<line>`", "Delete a specific line"),
("**replace** `<file>` `\"find\"` `\"replace\"` `[--all]`", "Find and replace text"),
("**backup** `<file>`", "Create timestamped backup"),
("**diff** `<file1>` `<file2>`", "Compare two files"),
("**find** `<file>` `<text>`", "Find specific text and show line numbers"),
]
for cmd, desc in commands_list:
embed.add_field(name=cmd, value=desc, inline=False)
embed.set_footer(text="Example: !code edit bot.py 42")
await ctx.send(embed=embed)
@code.command(name="view", aliases=["cat", "read"])
@is_owner()
async def code_view(self, ctx, *, path: str):
"""View file contents. Usage: !code view <file_path>"""
# Security check
if not is_path_safe(path):
await ctx.send("❌ Access to this file is blocked for security reasons.")
return
try:
if not os.path.isfile(path):
await ctx.send(f"❌ File not found: `{path}`")
return
# Check file size
file_size = os.path.getsize(path)
if file_size > 1_000_000: # 1MB limit
await ctx.send(f"❌ File is too large ({file_size:,} bytes). Max: 1MB")
return
loop = asyncio.get_event_loop()
def _read():
with open(path, 'r', encoding='utf-8') as f:
return f.read()
try:
content = await loop.run_in_executor(None, _read)
except UnicodeDecodeError:
await ctx.send("❌ Cannot read file: binary or non-UTF-8 encoded")
return
# Determine language for syntax highlighting
ext = os.path.splitext(path)[1].lower()
lang_map = {
'.py': 'python',
'.js': 'javascript',
'.ts': 'typescript',
'.json': 'json',
'.yml': 'yaml',
'.yaml': 'yaml',
'.md': 'markdown',
'.sh': 'bash',
'.sql': 'sql',
'.html': 'html',
'.css': 'css',
}
lang = lang_map.get(ext, '')
# Add line numbers
lines = content.split('\n')
numbered_lines = []
line_num_width = len(str(len(lines)))
for i, line in enumerate(lines, 1):
numbered_lines.append(f"{i:>{line_num_width}} | {line}")
numbered_content = '\n'.join(numbered_lines)
# Handle output
if len(numbered_content) > 1900:
filename = f"file_content_{ctx.message.id}.txt"
await loop.run_in_executor(None, self._write_file, filename, numbered_content)
await ctx.send(f"📄 `{path}` ({file_size:,} bytes, {len(lines)} lines):", file=discord.File(filename))
await loop.run_in_executor(None, os.remove, filename)
else:
await ctx.send(f"📄 `{path}` ({len(lines)} lines):\n```{lang}\n{numbered_content}\n```")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@code.command(name="search", aliases=["grep", "find-all"])
@is_owner()
async def code_search(self, ctx, pattern: str, path: str = "."):
"""Search for code patterns in files. Usage: !code search "<pattern>" [path]"""
try:
loop = asyncio.get_event_loop()
def _search():
import re
results = []
if os.path.isfile(path):
files = [path]
elif os.path.isdir(path):
files = []
for root, dirs, filenames in os.walk(path):
# Skip hidden and backup directories
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['backups', '__pycache__', 'node_modules', 'venv', '.venv']]
for filename in filenames:
if filename.endswith(('.py', '.js', '.json', '.txt', '.md', '.yml', '.yaml', '.html', '.css', '.sql')):
files.append(os.path.join(root, filename))
else:
return None, "Invalid path"
for filepath in files[:200]: # Limit to 200 files
if not is_path_safe(filepath):
continue
try:
with open(filepath, 'r', encoding='utf-8') as f:
for i, line in enumerate(f, 1):
if re.search(pattern, line, re.IGNORECASE):
results.append({
'file': filepath,
'line': i,
'content': line.rstrip()
})
if len(results) >= 100: # Limit results
return results, "truncated"
except:
continue
return results, None
results, status = await loop.run_in_executor(None, _search)
if results is None:
await ctx.send(f"{status}")
return
if not results:
await ctx.send(f"❌ No matches found for pattern: `{pattern}`")
return
# Group by file
files_dict = {}
for result in results:
if result['file'] not in files_dict:
files_dict[result['file']] = []
files_dict[result['file']].append(result)
# Build output
output_parts = []
output_parts.append(f"🔍 Found {len(results)} match(es) in {len(files_dict)} file(s)")
if status == "truncated":
output_parts.append("(Showing first 100 matches)")
output_parts.append("")
for filepath, file_results in files_dict.items():
output_parts.append(f"\n📄 **{filepath}** ({len(file_results)} match(es)):")
for result in file_results[:10]: # Max 10 per file in output
output_parts.append(f" Line {result['line']}: `{result['content'][:100]}`")
if len(file_results) > 10:
output_parts.append(f" ... and {len(file_results) - 10} more")
output = "\n".join(output_parts)
if len(output) > 1900:
# Create detailed file
detailed_output = [f"Search results for: {pattern}\n{'=' * 50}\n"]
for filepath, file_results in files_dict.items():
detailed_output.append(f"\n{filepath}:")
for result in file_results:
detailed_output.append(f" {result['line']}: {result['content']}")
filename = f"search_{ctx.message.id}.txt"
await loop.run_in_executor(None, self._write_file, filename, '\n'.join(detailed_output))
await ctx.send(f"🔍 Found {len(results)} matches (see attached file):", file=discord.File(filename))
await loop.run_in_executor(None, os.remove, filename)
else:
await ctx.send(output)
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@code.command(name="find")
@is_owner()
async def code_find(self, ctx, path: str, *, text: str):
"""Find specific text in a file and show line numbers. Usage: !code find <file> <text>"""
# Security check
if not is_path_safe(path):
await ctx.send("❌ Access to this file is blocked for security reasons.")
return
try:
if not os.path.isfile(path):
await ctx.send(f"❌ File not found: `{path}`")
return
loop = asyncio.get_event_loop()
def _find():
matches = []
with open(path, 'r', encoding='utf-8') as f:
for i, line in enumerate(f, 1):
if text.lower() in line.lower():
matches.append((i, line.rstrip()))
return matches
matches = await loop.run_in_executor(None, _find)
if not matches:
await ctx.send(f"❌ Text not found in `{path}`")
return
embed = discord.Embed(
title=f"🔍 Found in `{os.path.basename(path)}`",
description=f"Found {len(matches)} occurrence(s) of: `{text}`",
color=discord.Color.green()
)
# Show matches
for line_num, line_content in matches[:15]: # Show max 15
# Highlight the match
highlighted = line_content.replace(text, f"**{text}**")
if len(highlighted) > 100:
# Find the match position and show context
pos = line_content.lower().find(text.lower())
start = max(0, pos - 40)
end = min(len(line_content), pos + len(text) + 40)
snippet = line_content[start:end]
if start > 0:
snippet = "..." + snippet
if end < len(line_content):
snippet = snippet + "..."
highlighted = snippet.replace(text, f"**{text}**")
embed.add_field(
name=f"Line {line_num}",
value=f"`{highlighted}`",
inline=False
)
if len(matches) > 15:
embed.set_footer(text=f"Showing first 15 of {len(matches)} matches")
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@code.command(name="edit")
@is_owner()
async def code_edit(self, ctx, path: str, line_start: int, line_end: Optional[int] = None):
"""Edit line(s) in a file interactively. Usage: !code edit <file> <line_num> OR !code edit <file> <start_line> <end_line>"""
# Security check
if not is_path_safe(path):
await ctx.send("❌ Access to this file is blocked for security reasons.")
return
try:
if not os.path.isfile(path):
await ctx.send(f"❌ File not found: `{path}`")
return
# If no end line, edit single line
if line_end is None:
line_end = line_start
loop = asyncio.get_event_loop()
def _read_lines():
with open(path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Validate line numbers
if line_start < 1 or line_start > len(lines):
return None, None, f"Line {line_start} is out of range (file has {len(lines)} lines)"
if line_end < line_start or line_end > len(lines):
return None, None, f"Line {line_end} is out of range"
# Get the lines to edit
selected_lines = ''.join(lines[line_start-1:line_end]).rstrip()
return lines, selected_lines, None
lines, selected_lines, error = await loop.run_in_executor(None, _read_lines)
if error:
await ctx.send(f"{error}")
return
# Create modal for editing
class EditModal(discord.ui.Modal, title=f"Edit {os.path.basename(path)}"):
def __init__(self, parent_cog, path, lines, line_start, line_end, selected_lines):
super().__init__()
self.cog = parent_cog
self.path = path
self.lines = lines
self.line_start = line_start
self.line_end = line_end
# Add text input with current content
self.code_input = discord.ui.TextInput(
label=f"Lines {line_start}-{line_end}" if line_start != line_end else f"Line {line_start}",
style=discord.TextStyle.paragraph,
default=selected_lines,
required=True,
max_length=4000
)
self.add_item(self.code_input)
async def on_submit(self, interaction: discord.Interaction):
new_content = self.code_input.value
loop = asyncio.get_event_loop()
def _write():
# Replace the lines
new_lines = new_content.split('\n')
# Ensure newlines at end
new_lines = [line + '\n' if not line.endswith('\n') else line for line in new_lines]
# Remove the old lines and insert new ones
self.lines[self.line_start-1:self.line_end] = new_lines
# Write back
with open(self.path, 'w', encoding='utf-8') as f:
f.writelines(self.lines)
try:
await loop.run_in_executor(None, _write)
embed = discord.Embed(
title=f"✅ Edited `{os.path.basename(self.path)}`",
description=f"Lines {self.line_start}-{self.line_end}" if self.line_start != self.line_end else f"Line {self.line_start}",
color=discord.Color.green()
)
# Show a preview if not too long
if len(new_content) < 1000:
embed.add_field(name="New Content", value=f"```python\n{new_content}\n```", inline=False)
else:
embed.add_field(name="New Content", value=f"```\n{new_content[:500]}...\n(truncated)\n```", inline=False)
await interaction.response.send_message(embed=embed)
except Exception as e:
await interaction.response.send_message(f"❌ Error saving: {str(e)}", ephemeral=True)
# Create a view with button to open modal
class EditView(discord.ui.View):
def __init__(self, cog, path, lines, line_start, line_end, selected_lines):
super().__init__(timeout=300)
self.cog = cog
self.path = path
self.lines = lines
self.line_start = line_start
self.line_end = line_end
self.selected_lines = selected_lines
@discord.ui.button(label="✏️ Edit Code", style=discord.ButtonStyle.primary)
async def edit_button(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user.id != ADMIN_USER_ID:
await interaction.response.send_message("❌ Only the bot owner can edit files.", ephemeral=True)
return
modal = EditModal(self.cog, self.path, self.lines.copy(), self.line_start, self.line_end, self.selected_lines)
await interaction.response.send_modal(modal)
@discord.ui.button(label="❌ Cancel", style=discord.ButtonStyle.secondary)
async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button):
if interaction.user.id != ADMIN_USER_ID:
await interaction.response.send_message("❌ Only the bot owner can use this.", ephemeral=True)
return
await interaction.response.send_message("✅ Edit cancelled.", ephemeral=True)
self.stop()
# Send message with current code
embed = discord.Embed(
title=f"📝 Ready to edit `{os.path.basename(path)}`",
description=f"Lines {line_start}-{line_end}" if line_start != line_end else f"Line {line_start}",
color=discord.Color.blue()
)
# Show current content
if len(selected_lines) < 1000:
embed.add_field(name="Current Content", value=f"```python\n{selected_lines}\n```", inline=False)
else:
embed.add_field(name="Current Content", value=f"```python\n{selected_lines[:500]}...\n(truncated, will show full in editor)\n```", inline=False)
embed.set_footer(text="Click 'Edit Code' to open the editor")
view = EditView(self, path, lines, line_start, line_end, selected_lines)
await ctx.send(embed=embed, view=view)
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@code.command(name="insert")
@is_owner()
async def code_insert(self, ctx, path: str, line_num: int, *, content: str):
"""Insert a new line at a specific position. Usage: !code insert <file> <line_number> <content>"""
# Security check
if not is_path_safe(path):
await ctx.send("❌ Access to this file is blocked for security reasons.")
return
try:
if not os.path.isfile(path):
await ctx.send(f"❌ File not found: `{path}`")
return
loop = asyncio.get_event_loop()
def _insert():
with open(path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Validate line number
if line_num < 1 or line_num > len(lines) + 1:
return f"Line {line_num} is out of range (file has {len(lines)} lines)"
# Insert new line
lines.insert(line_num - 1, content + '\n')
# Write back
with open(path, 'w', encoding='utf-8') as f:
f.writelines(lines)
return None
error = await loop.run_in_executor(None, _insert)
if error:
await ctx.send(f"{error}")
return
await ctx.send(f"✅ Inserted line {line_num} in `{path}`:\n```{content}```")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@code.command(name="delete", aliases=["del"])
@is_owner()
async def code_delete(self, ctx, path: str, line_num: int):
"""Delete a specific line from a file. Usage: !code delete <file> <line_number>"""
# Security check
if not is_path_safe(path):
await ctx.send("❌ Access to this file is blocked for security reasons.")
return
try:
if not os.path.isfile(path):
await ctx.send(f"❌ File not found: `{path}`")
return
loop = asyncio.get_event_loop()
def _delete():
with open(path, 'r', encoding='utf-8') as f:
lines = f.readlines()
# Validate line number
if line_num < 1 or line_num > len(lines):
return None, f"Line {line_num} is out of range (file has {len(lines)} lines)"
# Store deleted line for confirmation
deleted_line = lines[line_num - 1].rstrip()
# Delete line
del lines[line_num - 1]
# Write back
with open(path, 'w', encoding='utf-8') as f:
f.writelines(lines)
return deleted_line, None
deleted_line, error = await loop.run_in_executor(None, _delete)
if error:
await ctx.send(f"{error}")
return
await ctx.send(f"✅ Deleted line {line_num} from `{path}`:\n```{deleted_line}```")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@code.command(name="replace")
@is_owner()
async def code_replace(self, ctx, path: str, find: str, replace: str, *, options: str = ""):
"""Find and replace text in a file. Usage: !code replace <file> "<find>" "<replace>" [--all]"""
# Security check
if not is_path_safe(path):
await ctx.send("❌ Access to this file is blocked for security reasons.")
return
try:
if not os.path.isfile(path):
await ctx.send(f"❌ File not found: `{path}`")
return
replace_all = "--all" in options.lower()
loop = asyncio.get_event_loop()
def _replace():
with open(path, 'r', encoding='utf-8') as f:
content = f.read()
# Count occurrences
count = content.count(find)
if count == 0:
return 0, None
# Replace
if replace_all:
new_content = content.replace(find, replace)
else:
new_content = content.replace(find, replace, 1)
# Write back
with open(path, 'w', encoding='utf-8') as f:
f.write(new_content)
return count, 1 if not replace_all else count
total_found, replaced = await loop.run_in_executor(None, _replace)
if total_found == 0:
await ctx.send(f"❌ Text not found in `{path}`")
return
msg = f"✅ Replaced {replaced} occurrence(s) in `{path}`"
if not replace_all and total_found > 1:
msg += f"\n(Found {total_found} total occurrences. Use `--all` to replace all)"
embed = discord.Embed(
title=msg,
color=discord.Color.green()
)
embed.add_field(name="Find", value=f"```{find}```", inline=False)
embed.add_field(name="Replace", value=f"```{replace}```", inline=False)
await ctx.send(embed=embed)
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@code.command(name="backup")
@is_owner()
async def code_backup(self, ctx, path: str):
"""Create a backup of a file. Usage: !code backup <file>"""
# Security check
if not is_path_safe(path):
await ctx.send("❌ Access to this file is blocked for security reasons.")
return
try:
if not os.path.isfile(path):
await ctx.send(f"❌ File not found: `{path}`")
return
# Create backup filename
import datetime
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = f"{path}.backup_{timestamp}"
loop = asyncio.get_event_loop()
def _backup():
import shutil
shutil.copy2(path, backup_path)
await loop.run_in_executor(None, _backup)
await ctx.send(f"✅ Created backup: `{backup_path}`")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@code.command(name="diff")
@is_owner()
async def code_diff(self, ctx, path1: str, path2: str):
"""Compare two files. Usage: !code diff <file1> <file2>"""
# Security check
if not is_path_safe(path1) or not is_path_safe(path2):
await ctx.send("❌ Access to one or more files is blocked for security reasons.")
return
try:
if not os.path.isfile(path1):
await ctx.send(f"❌ File not found: `{path1}`")
return
if not os.path.isfile(path2):
await ctx.send(f"❌ File not found: `{path2}`")
return
loop = asyncio.get_event_loop()
def _diff():
import difflib
with open(path1, 'r', encoding='utf-8') as f:
lines1 = f.readlines()
with open(path2, 'r', encoding='utf-8') as f:
lines2 = f.readlines()
diff = difflib.unified_diff(
lines1, lines2,
fromfile=path1,
tofile=path2,
lineterm=''
)
return '\n'.join(diff)
diff_output = await loop.run_in_executor(None, _diff)
if not diff_output:
await ctx.send("✅ Files are identical")
return
if len(diff_output) > 1900:
filename = f"diff_{ctx.message.id}.txt"
await loop.run_in_executor(None, self._write_file, filename, diff_output)
await ctx.send("📊 Diff:", file=discord.File(filename))
await loop.run_in_executor(None, os.remove, filename)
else:
await ctx.send(f"```diff\n{diff_output}\n```")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
@commands.command(name="grep")
@is_owner()
async def grep(self, ctx, pattern: str, path: str = "."):
"""Search for text in files. Usage: !grep "<pattern>" [path]"""
try:
loop = asyncio.get_event_loop()
def _grep():
import re
results = []
if os.path.isfile(path):
files = [path]
elif os.path.isdir(path):
files = []
for root, dirs, filenames in os.walk(path):
# Skip hidden and backup directories
dirs[:] = [d for d in dirs if not d.startswith('.') and d not in ['backups', '__pycache__', 'node_modules']]
for filename in filenames:
if filename.endswith(('.py', '.js', '.json', '.txt', '.md', '.yml', '.yaml')):
files.append(os.path.join(root, filename))
else:
return None, "Invalid path"
for filepath in files[:100]: # Limit to 100 files
if not is_path_safe(filepath):
continue
try:
with open(filepath, 'r', encoding='utf-8') as f:
for i, line in enumerate(f, 1):
if re.search(pattern, line):
results.append(f"{filepath}:{i}: {line.rstrip()}")
if len(results) >= 50: # Limit results
return results, "truncated"
except:
continue
return results, None
results, status = await loop.run_in_executor(None, _grep)
if results is None:
await ctx.send(f"{status}")
return
if not results:
await ctx.send(f"❌ No matches found for pattern: `{pattern}`")
return
output = "\n".join(results)
if status == "truncated":
output += "\n... (truncated, showing first 50 matches)"
if len(output) > 1900:
filename = f"grep_{ctx.message.id}.txt"
await loop.run_in_executor(None, self._write_file, filename, output)
await ctx.send(f"🔍 Search results for `{pattern}`:", file=discord.File(filename))
await loop.run_in_executor(None, os.remove, filename)
else:
await ctx.send(f"🔍 Search results for `{pattern}`:\n```\n{output}\n```")
except Exception as e:
await ctx.send(f"❌ Error: {str(e)}")
traceback.print_exc()
async def setup(bot):
await bot.add_cog(AdminCommands(bot))