aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--bot.py35
-rw-r--r--cogs/__init__.py5
-rw-r--r--cogs/alerts.py208
-rw-r--r--cogs/commissions.py327
-rw-r--r--cogs/vm_management.py185
-rw-r--r--php_migration_notes.txt257
-rw-r--r--requirements.txt1
-rw-r--r--utils/__init__.py4
-rw-r--r--utils/cooldowns.py40
-rw-r--r--utils/database.py89
10 files changed, 1150 insertions, 1 deletions
diff --git a/bot.py b/bot.py
index ea69475..0f0d597 100644
--- a/bot.py
+++ b/bot.py
@@ -2,12 +2,21 @@ import discord
from discord import app_commands
from discord.ext import commands
import random
+import asyncio
from proxmoxer import ProxmoxAPI
import config
+from utils.database import Database
bot = commands.Bot(command_prefix="!", intents=discord.Intents.default())
tree = bot.tree
+# List of cogs to load
+COGS = [
+ "cogs.commissions",
+ "cogs.vm_management",
+ "cogs.alerts",
+]
+
def get_proxmox():
return ProxmoxAPI(
@@ -19,9 +28,27 @@ def get_proxmox():
)
+async def load_cogs():
+ """Load all cogs."""
+ for cog in COGS:
+ try:
+ await bot.load_extension(cog)
+ print(f"Loaded cog: {cog}")
+ except Exception as e:
+ print(f"Failed to load cog {cog}: {e}")
+
+
@bot.event
async def on_ready():
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
+
+ # Initialize database tables
+ try:
+ await Database.init_tables()
+ print("Database tables initialized")
+ except Exception as e:
+ print(f"Failed to initialize database: {e}")
+
try:
synced = await tree.sync()
print(f"Synced {len(synced)} command(s)")
@@ -221,6 +248,12 @@ async def notify(interaction: discord.Interaction, user: discord.User, message:
await interaction.response.send_message(f"Failed to send notification: {e}", ephemeral=True)
+async def main():
+ async with bot:
+ await load_cogs()
+ await bot.start(config.DISCORD_TOKEN)
+
+
if __name__ == "__main__":
- bot.run(config.DISCORD_TOKEN)
+ asyncio.run(main())
diff --git a/cogs/__init__.py b/cogs/__init__.py
new file mode 100644
index 0000000..e169ac2
--- /dev/null
+++ b/cogs/__init__.py
@@ -0,0 +1,5 @@
+from .commissions import Commissions
+from .vm_management import VMManagement
+from .alerts import Alerts
+
+__all__ = ["Commissions", "VMManagement", "Alerts"]
diff --git a/cogs/alerts.py b/cogs/alerts.py
new file mode 100644
index 0000000..9e2c71c
--- /dev/null
+++ b/cogs/alerts.py
@@ -0,0 +1,208 @@
+import discord
+from discord.ext import commands, tasks
+from typing import Dict, Set
+from proxmoxer import ProxmoxAPI
+import config
+
+
+class Alerts(commands.Cog):
+ """Background monitoring and DM alerts."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ # Track alert states to prevent spam
+ self._cpu_alert_active = False
+ self._memory_alert_active = False
+ self._stopped_vms: Set[int] = set() # VMIDs that were reported as stopped
+ self._last_known_running: Set[int] = set() # VMIDs that were running last check
+ self._seen_backup_tasks: Set[str] = set() # UPIDs of already-notified backup tasks
+
+ def get_proxmox(self) -> ProxmoxAPI:
+ """Get a Proxmox API connection."""
+ return ProxmoxAPI(
+ config.PROXMOX_HOST,
+ user=config.PROXMOX_USER,
+ token_name=config.PROXMOX_TOKEN_NAME,
+ token_value=config.PROXMOX_TOKEN_VALUE,
+ verify_ssl=config.PROXMOX_VERIFY_SSL,
+ )
+
+ async def cog_load(self):
+ """Called when cog is loaded."""
+ self.monitor_loop.start()
+
+ async def cog_unload(self):
+ """Called when cog is unloaded."""
+ self.monitor_loop.cancel()
+
+ async def _send_alert(self, message: str, embed: discord.Embed = None):
+ """Send an alert DM to the owner."""
+ try:
+ owner = await self.bot.fetch_user(config.OWNER_ID)
+ if embed:
+ await owner.send(content=message, embed=embed)
+ else:
+ await owner.send(message)
+ except Exception as e:
+ print(f"Failed to send alert: {e}")
+
+ @tasks.loop(seconds=60)
+ async def monitor_loop(self):
+ """Background task that checks system status."""
+ try:
+ proxmox = self.get_proxmox()
+ await self._check_node_resources(proxmox)
+ await self._check_vm_status(proxmox)
+ await self._check_backup_tasks(proxmox)
+ except Exception as e:
+ print(f"Monitor loop error: {e}")
+
+ @monitor_loop.before_loop
+ async def before_monitor_loop(self):
+ """Wait for bot to be ready before starting loop."""
+ await self.bot.wait_until_ready()
+ # Initialize known running VMs
+ try:
+ proxmox = self.get_proxmox()
+ self._last_known_running = await self._get_running_vmids(proxmox)
+ except Exception:
+ pass
+
+ async def _get_running_vmids(self, proxmox: ProxmoxAPI) -> Set[int]:
+ """Get set of all running VM/LXC IDs."""
+ running = set()
+ try:
+ vms = proxmox.nodes(config.PROXMOX_NODE).qemu.get()
+ for vm in vms:
+ if vm.get("status") == "running":
+ running.add(vm.get("vmid"))
+ except Exception:
+ pass
+
+ try:
+ lxcs = proxmox.nodes(config.PROXMOX_NODE).lxc.get()
+ for lxc in lxcs:
+ if lxc.get("status") == "running":
+ running.add(lxc.get("vmid"))
+ except Exception:
+ pass
+
+ return running
+
+ async def _check_node_resources(self, proxmox: ProxmoxAPI):
+ """Check CPU and memory usage, alert if above threshold."""
+ try:
+ node_status = proxmox.nodes(config.PROXMOX_NODE).status.get()
+
+ # CPU check
+ cpu_percent = node_status["cpu"] * 100
+ if cpu_percent >= config.ALERT_CPU_THRESHOLD:
+ if not self._cpu_alert_active:
+ self._cpu_alert_active = True
+ embed = discord.Embed(
+ title=":warning: High CPU Usage Alert",
+ description=f"CPU usage on **{config.PROXMOX_NODE}** is at **{cpu_percent:.1f}%**",
+ color=discord.Color.orange(),
+ )
+ await self._send_alert("", embed=embed)
+ else:
+ self._cpu_alert_active = False
+
+ # Memory check
+ mem_used = node_status["memory"]["used"]
+ mem_total = node_status["memory"]["total"]
+ mem_percent = (mem_used / mem_total) * 100 if mem_total > 0 else 0
+
+ if mem_percent >= config.ALERT_MEMORY_THRESHOLD:
+ if not self._memory_alert_active:
+ self._memory_alert_active = True
+ embed = discord.Embed(
+ title=":warning: High Memory Usage Alert",
+ description=f"Memory usage on **{config.PROXMOX_NODE}** is at **{mem_percent:.1f}%**\n"
+ f"({mem_used / (1024**3):.1f} GB / {mem_total / (1024**3):.1f} GB)",
+ color=discord.Color.orange(),
+ )
+ await self._send_alert("", embed=embed)
+ else:
+ self._memory_alert_active = False
+
+ except Exception as e:
+ print(f"Resource check error: {e}")
+
+ async def _check_vm_status(self, proxmox: ProxmoxAPI):
+ """Check for VMs/LXCs that have stopped unexpectedly."""
+ try:
+ current_running = await self._get_running_vmids(proxmox)
+
+ # Find VMs that were running but are now stopped
+ newly_stopped = self._last_known_running - current_running
+
+ for vmid in newly_stopped:
+ if vmid not in self._stopped_vms:
+ self._stopped_vms.add(vmid)
+ # Try to get VM name
+ vm_name = f"ID {vmid}"
+ try:
+ # Check if it's a QEMU VM
+ vms = proxmox.nodes(config.PROXMOX_NODE).qemu.get()
+ for vm in vms:
+ if vm.get("vmid") == vmid:
+ vm_name = vm.get("name", vm_name)
+ break
+ else:
+ # Check LXC
+ lxcs = proxmox.nodes(config.PROXMOX_NODE).lxc.get()
+ for lxc in lxcs:
+ if lxc.get("vmid") == vmid:
+ vm_name = lxc.get("name", vm_name)
+ break
+ except Exception:
+ pass
+
+ embed = discord.Embed(
+ title=":red_circle: VM/Container Stopped",
+ description=f"**{vm_name}** (VMID: {vmid}) has stopped running.",
+ color=discord.Color.red(),
+ )
+ await self._send_alert("", embed=embed)
+
+ # Clear stopped alerts for VMs that are running again
+ self._stopped_vms = self._stopped_vms - current_running
+
+ # Update last known state
+ self._last_known_running = current_running
+
+ except Exception as e:
+ print(f"VM status check error: {e}")
+
+ async def _check_backup_tasks(self, proxmox: ProxmoxAPI):
+ """Check for completed backup tasks."""
+ try:
+ tasks = proxmox.nodes(config.PROXMOX_NODE).tasks.get()
+
+ for task in tasks:
+ # Look for vzdump (backup) tasks
+ if task.get("type") == "vzdump" and task.get("status") == "OK":
+ upid = task.get("upid")
+ if upid and upid not in self._seen_backup_tasks:
+ self._seen_backup_tasks.add(upid)
+ # Keep set from growing too large
+ if len(self._seen_backup_tasks) > 100:
+ self._seen_backup_tasks = set(list(self._seen_backup_tasks)[-50:])
+
+ embed = discord.Embed(
+ title=":white_check_mark: Backup Completed",
+ description=f"Backup task completed successfully.\n"
+ f"**Node:** {task.get('node', 'unknown')}\n"
+ f"**Started:** {task.get('starttime', 'unknown')}\n"
+ f"**Ended:** {task.get('endtime', 'unknown')}",
+ color=discord.Color.green(),
+ )
+ await self._send_alert("", embed=embed)
+
+ except Exception as e:
+ print(f"Backup check error: {e}")
+
+
+async def setup(bot: commands.Bot):
+ await bot.add_cog(Alerts(bot))
diff --git a/cogs/commissions.py b/cogs/commissions.py
new file mode 100644
index 0000000..e2f35e8
--- /dev/null
+++ b/cogs/commissions.py
@@ -0,0 +1,327 @@
+import discord
+from discord import app_commands
+from discord.ext import commands
+from typing import Optional, Literal
+import config
+from utils.database import Database
+from utils.cooldowns import CooldownManager
+
+
+class CommissionModal(discord.ui.Modal, title="Submit a Commission"):
+ """Modal for submitting a new commission request."""
+
+ email = discord.ui.TextInput(
+ label="Email",
+ placeholder="your@email.com",
+ required=True,
+ max_length=255,
+ )
+
+ name = discord.ui.TextInput(
+ label="Name",
+ placeholder="Your name or preferred alias",
+ required=True,
+ max_length=255,
+ )
+
+ commission_type = discord.ui.TextInput(
+ label="Type (art, design, other, unsure)",
+ placeholder="art",
+ required=True,
+ max_length=20,
+ )
+
+ description = discord.ui.TextInput(
+ label="Description",
+ placeholder="Describe what you're looking for...",
+ style=discord.TextStyle.paragraph,
+ required=True,
+ max_length=2000,
+ )
+
+ budget = discord.ui.TextInput(
+ label="Budget (optional)",
+ placeholder="e.g., $50-100",
+ required=False,
+ max_length=100,
+ )
+
+ def __init__(self, cog: "Commissions"):
+ super().__init__()
+ self.cog = cog
+
+ async def on_submit(self, interaction: discord.Interaction):
+ # Validate commission type
+ valid_types = config.COMMISSION_TYPES
+ c_type = self.commission_type.value.lower().strip()
+ if c_type not in valid_types:
+ await interaction.response.send_message(
+ f"Invalid commission type. Please use one of: {', '.join(valid_types)}",
+ ephemeral=True,
+ )
+ return
+
+ # Insert into database
+ query = """
+ INSERT INTO commissions (discord_user_id, email, name, commission_type, description, budget)
+ VALUES (%s, %s, %s, %s, %s, %s)
+ """
+ commission_id = await Database.execute_returning_id(
+ query,
+ (
+ interaction.user.id,
+ self.email.value,
+ self.name.value,
+ c_type,
+ self.description.value,
+ self.budget.value or None,
+ ),
+ )
+
+ # Set cooldown
+ self.cog.cooldown_manager.set(interaction.user.id)
+
+ await interaction.response.send_message(
+ f"Commission submitted! Your commission ID is **#{commission_id}**.\n"
+ "You'll be notified via DM when the status changes.",
+ ephemeral=True,
+ )
+
+ # Notify owner
+ try:
+ owner = await interaction.client.fetch_user(config.OWNER_ID)
+ embed = discord.Embed(
+ title="New Commission Submitted",
+ color=discord.Color.green(),
+ )
+ embed.add_field(name="ID", value=f"#{commission_id}", inline=True)
+ embed.add_field(name="From", value=f"{interaction.user} ({interaction.user.id})", inline=True)
+ embed.add_field(name="Type", value=c_type, inline=True)
+ embed.add_field(name="Email", value=self.email.value, inline=True)
+ embed.add_field(name="Name", value=self.name.value, inline=True)
+ embed.add_field(name="Budget", value=self.budget.value or "Not specified", inline=True)
+ embed.add_field(name="Description", value=self.description.value[:1024], inline=False)
+ await owner.send(embed=embed)
+ except Exception:
+ pass # Don't fail if we can't notify owner
+
+
+class Commissions(commands.Cog):
+ """Commission management commands."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+ self.cooldown_manager = CooldownManager()
+
+ # ============ PUBLIC COMMANDS ============
+
+ @app_commands.command(name="queue", description="Check how many commissions are in the queue")
+ @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True)
+ @app_commands.allowed_installs(guilds=True, users=True)
+ async def queue(self, interaction: discord.Interaction):
+ query = """
+ SELECT
+ COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending,
+ COUNT(CASE WHEN status = 'in_progress' THEN 1 END) as in_progress
+ FROM commissions
+ """
+ result = await Database.fetchone(query)
+ pending = result["pending"] if result else 0
+ in_progress = result["in_progress"] if result else 0
+ total = pending + in_progress
+
+ await interaction.response.send_message(
+ f"**Commission Queue:** {total} total ({pending} pending, {in_progress} in progress)"
+ )
+
+ @app_commands.command(name="commission", description="Submit a new commission request")
+ @app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True)
+ @app_commands.allowed_installs(guilds=True, users=True)
+ async def commission(self, interaction: discord.Interaction):
+ # Check cooldown
+ remaining = self.cooldown_manager.check(interaction.user.id)
+ if remaining is not None:
+ minutes = remaining // 60
+ seconds = remaining % 60
+ await interaction.response.send_message(
+ f"You're on cooldown. Please wait {minutes}m {seconds}s before submitting another commission.",
+ ephemeral=True,
+ )
+ return
+
+ await interaction.response.send_modal(CommissionModal(self))
+
+ # ============ OWNER COMMANDS ============
+
+ commissions_group = app_commands.Group(
+ name="commissions",
+ description="Manage commissions (owner only)",
+ allowed_contexts=app_commands.AppCommandContext(guild=True, dm_channel=True, private_channel=True),
+ allowed_installs=app_commands.AppInstallationType(guild=True, user=True),
+ )
+
+ async def _check_owner(self, interaction: discord.Interaction) -> bool:
+ """Check if user is owner, send error if not."""
+ if interaction.user.id != config.OWNER_ID:
+ await interaction.response.send_message(
+ "You don't have permission to use this command.", ephemeral=True
+ )
+ return False
+ return True
+
+ @commissions_group.command(name="list", description="List all commissions")
+ @app_commands.describe(status="Filter by status")
+ async def list_commissions(
+ self,
+ interaction: discord.Interaction,
+ status: Optional[Literal["pending", "accepted", "in_progress", "completed", "rejected"]] = None,
+ ):
+ if not await self._check_owner(interaction):
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ if status:
+ query = "SELECT id, name, commission_type, status, created_at FROM commissions WHERE status = %s ORDER BY created_at DESC LIMIT 25"
+ commissions = await Database.fetchall(query, (status,))
+ else:
+ query = "SELECT id, name, commission_type, status, created_at FROM commissions ORDER BY created_at DESC LIMIT 25"
+ commissions = await Database.fetchall(query)
+
+ if not commissions:
+ await interaction.followup.send("No commissions found.", ephemeral=True)
+ return
+
+ embed = discord.Embed(
+ title="Commissions" + (f" ({status})" if status else ""),
+ color=discord.Color.blue(),
+ )
+
+ for c in commissions:
+ status_emoji = {
+ "pending": ":yellow_circle:",
+ "accepted": ":white_circle:",
+ "in_progress": ":blue_circle:",
+ "completed": ":green_circle:",
+ "rejected": ":red_circle:",
+ }.get(c["status"], ":black_circle:")
+
+ embed.add_field(
+ name=f"#{c['id']} - {c['name']}",
+ value=f"{status_emoji} {c['status']} | {c['commission_type']} | {c['created_at'].strftime('%Y-%m-%d')}",
+ inline=False,
+ )
+
+ await interaction.followup.send(embed=embed, ephemeral=True)
+
+ @commissions_group.command(name="view", description="View full details of a commission")
+ @app_commands.describe(commission_id="The commission ID to view")
+ async def view_commission(self, interaction: discord.Interaction, commission_id: int):
+ if not await self._check_owner(interaction):
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ query = "SELECT * FROM commissions WHERE id = %s"
+ c = await Database.fetchone(query, (commission_id,))
+
+ if not c:
+ await interaction.followup.send(f"Commission #{commission_id} not found.", ephemeral=True)
+ return
+
+ embed = discord.Embed(
+ title=f"Commission #{c['id']}",
+ color=discord.Color.blue(),
+ )
+ embed.add_field(name="Name", value=c["name"], inline=True)
+ embed.add_field(name="Email", value=c["email"], inline=True)
+ embed.add_field(name="Type", value=c["commission_type"], inline=True)
+ embed.add_field(name="Status", value=c["status"], inline=True)
+ embed.add_field(name="Budget", value=c["budget"] or "Not specified", inline=True)
+
+ if c["discord_user_id"]:
+ embed.add_field(name="Discord ID", value=str(c["discord_user_id"]), inline=True)
+
+ embed.add_field(name="Description", value=c["description"][:1024], inline=False)
+
+ if c["rejection_reason"]:
+ embed.add_field(name="Rejection Reason", value=c["rejection_reason"], inline=False)
+
+ embed.set_footer(text=f"Created: {c['created_at']} | Updated: {c['updated_at']}")
+
+ await interaction.followup.send(embed=embed, ephemeral=True)
+
+ async def _update_status(
+ self,
+ interaction: discord.Interaction,
+ commission_id: int,
+ new_status: str,
+ rejection_reason: Optional[str] = None,
+ ):
+ """Update commission status and notify user."""
+ if not await self._check_owner(interaction):
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ # Get current commission
+ query = "SELECT * FROM commissions WHERE id = %s"
+ c = await Database.fetchone(query, (commission_id,))
+
+ if not c:
+ await interaction.followup.send(f"Commission #{commission_id} not found.", ephemeral=True)
+ return
+
+ # Update status
+ if rejection_reason:
+ update_query = "UPDATE commissions SET status = %s, rejection_reason = %s WHERE id = %s"
+ await Database.execute(update_query, (new_status, rejection_reason, commission_id))
+ else:
+ update_query = "UPDATE commissions SET status = %s WHERE id = %s"
+ await Database.execute(update_query, (new_status, commission_id))
+
+ await interaction.followup.send(
+ f"Commission #{commission_id} marked as **{new_status}**.", ephemeral=True
+ )
+
+ # Notify user via DM if they submitted via Discord
+ if c["discord_user_id"]:
+ try:
+ user = await self.bot.fetch_user(c["discord_user_id"])
+ status_messages = {
+ "accepted": f"Your commission (#{commission_id}) has been **accepted**!",
+ "in_progress": f"Your commission (#{commission_id}) is now **in progress**!",
+ "completed": f"Your commission (#{commission_id}) has been **completed**!",
+ "rejected": f"Your commission (#{commission_id}) has been **rejected**."
+ + (f"\nReason: {rejection_reason}" if rejection_reason else ""),
+ }
+ message = status_messages.get(new_status, f"Your commission (#{commission_id}) status updated to: {new_status}")
+ await user.send(message)
+ except Exception:
+ pass # Don't fail if we can't DM
+
+ @commissions_group.command(name="accept", description="Accept a pending commission")
+ @app_commands.describe(commission_id="The commission ID to accept")
+ async def accept_commission(self, interaction: discord.Interaction, commission_id: int):
+ await self._update_status(interaction, commission_id, "accepted")
+
+ @commissions_group.command(name="progress", description="Mark a commission as in progress")
+ @app_commands.describe(commission_id="The commission ID to mark in progress")
+ async def progress_commission(self, interaction: discord.Interaction, commission_id: int):
+ await self._update_status(interaction, commission_id, "in_progress")
+
+ @commissions_group.command(name="done", description="Mark a commission as completed")
+ @app_commands.describe(commission_id="The commission ID to mark completed")
+ async def done_commission(self, interaction: discord.Interaction, commission_id: int):
+ await self._update_status(interaction, commission_id, "completed")
+
+ @commissions_group.command(name="reject", description="Reject a commission")
+ @app_commands.describe(commission_id="The commission ID to reject", reason="Reason for rejection")
+ async def reject_commission(
+ self, interaction: discord.Interaction, commission_id: int, reason: Optional[str] = None
+ ):
+ await self._update_status(interaction, commission_id, "rejected", reason)
+
+
+async def setup(bot: commands.Bot):
+ await bot.add_cog(Commissions(bot))
diff --git a/cogs/vm_management.py b/cogs/vm_management.py
new file mode 100644
index 0000000..fd76eaa
--- /dev/null
+++ b/cogs/vm_management.py
@@ -0,0 +1,185 @@
+import discord
+from discord import app_commands
+from discord.ext import commands
+from typing import Literal
+import asyncio
+from proxmoxer import ProxmoxAPI
+import config
+
+
+class VMManagement(commands.Cog):
+ """VM and LXC container management commands."""
+
+ def __init__(self, bot: commands.Bot):
+ self.bot = bot
+
+ def get_proxmox(self) -> ProxmoxAPI:
+ """Get a Proxmox API connection."""
+ return ProxmoxAPI(
+ config.PROXMOX_HOST,
+ user=config.PROXMOX_USER,
+ token_name=config.PROXMOX_TOKEN_NAME,
+ token_value=config.PROXMOX_TOKEN_VALUE,
+ verify_ssl=config.PROXMOX_VERIFY_SSL,
+ )
+
+ async def _check_owner(self, interaction: discord.Interaction) -> bool:
+ """Check if user is owner, send error if not."""
+ if interaction.user.id != config.OWNER_ID:
+ await interaction.response.send_message(
+ "You don't have permission to use this command.", ephemeral=True
+ )
+ return False
+ return True
+
+ def _get_vm_type(self, proxmox: ProxmoxAPI, vmid: int) -> str | None:
+ """Determine if VMID is a QEMU VM or LXC container."""
+ # Check QEMU VMs
+ try:
+ vms = proxmox.nodes(config.PROXMOX_NODE).qemu.get()
+ for vm in vms:
+ if vm.get("vmid") == vmid:
+ return "qemu"
+ except Exception:
+ pass
+
+ # Check LXC containers
+ try:
+ lxcs = proxmox.nodes(config.PROXMOX_NODE).lxc.get()
+ for lxc in lxcs:
+ if lxc.get("vmid") == vmid:
+ return "lxc"
+ except Exception:
+ pass
+
+ return None
+
+ def _get_vm_name(self, proxmox: ProxmoxAPI, vmid: int, vm_type: str) -> str:
+ """Get the name of a VM or container."""
+ try:
+ if vm_type == "qemu":
+ config_data = proxmox.nodes(config.PROXMOX_NODE).qemu(vmid).config.get()
+ else:
+ config_data = proxmox.nodes(config.PROXMOX_NODE).lxc(vmid).config.get()
+ return config_data.get("hostname", config_data.get("name", f"VM {vmid}"))
+ except Exception:
+ return f"VM {vmid}"
+
+ vm_group = app_commands.Group(
+ name="vm",
+ description="Manage VMs and containers (owner only)",
+ allowed_contexts=app_commands.AppCommandContext(guild=True, dm_channel=True, private_channel=True),
+ allowed_installs=app_commands.AppInstallationType(guild=True, user=True),
+ )
+
+ @vm_group.command(name="start", description="Start a VM or container")
+ @app_commands.describe(vmid="The VMID to start")
+ async def start_vm(self, interaction: discord.Interaction, vmid: int):
+ if not await self._check_owner(interaction):
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ try:
+ proxmox = self.get_proxmox()
+ vm_type = self._get_vm_type(proxmox, vmid)
+
+ if not vm_type:
+ await interaction.followup.send(f"VM/Container {vmid} not found.", ephemeral=True)
+ return
+
+ vm_name = self._get_vm_name(proxmox, vmid, vm_type)
+
+ # Start the VM/container
+ if vm_type == "qemu":
+ await asyncio.to_thread(
+ lambda: proxmox.nodes(config.PROXMOX_NODE).qemu(vmid).status.start.post()
+ )
+ else:
+ await asyncio.to_thread(
+ lambda: proxmox.nodes(config.PROXMOX_NODE).lxc(vmid).status.start.post()
+ )
+
+ type_name = "VM" if vm_type == "qemu" else "Container"
+ await interaction.followup.send(
+ f":green_circle: {type_name} **{vmid}** ({vm_name}) is starting...", ephemeral=True
+ )
+
+ except Exception as e:
+ await interaction.followup.send(f"Failed to start VM {vmid}: {e}", ephemeral=True)
+
+ @vm_group.command(name="stop", description="Stop a VM or container")
+ @app_commands.describe(vmid="The VMID to stop")
+ async def stop_vm(self, interaction: discord.Interaction, vmid: int):
+ if not await self._check_owner(interaction):
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ try:
+ proxmox = self.get_proxmox()
+ vm_type = self._get_vm_type(proxmox, vmid)
+
+ if not vm_type:
+ await interaction.followup.send(f"VM/Container {vmid} not found.", ephemeral=True)
+ return
+
+ vm_name = self._get_vm_name(proxmox, vmid, vm_type)
+
+ # Stop the VM/container
+ if vm_type == "qemu":
+ await asyncio.to_thread(
+ lambda: proxmox.nodes(config.PROXMOX_NODE).qemu(vmid).status.stop.post()
+ )
+ else:
+ await asyncio.to_thread(
+ lambda: proxmox.nodes(config.PROXMOX_NODE).lxc(vmid).status.stop.post()
+ )
+
+ type_name = "VM" if vm_type == "qemu" else "Container"
+ await interaction.followup.send(
+ f":red_circle: {type_name} **{vmid}** ({vm_name}) is stopping...", ephemeral=True
+ )
+
+ except Exception as e:
+ await interaction.followup.send(f"Failed to stop VM {vmid}: {e}", ephemeral=True)
+
+ @vm_group.command(name="restart", description="Restart a VM or container")
+ @app_commands.describe(vmid="The VMID to restart")
+ async def restart_vm(self, interaction: discord.Interaction, vmid: int):
+ if not await self._check_owner(interaction):
+ return
+
+ await interaction.response.defer(ephemeral=True)
+
+ try:
+ proxmox = self.get_proxmox()
+ vm_type = self._get_vm_type(proxmox, vmid)
+
+ if not vm_type:
+ await interaction.followup.send(f"VM/Container {vmid} not found.", ephemeral=True)
+ return
+
+ vm_name = self._get_vm_name(proxmox, vmid, vm_type)
+
+ # Reboot the VM/container
+ if vm_type == "qemu":
+ await asyncio.to_thread(
+ lambda: proxmox.nodes(config.PROXMOX_NODE).qemu(vmid).status.reboot.post()
+ )
+ else:
+ await asyncio.to_thread(
+ lambda: proxmox.nodes(config.PROXMOX_NODE).lxc(vmid).status.reboot.post()
+ )
+
+ type_name = "VM" if vm_type == "qemu" else "Container"
+ await interaction.followup.send(
+ f":arrows_counterclockwise: {type_name} **{vmid}** ({vm_name}) is restarting...", ephemeral=True
+ )
+
+ except Exception as e:
+ await interaction.followup.send(f"Failed to restart VM {vmid}: {e}", ephemeral=True)
+
+
+async def setup(bot: commands.Bot):
+ await bot.add_cog(VMManagement(bot))
diff --git a/php_migration_notes.txt b/php_migration_notes.txt
new file mode 100644
index 0000000..f52bd55
--- /dev/null
+++ b/php_migration_notes.txt
@@ -0,0 +1,257 @@
+# PHP Website Migration Notes: JSON to MariaDB
+
+This document outlines the changes needed to migrate the PHP website from JSON
+file storage to MariaDB for commission management.
+
+## 1. Database Connection Setup (PDO)
+
+Create a file `db.php` for database connection:
+
+```php
+<?php
+$db_host = '10.0.0.xxx'; // Same as bot's DB_HOST
+$db_name = 'mystra';
+$db_user = 'mystra';
+$db_pass = '...'; // Same as bot's DB_PASSWORD
+
+try {
+ $pdo = new PDO(
+ "mysql:host=$db_host;dbname=$db_name;charset=utf8mb4",
+ $db_user,
+ $db_pass,
+ [
+ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+ PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+ PDO::ATTR_EMULATE_PREPARES => false,
+ ]
+ );
+} catch (PDOException $e) {
+ die("Database connection failed: " . $e->getMessage());
+}
+```
+
+## 2. Updated Commission Submission Form Handling
+
+Replace JSON file writing with database insert:
+
+```php
+<?php
+require_once 'db.php';
+
+if ($_SERVER['REQUEST_METHOD'] === 'POST') {
+ $email = filter_input(INPUT_POST, 'email', FILTER_SANITIZE_EMAIL);
+ $name = filter_input(INPUT_POST, 'name', FILTER_SANITIZE_STRING);
+ $type = filter_input(INPUT_POST, 'type', FILTER_SANITIZE_STRING);
+ $description = filter_input(INPUT_POST, 'description', FILTER_SANITIZE_STRING);
+ $budget = filter_input(INPUT_POST, 'budget', FILTER_SANITIZE_STRING);
+
+ // Validate commission type
+ $valid_types = ['art', 'design', 'other', 'unsure'];
+ if (!in_array($type, $valid_types)) {
+ $error = "Invalid commission type";
+ } else {
+ $stmt = $pdo->prepare("
+ INSERT INTO commissions (discord_user_id, email, name, commission_type, description, budget)
+ VALUES (NULL, :email, :name, :type, :description, :budget)
+ ");
+
+ $stmt->execute([
+ ':email' => $email,
+ ':name' => $name,
+ ':type' => $type,
+ ':description' => $description,
+ ':budget' => $budget ?: null,
+ ]);
+
+ $commission_id = $pdo->lastInsertId();
+ $success = "Commission submitted! Your ID is #$commission_id";
+ }
+}
+```
+
+## 3. Updated Commission Display Queries
+
+### Show Queue Count (Public)
+
+```php
+<?php
+require_once 'db.php';
+
+$stmt = $pdo->query("
+ SELECT
+ COUNT(CASE WHEN status = 'pending' THEN 1 END) as pending,
+ COUNT(CASE WHEN status = 'in_progress' THEN 1 END) as in_progress
+ FROM commissions
+");
+$counts = $stmt->fetch();
+$total = $counts['pending'] + $counts['in_progress'];
+
+echo "Queue: $total commissions ({$counts['pending']} pending, {$counts['in_progress']} in progress)";
+```
+
+### List All Commissions (Admin)
+
+```php
+<?php
+require_once 'db.php';
+
+$status_filter = $_GET['status'] ?? null;
+
+if ($status_filter) {
+ $stmt = $pdo->prepare("
+ SELECT id, name, email, commission_type, status, created_at
+ FROM commissions
+ WHERE status = :status
+ ORDER BY created_at DESC
+ ");
+ $stmt->execute([':status' => $status_filter]);
+} else {
+ $stmt = $pdo->query("
+ SELECT id, name, email, commission_type, status, created_at
+ FROM commissions
+ ORDER BY created_at DESC
+ ");
+}
+
+$commissions = $stmt->fetchAll();
+```
+
+### View Single Commission (Admin)
+
+```php
+<?php
+require_once 'db.php';
+
+$id = filter_input(INPUT_GET, 'id', FILTER_VALIDATE_INT);
+
+$stmt = $pdo->prepare("SELECT * FROM commissions WHERE id = :id");
+$stmt->execute([':id' => $id]);
+$commission = $stmt->fetch();
+
+if (!$commission) {
+ die("Commission not found");
+}
+```
+
+### Update Commission Status (Admin)
+
+```php
+<?php
+require_once 'db.php';
+
+$id = filter_input(INPUT_POST, 'id', FILTER_VALIDATE_INT);
+$status = filter_input(INPUT_POST, 'status', FILTER_SANITIZE_STRING);
+$reason = filter_input(INPUT_POST, 'rejection_reason', FILTER_SANITIZE_STRING);
+
+$valid_statuses = ['pending', 'accepted', 'in_progress', 'completed', 'rejected'];
+if (!in_array($status, $valid_statuses)) {
+ die("Invalid status");
+}
+
+if ($status === 'rejected' && $reason) {
+ $stmt = $pdo->prepare("
+ UPDATE commissions
+ SET status = :status, rejection_reason = :reason
+ WHERE id = :id
+ ");
+ $stmt->execute([':status' => $status, ':reason' => $reason, ':id' => $id]);
+} else {
+ $stmt = $pdo->prepare("UPDATE commissions SET status = :status WHERE id = :id");
+ $stmt->execute([':status' => $status, ':id' => $id]);
+}
+```
+
+## 4. Migration Script: Import Existing JSON Data
+
+If you have existing commissions in a JSON file, use this script to import them:
+
+```php
+<?php
+require_once 'db.php';
+
+// Load existing JSON data
+$json_file = 'commissions.json'; // Adjust path as needed
+$json_data = file_get_contents($json_file);
+$commissions = json_decode($json_data, true);
+
+if (!$commissions) {
+ die("No commissions to migrate or invalid JSON");
+}
+
+$stmt = $pdo->prepare("
+ INSERT INTO commissions (email, name, commission_type, description, budget, status, created_at)
+ VALUES (:email, :name, :type, :description, :budget, :status, :created_at)
+");
+
+$count = 0;
+foreach ($commissions as $c) {
+ // Map your existing JSON fields to database columns
+ // Adjust field names based on your actual JSON structure
+ $stmt->execute([
+ ':email' => $c['email'] ?? '',
+ ':name' => $c['name'] ?? '',
+ ':type' => $c['type'] ?? 'unsure',
+ ':description' => $c['description'] ?? '',
+ ':budget' => $c['budget'] ?? null,
+ ':status' => $c['status'] ?? 'pending',
+ ':created_at' => $c['created_at'] ?? date('Y-m-d H:i:s'),
+ ]);
+ $count++;
+}
+
+echo "Migrated $count commissions to MariaDB\n";
+```
+
+## 5. MariaDB Setup Commands
+
+Run these on your database LXC:
+
+```sql
+-- Create database and user
+CREATE DATABASE mystra CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
+CREATE USER 'mystra'@'10.0.0.%' IDENTIFIED BY 'your_secure_password';
+GRANT ALL PRIVILEGES ON mystra.* TO 'mystra'@'10.0.0.%';
+FLUSH PRIVILEGES;
+
+-- Create the commissions table
+USE mystra;
+
+CREATE TABLE commissions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ discord_user_id BIGINT,
+ email VARCHAR(255) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ commission_type ENUM('art', 'design', 'other', 'unsure') NOT NULL,
+ description TEXT NOT NULL,
+ budget VARCHAR(100),
+ status ENUM('pending', 'accepted', 'in_progress', 'completed', 'rejected') DEFAULT 'pending',
+ rejection_reason TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+);
+
+-- Optional: Add index for faster status filtering
+CREATE INDEX idx_status ON commissions(status);
+```
+
+## 6. MariaDB Configuration
+
+In `/etc/mysql/mariadb.conf.d/50-server.cnf`, ensure MariaDB listens on the
+network interface (not just localhost):
+
+```ini
+bind-address = 0.0.0.0
+```
+
+Then restart MariaDB:
+
+```bash
+systemctl restart mariadb
+```
+
+## Notes
+
+- Both the Discord bot and PHP website use the same database and table
+- discord_user_id will be NULL for commissions submitted via the website
+- The bot will notify users via Discord DM only if they submitted via Discord
+- Make sure to update DB credentials in both bot's config.py and PHP's db.php
diff --git a/requirements.txt b/requirements.txt
index 743ac64..2066b3c 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
discord.py>=2.3.0
proxmoxer>=2.0.0
requests>=2.31.0
+aiomysql>=0.2.0
diff --git a/utils/__init__.py b/utils/__init__.py
new file mode 100644
index 0000000..6a69aa0
--- /dev/null
+++ b/utils/__init__.py
@@ -0,0 +1,4 @@
+from .database import Database
+from .cooldowns import CooldownManager
+
+__all__ = ["Database", "CooldownManager"]
diff --git a/utils/cooldowns.py b/utils/cooldowns.py
new file mode 100644
index 0000000..a1dc153
--- /dev/null
+++ b/utils/cooldowns.py
@@ -0,0 +1,40 @@
+import time
+from typing import Dict, Optional
+import config
+
+
+class CooldownManager:
+ """In-memory cooldown tracking for anti-spam."""
+
+ def __init__(self, cooldown_seconds: Optional[int] = None):
+ self._cooldowns: Dict[int, float] = {}
+ self.cooldown_seconds = cooldown_seconds or config.COMMISSION_COOLDOWN_SECONDS
+
+ def check(self, user_id: int) -> Optional[int]:
+ """
+ Check if a user is on cooldown.
+
+ Returns:
+ None if not on cooldown, otherwise seconds remaining.
+ """
+ if user_id not in self._cooldowns:
+ return None
+
+ elapsed = time.time() - self._cooldowns[user_id]
+ if elapsed >= self.cooldown_seconds:
+ del self._cooldowns[user_id]
+ return None
+
+ return int(self.cooldown_seconds - elapsed)
+
+ def set(self, user_id: int):
+ """Set cooldown for a user."""
+ self._cooldowns[user_id] = time.time()
+
+ def clear(self, user_id: int):
+ """Clear cooldown for a user."""
+ self._cooldowns.pop(user_id, None)
+
+ def is_on_cooldown(self, user_id: int) -> bool:
+ """Check if user is currently on cooldown."""
+ return self.check(user_id) is not None
diff --git a/utils/database.py b/utils/database.py
new file mode 100644
index 0000000..e234331
--- /dev/null
+++ b/utils/database.py
@@ -0,0 +1,89 @@
+import aiomysql
+from typing import Optional, List, Dict, Any
+import config
+
+
+class Database:
+ """MariaDB connection pool manager for async operations."""
+
+ _pool: Optional[aiomysql.Pool] = None
+
+ @classmethod
+ async def get_pool(cls) -> aiomysql.Pool:
+ """Get or create the connection pool."""
+ if cls._pool is None or cls._pool.closed:
+ cls._pool = await aiomysql.create_pool(
+ host=config.DB_HOST,
+ port=config.DB_PORT,
+ user=config.DB_USER,
+ password=config.DB_PASSWORD,
+ db=config.DB_NAME,
+ autocommit=True,
+ minsize=1,
+ maxsize=10,
+ )
+ return cls._pool
+
+ @classmethod
+ async def close(cls):
+ """Close the connection pool."""
+ if cls._pool is not None and not cls._pool.closed:
+ cls._pool.close()
+ await cls._pool.wait_closed()
+ cls._pool = None
+
+ @classmethod
+ async def execute(cls, query: str, args: tuple = ()) -> int:
+ """Execute a query and return the number of affected rows."""
+ pool = await cls.get_pool()
+ async with pool.acquire() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(query, args)
+ return cur.rowcount
+
+ @classmethod
+ async def execute_returning_id(cls, query: str, args: tuple = ()) -> int:
+ """Execute an INSERT query and return the last inserted ID."""
+ pool = await cls.get_pool()
+ async with pool.acquire() as conn:
+ async with conn.cursor() as cur:
+ await cur.execute(query, args)
+ return cur.lastrowid
+
+ @classmethod
+ async def fetchone(cls, query: str, args: tuple = ()) -> Optional[Dict[str, Any]]:
+ """Fetch a single row as a dictionary."""
+ pool = await cls.get_pool()
+ async with pool.acquire() as conn:
+ async with conn.cursor(aiomysql.DictCursor) as cur:
+ await cur.execute(query, args)
+ return await cur.fetchone()
+
+ @classmethod
+ async def fetchall(cls, query: str, args: tuple = ()) -> List[Dict[str, Any]]:
+ """Fetch all rows as a list of dictionaries."""
+ pool = await cls.get_pool()
+ async with pool.acquire() as conn:
+ async with conn.cursor(aiomysql.DictCursor) as cur:
+ await cur.execute(query, args)
+ return await cur.fetchall()
+
+ @classmethod
+ async def init_tables(cls):
+ """Initialize database tables if they don't exist."""
+ create_commissions = """
+ CREATE TABLE IF NOT EXISTS commissions (
+ id INT AUTO_INCREMENT PRIMARY KEY,
+ discord_user_id BIGINT,
+ email VARCHAR(255) NOT NULL,
+ name VARCHAR(255) NOT NULL,
+ commission_type ENUM('art', 'design', 'other', 'unsure') NOT NULL,
+ description TEXT NOT NULL,
+ budget VARCHAR(100),
+ status ENUM('pending', 'accepted', 'in_progress', 'completed', 'rejected') DEFAULT 'pending',
+ rejection_reason TEXT,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
+ )
+ """
+ await cls.execute(create_commissions)