diff options
| author | Natasha Moongrave <natasha@256phi.eu> | 2026-04-01 18:47:33 +0200 |
|---|---|---|
| committer | Natasha Moongrave <natasha@256phi.eu> | 2026-04-01 18:47:33 +0200 |
| commit | 16733406364b182f90c0dd2de03ac4d3fa00d2a4 (patch) | |
| tree | c2c7c8ee5f99c379dd0f4c0b353d37dc8189a656 | |
| parent | dbe036aaf799ac31c3a8249ef7af07c7dc8c91d7 (diff) | |
| -rw-r--r-- | bot.py | 35 | ||||
| -rw-r--r-- | cogs/__init__.py | 5 | ||||
| -rw-r--r-- | cogs/alerts.py | 208 | ||||
| -rw-r--r-- | cogs/commissions.py | 327 | ||||
| -rw-r--r-- | cogs/vm_management.py | 185 | ||||
| -rw-r--r-- | php_migration_notes.txt | 257 | ||||
| -rw-r--r-- | requirements.txt | 1 | ||||
| -rw-r--r-- | utils/__init__.py | 4 | ||||
| -rw-r--r-- | utils/cooldowns.py | 40 | ||||
| -rw-r--r-- | utils/database.py | 89 |
10 files changed, 1150 insertions, 1 deletions
@@ -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) |
