diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rw-r--r-- | README.md | 41 | ||||
| -rw-r--r-- | bot.py | 226 | ||||
| -rw-r--r-- | requirements.txt | 4 |
4 files changed, 273 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3afd512 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__ +config.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..643f6b6 --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# 256phi - Discord Bot + +Fun interactions and Proxmox monitoring. + +## Commands + +- `/pet`, `/boop`, `/hug`, `/cookie`, `/headpat` - fun stuff +- `/node` - full node health (CPU, memory, swap, storage, VMs/containers) +- `/vms` - list all VMs and containers +- `/notify` - send a DM (owner only) + +## Run + +```bash +pip install -r requirements.txt +python3 bot.py +``` + +## Systemd Service + +```ini +[Unit] +Description=256phi Discord Bot +After=network.target + +[Service] +Type=simple +User=mystra +WorkingDirectory=/home/mystra +ExecStart=/usr/bin/python3 bot.py +Restart=on-failure + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl enable 256phi +sudo systemctl start 256phi +``` + @@ -0,0 +1,226 @@ +import discord +from discord import app_commands +from discord.ext import commands +import random +from proxmoxer import ProxmoxAPI +import config + +bot = commands.Bot(command_prefix="!", intents=discord.Intents.default()) +tree = bot.tree + + +def get_proxmox(): + 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, + ) + + +@bot.event +async def on_ready(): + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + try: + synced = await tree.sync() + print(f"Synced {len(synced)} command(s)") + except Exception as e: + print(f"Failed to sync commands: {e}") + + +# ============ FUN COMMANDS ============ + +@tree.command(name="pet", description="Pet the bot!") +@app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) +@app_commands.allowed_installs(guilds=True, users=True) +async def pet(interaction: discord.Interaction): + responses = [ + "*purrs contentedly* :3", + "*happy bot noises*", + "ehe~ thank you!", + "*wags tail enthusiastically*", + "nyaa~ more pets please!", + "*leans into the pets*", + "beep boop... I mean, *purr*", + ] + await interaction.response.send_message(random.choice(responses)) + + +@tree.command(name="boop", description="Boop the bot's nose") +@app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) +@app_commands.allowed_installs(guilds=True, users=True) +async def boop(interaction: discord.Interaction): + responses = [ + "*surprised pikachu face*", + "hey! >:(", + "*boops you back*", + "my nose! D:", + "owo what's this?", + "*sneezes*", + ] + await interaction.response.send_message(random.choice(responses)) + + +@tree.command(name="hug", description="Give the bot a hug") +@app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) +@app_commands.allowed_installs(guilds=True, users=True) +async def hug(interaction: discord.Interaction): + responses = [ + f"*hugs {interaction.user.display_name} back tightly*", + "warm... cozy... :)", + "*melts into the hug*", + f"aww, {interaction.user.display_name}! *squeeze*", + ] + await interaction.response.send_message(random.choice(responses)) + + +@tree.command(name="cookie", description="Give the bot a cookie") +@app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) +@app_commands.allowed_installs(guilds=True, users=True) +async def cookie(interaction: discord.Interaction): + await interaction.response.send_message("*nom nom nom* thank you for the cookie! :cookie:") + + +@tree.command(name="headpat", description="Give the bot headpats") +@app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) +@app_commands.allowed_installs(guilds=True, users=True) +async def headpat(interaction: discord.Interaction): + responses = [ + "*happy rumbling*", + "yes yes yes!!", + "*closes eyes contentedly*", + "more... MORE!", + ":D", + ] + await interaction.response.send_message(random.choice(responses)) + + +# ============ SERVER ANALYTICS ============ + +@tree.command(name="node", description="Check full Proxmox node health") +@app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) +@app_commands.allowed_installs(guilds=True, users=True) +async def node(interaction: discord.Interaction): + await interaction.response.defer() + try: + proxmox = get_proxmox() + node_status = proxmox.nodes(config.PROXMOX_NODE).status.get() + + mem_used = node_status["memory"]["used"] / (1024**3) + mem_total = node_status["memory"]["total"] / (1024**3) + swap_used = node_status.get("swap", {}).get("used", 0) / (1024**3) + swap_total = node_status.get("swap", {}).get("total", 1) / (1024**3) + rootfs_used = node_status.get("rootfs", {}).get("used", 0) / (1024**3) + rootfs_total = node_status.get("rootfs", {}).get("total", 1) / (1024**3) + + embed = discord.Embed( + title=f"Node: {config.PROXMOX_NODE}", + color=discord.Color.blue() + ) + embed.add_field( + name="CPU", + value=f"{node_status['cpu'] * 100:.1f}% ({node_status.get('cpuinfo', {}).get('cpus', '?')} cores)", + inline=True + ) + embed.add_field( + name="Memory", + value=f"{mem_used:.1f} / {mem_total:.1f} GB\n({mem_used/mem_total*100:.0f}%)", + inline=True + ) + embed.add_field( + name="Swap", + value=f"{swap_used:.1f} / {swap_total:.1f} GB", + inline=True + ) + embed.add_field( + name="Root FS", + value=f"{rootfs_used:.1f} / {rootfs_total:.1f} GB\n({rootfs_used/rootfs_total*100:.0f}%)", + inline=True + ) + embed.add_field( + name="Uptime", + value=f"{node_status['uptime'] // 86400}d {(node_status['uptime'] % 86400) // 3600}h {(node_status['uptime'] % 3600) // 60}m", + inline=True + ) + embed.add_field( + name="Load Avg", + value=f"{', '.join(f'{float(l):.2f}' for l in node_status.get('loadavg', [0,0,0]))}", + inline=True + ) + + vms = proxmox.nodes(config.PROXMOX_NODE).qemu.get() + lxcs = proxmox.nodes(config.PROXMOX_NODE).lxc.get() + running_vms = sum(1 for vm in vms if vm.get("status") == "running") + running_lxc = sum(1 for lxc in lxcs if lxc.get("status") == "running") + + embed.add_field(name="VMs", value=f"{running_vms}/{len(vms)} running", inline=True) + embed.add_field(name="Containers", value=f"{running_lxc}/{len(lxcs)} running", inline=True) + embed.set_footer(text=node_status.get("pveversion", "")) + + await interaction.followup.send(embed=embed) + except Exception as e: + await interaction.followup.send(f"Failed to fetch node status: {e}") + + +@tree.command(name="vms", description="List all VMs and containers") +@app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) +@app_commands.allowed_installs(guilds=True, users=True) +async def vms(interaction: discord.Interaction): + await interaction.response.defer() + try: + proxmox = get_proxmox() + qemu_vms = proxmox.nodes(config.PROXMOX_NODE).qemu.get() + lxcs = proxmox.nodes(config.PROXMOX_NODE).lxc.get() + + embed = discord.Embed( + title=f"VMs & Containers on {config.PROXMOX_NODE}", + color=discord.Color.blue() + ) + + if qemu_vms: + vm_list = [] + for vm in sorted(qemu_vms, key=lambda x: x.get("vmid", 0)): + status = ":green_circle:" if vm.get("status") == "running" else ":red_circle:" + mem = vm.get("mem", 0) / (1024**3) if vm.get("status") == "running" else 0 + maxmem = vm.get("maxmem", 1) / (1024**3) + vm_list.append(f"{status} **{vm.get('vmid')}** {vm.get('name', 'unnamed')} ({mem:.1f}/{maxmem:.0f}GB)") + embed.add_field(name="VMs", value="\n".join(vm_list) or "None", inline=False) + + if lxcs: + lxc_list = [] + for lxc in sorted(lxcs, key=lambda x: x.get("vmid", 0)): + status = ":green_circle:" if lxc.get("status") == "running" else ":red_circle:" + mem = lxc.get("mem", 0) / (1024**3) if lxc.get("status") == "running" else 0 + maxmem = lxc.get("maxmem", 1) / (1024**3) + lxc_list.append(f"{status} **{lxc.get('vmid')}** {lxc.get('name', 'unnamed')} ({mem:.1f}/{maxmem:.0f}GB)") + embed.add_field(name="Containers", value="\n".join(lxc_list) or "None", inline=False) + + await interaction.followup.send(embed=embed) + except Exception as e: + await interaction.followup.send(f"Failed to fetch VMs: {e}") + + +# ============ NOTIFY COMMAND ============ + +@tree.command(name="notify", description="Send a DM notification (owner only)") +@app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True) +@app_commands.allowed_installs(guilds=True, users=True) +@app_commands.describe(user="User to notify", message="Message to send") +async def notify(interaction: discord.Interaction, user: discord.User, message: str): + if interaction.user.id != config.OWNER_ID: + await interaction.response.send_message("You don't have permission to use this command.", ephemeral=True) + return + + try: + await user.send(message) + await interaction.response.send_message(f"Sent notification to {user.display_name}", ephemeral=True) + except discord.Forbidden: + await interaction.response.send_message(f"Couldn't DM {user.display_name} - they may have DMs disabled.", ephemeral=True) + except Exception as e: + await interaction.response.send_message(f"Failed to send notification: {e}", ephemeral=True) + + +if __name__ == "__main__": + bot.run(config.DISCORD_TOKEN) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..743ac64 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +discord.py>=2.3.0 +proxmoxer>=2.0.0 +requests>=2.31.0 + |
