commit f0339058fce46f905598f483a527bcb7de125a3d Author: Jared Vititoe Date: Tue Jan 13 16:03:54 2026 -0500 Initial commit - refactored Discord bot with security fixes diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..50e9a8f --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# Lotus Discord Bot + +Discord bot for the Lotus Guild server with auto-deployment from Gitea. + +## Features + +- Daily adjective posting +- Minecraft server whitelist management +- Custom LLM integration (Lotus LLM) +- Fun commands (8ball, fortune, dice, etc.) +- Game-specific commands (Valorant agents, LoL champions) +- Server audit logging +- Reaction roles + +## Auto-Deployment Setup + +The bot automatically deploys when code is pushed to Gitea. + +### Deployment Components + +1. **Webhook Listener**: Runs on port 9000 on the production server +2. **Deployment Script**: `/usr/local/bin/discord_bot_deploy.sh` +3. **Service**: `discord.service` runs the bot + +### Webhook Configuration + +- **Endpoint**: `http://10.10.10.6:9000/hooks/discord-bot-deploy` +- **Secret**: `discord-bot-secret` +- **Header**: `X-Gitea-Signature` + +### Manual Deployment + +If needed, you can manually trigger deployment: + +```bash +ssh root@10.10.10.6 +/usr/local/bin/discord_bot_deploy.sh +``` + +## Production Server + +- **Host**: 10.10.10.6 +- **Bot Directory**: `/mnt/discordBot/` +- **Service**: `discord.service` +- **User**: jared + +## Local Development + +The bot requires: +- Python 3 +- discord.py +- python-dotenv +- aiohttp +- mcrcon + +Environment variables in `.env`: +- `DISCORD_TOKEN` + +## Commands + +Run `/help` in Discord to see all available commands. diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..144313c --- /dev/null +++ b/bot.py @@ -0,0 +1,1153 @@ +import discord +import os +import random +import asyncio +import json +import logging +import aiohttp +from datetime import datetime, timedelta +from datetime import time as datetime_time +from pathlib import Path +from dotenv import load_dotenv +from discord import app_commands +from discord.ext import commands, tasks +from discord.utils import get +from itertools import cycle +from mcrcon import MCRcon + +# Create logs directory if it doesn't exist +Path("logs").mkdir(exist_ok=True) + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler(f'logs/discord_{datetime.now().strftime("%Y-%m-%d")}.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger('discord_bot') + +load_dotenv() + +# Configuration - Environment variables with defaults +GUILD_ID = int(os.getenv('GUILD_ID', '605864889927467181')) +AUDIT_CHANNEL_ID = int(os.getenv('AUDIT_CHANNEL_ID', '1340861451392520233')) +ANNOUNCEMENT_CHANNEL_ID = int(os.getenv('ANNOUNCEMENT_CHANNEL_ID', '605864889940050088')) +ADMIN_ROLE_ID = int(os.getenv('ADMIN_ROLE_ID', '605867042104541194')) +MINECRAFT_ROLE_ID = int(os.getenv('MINECRAFT_ROLE_ID', '821163520942145556')) +COOL_KIDS_ROLE_ID = int(os.getenv('COOL_KIDS_ROLE_ID', '788968178117902347')) +OWNER_ID = int(os.getenv('OWNER_ID', '238728085342519296')) +REACTION_MESSAGE_ID = int(os.getenv('REACTION_MESSAGE_ID', '744047519696420914')) +MINECRAFT_RCON_HOST = os.getenv('MINECRAFT_RCON_HOST', '10.10.10.67') +MINECRAFT_RCON_PASSWORD = os.getenv('MINECRAFT_RCON_PASSWORD', '') +STATUS_UPDATE_INTERVAL = int(os.getenv('STATUS_UPDATE_INTERVAL', '300')) # 5 minutes default + +# Emoji to role mapping for reaction roles +EMOJI_ROLE_MAP = { + "Overwatch": "(Overwatch)", + "Minecraft": "(Minecraft)", + "LeagueOfLegends": "(League Of Legends)", + "ClashRoyale": "(Clash Royale)", + "CSGO": "(CSGO)", + "CivilizationVI": "(Civilization VI)", + "Python": "Computer Nerd", + "computer": "Computer Nerd", # Consistent handling for add/remove + "Valorant": "(Valorant)", + "Ark": "(Ark Survival Evolved)", + "AmongUs": "(Among Us)", + "RainbowSixSiege": "(Rainbow Six Siege)", + "Phasmophobia": "(Phasmophobia)", + "StardewValley": "(Stardew Valley)", + "Tarkov": "(Tarkov)", + "LethalCompany": "(Lethal Company)", + "BTD": "(Balloons Tower Defense)", + "HellDivers": "(Hell Divers)", + "ABI": "(Arena Breakout Infinite)", + "UnoReverse": "(Uno)", + "Hytale": "(Hytale)" +} + + +class CustomBot(commands.Bot): + def __init__(self): + intents = discord.Intents.all() + intents.message_content = True + super().__init__( + command_prefix=".", + intents=intents + ) + self.status_cycle = cycle([ + "The Lotus Guild is boomin", + "lotusguild.org", + "Ranked Minesweeper" + ]) + self.remove_command("help") + self.adjectives_data = None + + async def setup_hook(self): + logger.info("Syncing commands...") + await self.tree.sync() + logger.info("Commands synced!") + + # Load adjectives asynchronously + adjectives_path = Path('adjectives.json') + if adjectives_path.exists(): + try: + async with aiohttp.ClientSession() as session: + # Use aiofiles-like approach with asyncio + loop = asyncio.get_event_loop() + self.adjectives_data = await loop.run_in_executor( + None, + lambda: json.loads(adjectives_path.read_text()) + ) + logger.info("Adjectives data loaded successfully") + self.daily_adjective.start() + except Exception as e: + logger.error(f"Failed to load adjectives.json: {e}") + else: + logger.warning("adjectives.json not found, daily adjective task disabled") + + @tasks.loop(time=datetime_time(hour=14)) + async def daily_adjective(self): + try: + channel = self.get_channel(ANNOUNCEMENT_CHANNEL_ID) + if not channel: + logger.error(f"Failed to get announcement channel {ANNOUNCEMENT_CHANNEL_ID}") + return + + if not self.adjectives_data or not self.adjectives_data.get("adjectives"): + logger.warning("No adjectives available") + return + + # Get and remove today's adjective + adjective = self.adjectives_data["adjectives"].pop(0) + + # Create and send embed + embed = discord.Embed( + title="📚 Adjective of the Day", + color=discord.Color.from_rgb(152, 0, 0), + timestamp=datetime.now() + ) + embed.add_field( + name=adjective["word"].capitalize(), + value=adjective["definition"].capitalize(), + inline=False + ) + embed.set_footer(text="Expand your vocabulary!") + + await channel.send(embed=embed) + + # Save updated adjectives list back to file asynchronously + loop = asyncio.get_event_loop() + await loop.run_in_executor( + None, + lambda: Path('adjectives.json').write_text( + json.dumps(self.adjectives_data, indent=4) + ) + ) + + # Reload list if empty + if not self.adjectives_data["adjectives"]: + logger.info("Adjectives list empty, reloading from file") + self.adjectives_data = await loop.run_in_executor( + None, + lambda: json.loads(Path('adjectives.json').read_text()) + ) + except Exception as e: + logger.error(f"Error in daily_adjective task: {e}", exc_info=True) + + @daily_adjective.before_loop + async def before_daily_adjective(self): + await self.wait_until_ready() + + @tasks.loop(seconds=STATUS_UPDATE_INTERVAL) + async def change_status(self): + try: + await self.change_presence(activity=discord.Game(next(self.status_cycle))) + except Exception as e: + logger.error(f"Error changing status: {e}", exc_info=True) + + @change_status.before_loop + async def before_change_status(self): + await self.wait_until_ready() + + async def on_ready(self): + logger.info(f"Bot logged in as: {self.user.name}") + logger.info(f"Registered commands: {[cmd.name for cmd in self.tree.get_commands()]}") + self.change_status.start() + + +client = CustomBot() + + +def is_owner(): + async def predicate(ctx): + return ctx.author.id == OWNER_ID + return commands.check(predicate) + + +def get_role_from_emoji(emoji_name: str) -> str: + """Helper function to map emoji names to role names.""" + return EMOJI_ROLE_MAP.get(emoji_name, emoji_name) + + +async def send_audit_log(message: str, color: int = 0x980000): + """Send a message to the audit log channel.""" + try: + audit_channel = client.get_channel(AUDIT_CHANNEL_ID) + if audit_channel: + embed = discord.Embed( + description=message, + color=color, + timestamp=datetime.now() + ) + await audit_channel.send(embed=embed) + except Exception as e: + logger.error(f"Failed to send audit log: {e}", exc_info=True) + + +async def is_valid_minecraft_username(username: str) -> bool: + """Validate Minecraft username using Mojang API asynchronously.""" + url = f'https://api.mojang.com/users/profiles/minecraft/{username}' + try: + async with aiohttp.ClientSession() as session: + async with session.get(url) as response: + return response.status == 200 + except Exception as e: + logger.error(f"Error validating Minecraft username: {e}") + return False + + +def has_role_check(role_id: int): + """Check if user has a specific role (for slash commands).""" + async def predicate(interaction: discord.Interaction) -> bool: + return any(role.id == role_id for role in interaction.user.roles) + return app_commands.check(predicate) + + +@client.event +async def on_message(message): + try: + logger.info(f"Channel: [{str(message.channel)}] User: {str(message.author)} Content: {message.content}") + await client.process_commands(message) + except Exception as e: + logger.error(f"Error in on_message: {e}", exc_info=True) + + +@client.event +async def on_member_join(member): + try: + logger.info(f"New member joined: {member.name} (ID: {member.id})") + await send_audit_log(f"✨ Member Joined\nUser: {member.mention}\nAccount Created: {member.created_at}") + except Exception as e: + logger.error(f"Error in on_member_join: {e}", exc_info=True) + + +@client.event +async def on_member_remove(member): + try: + logger.info(f"Member left: {member.name} (ID: {member.id})") + # Fix: Use member.name instead of deprecated discriminator + await send_audit_log(f"👋 Member Left\nUser: {member.name} ({member.id})") + except Exception as e: + logger.error(f"Error in on_member_remove: {e}", exc_info=True) + + +@client.event +async def on_message_delete(message): + try: + logger.warning(f"Message deleted in {message.channel}: {message.author}: {message.content}") + await send_audit_log(f"🗑️ Message Deleted\nChannel: {message.channel}\nUser: {message.author}\nContent: {message.content}") + except Exception as e: + logger.error(f"Error in on_message_delete: {e}", exc_info=True) + + +@client.event +async def on_voice_state_update(member, before, after): + try: + if before.channel != after.channel: + if after.channel: + await send_audit_log(f"🎤 Voice Join\nUser: {member.mention}\nChannel: {after.channel.name}") + else: + await send_audit_log(f"🎤 Voice Leave\nUser: {member.mention}\nChannel: {before.channel.name}") + except Exception as e: + logger.error(f"Error in on_voice_state_update: {e}", exc_info=True) + + +@client.event +async def on_integration_create(integration): + try: + await send_audit_log(f"🔌 New Integration Added\nName: {integration.name}\nType: {integration.type}") + except Exception as e: + logger.error(f"Error in on_integration_create: {e}", exc_info=True) + + +@client.event +async def on_scheduled_event_create(event): + try: + await send_audit_log(f"📅 Event Created\nName: {event.name}\nStart Time: {event.start_time}") + except Exception as e: + logger.error(f"Error in on_scheduled_event_create: {e}", exc_info=True) + + +@client.event +async def on_scheduled_event_update(before, after): + try: + await send_audit_log(f"📝 Event Updated\nName: {after.name}\nChanges Made") + except Exception as e: + logger.error(f"Error in on_scheduled_event_update: {e}", exc_info=True) + + +@client.event +async def on_member_update(before, after): + """Unified handler for all member update events.""" + try: + # Check for timeout changes + if before.timed_out_until != after.timed_out_until: + await send_audit_log(f"⏰ Member Timeout\nUser: {after.mention}\nUntil: {after.timed_out_until}") + + # Check for server boost changes + if before.premium_since != after.premium_since: + await send_audit_log(f"⭐ Server Boost\nUser: {after.mention} boosted the server!") + + # Check for nickname changes + if before.nick != after.nick: + await send_audit_log(f"📛 Nickname Change\nUser: {before.mention}\nBefore: {before.nick}\nAfter: {after.nick}") + + # Check for role changes + if before.roles != after.roles: + logger.info(f"Role change for {before.name}: {before.roles} -> {after.roles}") + await send_audit_log( + f"👥 Role Update\nUser: {before.mention}\n" + f"Before: {', '.join([r.name for r in before.roles])}\n" + f"After: {', '.join([r.name for r in after.roles])}" + ) + except Exception as e: + logger.error(f"Error in on_member_update: {e}", exc_info=True) + + +@client.event +async def on_member_ban(guild, user): + try: + await send_audit_log(f"🔨 Member Banned\nUser: {user.name}") + except Exception as e: + logger.error(f"Error in on_member_ban: {e}", exc_info=True) + + +@client.event +async def on_guild_role_update(before, after): + try: + if before.permissions != after.permissions: + await send_audit_log(f"🔑 Role Permissions Updated\nRole: {after.name}") + except Exception as e: + logger.error(f"Error in on_guild_role_update: {e}", exc_info=True) + + +@client.event +async def on_guild_emoji_create(emoji): + try: + await send_audit_log(f"😄 New Emoji Added\nName: {emoji.name}") + except Exception as e: + logger.error(f"Error in on_guild_emoji_create: {e}", exc_info=True) + + +@client.event +async def on_guild_sticker_create(sticker): + try: + await send_audit_log(f"🎯 New Sticker Added\nName: {sticker.name}") + except Exception as e: + logger.error(f"Error in on_guild_sticker_create: {e}", exc_info=True) + + +@client.event +async def on_guild_role_create(role): + try: + await send_audit_log(f"👑 Role Created\nName: {role.name}\nColor: {role.color}") + except Exception as e: + logger.error(f"Error in on_guild_role_create: {e}", exc_info=True) + + +@client.event +async def on_guild_role_delete(role): + try: + await send_audit_log(f"🗑️ Role Deleted\nName: {role.name}") + except Exception as e: + logger.error(f"Error in on_guild_role_delete: {e}", exc_info=True) + + +@client.event +async def on_message_edit(before, after): + try: + # Skip if content didn't change (e.g. embed or pin updates) + if before.content == after.content: + return + + logger.info(f"Message edited in {before.channel}\nBefore: {before.content}\nAfter: {after.content}") + + await send_audit_log( + f"✏️ Message Edited\n" + f"Channel: {before.channel.mention}\n" + f"User: {before.author.mention}\n" + f"Before: {before.content}\n" + f"After: {after.content}" + ) + except Exception as e: + logger.error(f"Error in on_message_edit: {e}", exc_info=True) + + +@client.event +async def on_guild_channel_create(channel): + try: + logger.info(f"Channel created: {channel.name}") + except Exception as e: + logger.error(f"Error in on_guild_channel_create: {e}", exc_info=True) + + +@client.event +async def on_guild_channel_delete(channel): + try: + logger.info(f"Channel deleted: {channel.name}") + except Exception as e: + logger.error(f"Error in on_guild_channel_delete: {e}", exc_info=True) + + +@client.event +async def on_raw_reaction_add(payload): + """Handle reaction role additions.""" + try: + if payload.message_id != REACTION_MESSAGE_ID: + return + + guild = client.get_guild(payload.guild_id) + if not guild: + return + + member = await guild.fetch_member(payload.user_id) + if not member: + logger.warning(f"Member not found: {payload.user_id}") + return + + # Get role name from emoji using helper function + role_name = get_role_from_emoji(payload.emoji.name) + role = discord.utils.get(guild.roles, name=role_name) + + if role: + await member.add_roles(role) + try: + member_role = discord.utils.get(guild.roles, name='Member') + if member_role: + await member.add_roles(member_role) + except Exception as e: + logger.error(f"Failed to add Member role: {e}") + logger.info(f"Role {role_name} added to {member.name}") + else: + logger.warning(f"Role not found: {role_name}") + except Exception as e: + logger.error(f"Error in on_raw_reaction_add: {e}", exc_info=True) + + +@client.event +async def on_raw_reaction_remove(payload): + """Handle reaction role removals.""" + try: + if payload.message_id != REACTION_MESSAGE_ID: + return + + guild = client.get_guild(payload.guild_id) + if not guild: + return + + member = await guild.fetch_member(payload.user_id) + if not member: + logger.warning(f"Member not found: {payload.user_id}") + return + + # Get role name from emoji using helper function + role_name = get_role_from_emoji(payload.emoji.name) + role = discord.utils.get(guild.roles, name=role_name) + + if role: + await member.remove_roles(role) + logger.info(f"Role {role_name} removed from {member.name}") + else: + logger.warning(f"Role not found: {role_name}") + except Exception as e: + logger.error(f"Error in on_raw_reaction_remove: {e}", exc_info=True) + + +@client.tree.command(name="help", description="Shows all available commands") +async def help_command(interaction: discord.Interaction): + try: + embed = discord.Embed( + color=discord.Color.from_rgb(152, 0, 0), + title="Command List", + description="Describes what commands do and how to use them." + ) + + embed.set_author(name="Help Page (Lotus Bot)", icon_url="https://lotusguild.org/Lotus.png") + embed.set_image(url="https://lotusguild.org/favicon.ico") + embed.set_thumbnail(url="https://lotusguild.org/favicon.ico") + + # Basic Commands + embed.add_field(name="/help", value="Shows this help message with all available commands", inline=False) + embed.add_field(name="/ping", value="Shows the bot's response time in milliseconds", inline=False) + + # Fun Commands + embed.add_field(name="/8ball", value="Ask the magic 8-ball a question and receive an answer", inline=False) + embed.add_field(name="/fortune", value="Get your fortune cookie message", inline=False) + embed.add_field(name="/flip", value="Flip a coin and get heads or tails", inline=False) + embed.add_field(name="/roll", value="Roll dice (format: NdS, example: 2d6)", inline=False) + embed.add_field(name="/random", value="Generate a random number between specified range", inline=False) + embed.add_field(name="/rps", value="Play Rock Paper Scissors against the bot", inline=False) + embed.add_field(name="/poll", value="Create a simple yes/no poll", inline=False) + + # Game Commands + embed.add_field(name="/agent", value="Get a random Valorant agent with their role", inline=False) + embed.add_field(name="/champion", value="Get a random League of Legends champion with their lane", inline=False) + embed.add_field(name="/minecraft", value="Whitelists a player on the Minecraft server and shows server info", inline=False) + embed.add_field(name="/problem", value="Express your opinion about Canada geese", inline=False) + + # Interaction Commands + embed.add_field(name="/kill", value="Kill another user with a random animation", inline=False) + embed.add_field(name="/punch", value="Punch another user with a random animation", inline=False) + embed.add_field(name="/hug", value="Hug another user with a random animation", inline=False) + embed.add_field(name="/revive", value="Revive another user with a random animation", inline=False) + + # Admin Commands (Only visible to admins) + if discord.utils.get(interaction.user.roles, id=ADMIN_ROLE_ID): + embed.add_field(name="🛡️ Admin Commands", value="---------------", inline=False) + embed.add_field(name="/clear", value="Clears specified number of messages from the channel", inline=False) + embed.add_field(name="/lockdown", value="Lock a channel to prevent messages", inline=False) + embed.add_field(name="/unlock", value="Unlock a previously locked channel", inline=False) + + embed.set_footer(text="Made by https://lotusguild.org") + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in help command: {e}", exc_info=True) + await interaction.response.send_message("An error occurred while showing help.", ephemeral=True) + + +@client.tree.command(name="ping", description="Check the bot's latency") +async def ping(interaction: discord.Interaction): + try: + await interaction.response.send_message(f"Pong! {round(client.latency * 1000)}ms") + except Exception as e: + logger.error(f"Error in ping command: {e}", exc_info=True) + + +@client.tree.command(name="problem", description="Express your opinion about Canada geese") +async def problem(interaction: discord.Interaction): + try: + media_file = Path("media/canadagoose.gif") + if not media_file.exists(): + logger.warning(f"Media file not found: {media_file}") + await interaction.response.send_message( + "If you got a problem with canada gooses then you got a problem with me and I suggest you let that one marinate." + ) + else: + await interaction.response.send_message( + "If you got a problem with canada gooses then you got a problem with me and I suggest you let that one marinate.", + file=discord.File(str(media_file)) + ) + except Exception as e: + logger.error(f"Error in problem command: {e}", exc_info=True) + await interaction.response.send_message("An error occurred.", ephemeral=True) + + +@client.tree.command(name="clear", description="Clear messages from the channel") +@app_commands.describe(amount="Number of messages to delete") +@has_role_check(ADMIN_ROLE_ID) +async def clear(interaction: discord.Interaction, amount: int = 5): + try: + await interaction.response.defer(ephemeral=True) + deleted = await interaction.channel.purge(limit=amount) + await interaction.followup.send(f"Successfully cleared {len(deleted)} messages!", ephemeral=True) + except Exception as e: + logger.error(f"Error in clear command: {e}", exc_info=True) + await interaction.followup.send("Failed to clear messages.", ephemeral=True) + + +@client.tree.command(name="lockdown", description="Lock a channel") +@has_role_check(ADMIN_ROLE_ID) +async def lockdown(interaction: discord.Interaction, channel: discord.TextChannel = None): + try: + channel = channel or interaction.channel + await channel.set_permissions(interaction.guild.default_role, send_messages=False) + await send_audit_log(f"🔒 Channel Locked\nChannel: {channel.name}") + await interaction.response.send_message(f"Locked {channel.mention}") + except Exception as e: + logger.error(f"Error in lockdown command: {e}", exc_info=True) + await interaction.response.send_message("Failed to lock channel.", ephemeral=True) + + +@client.tree.command(name="unlock", description="Unlock a channel") +@has_role_check(ADMIN_ROLE_ID) +async def unlock(interaction: discord.Interaction, channel: discord.TextChannel = None): + try: + channel = channel or interaction.channel + await channel.set_permissions(interaction.guild.default_role, send_messages=True) + await send_audit_log(f"🔓 Channel Unlocked\nChannel: {channel.name}") + await interaction.response.send_message(f"Unlocked {channel.mention}") + except Exception as e: + logger.error(f"Error in unlock command: {e}", exc_info=True) + await interaction.response.send_message("Failed to unlock channel.", ephemeral=True) + + +@client.tree.command(name="minecraft", description="Whitelist a player on the Minecraft server") +@app_commands.describe(minecraft_username="The Minecraft username to whitelist") +@has_role_check(MINECRAFT_ROLE_ID) +async def minecraft(interaction: discord.Interaction, minecraft_username: str): + try: + # Validate username asynchronously + if not await is_valid_minecraft_username(minecraft_username): + await interaction.response.send_message("Invalid MC Username", ephemeral=True) + return + + # Check if RCON password is configured + if not MINECRAFT_RCON_PASSWORD: + logger.error("MINECRAFT_RCON_PASSWORD not configured") + await interaction.response.send_message( + "Server configuration error. Please contact an administrator.", + ephemeral=True + ) + return + + await interaction.response.defer() + + try: + with MCRcon(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD) as mcr: + response = mcr.command(f"whitelist add {minecraft_username}") + logger.info(f"RCON response: {response}") + except Exception as e: + logger.error(f"RCON error: {e}", exc_info=True) + await interaction.followup.send( + "An error occurred while whitelisting the player (jared will fix just let him know)." + ) + return + + minecraft_embed = discord.Embed( + color=discord.Color.from_rgb(152, 0, 0), + title="Minecraft" + ) + minecraft_embed.set_author( + name="(Lotus Bot)", + icon_url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" + ) + minecraft_embed.set_image( + url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" + ) + minecraft_embed.set_thumbnail( + url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" + ) + minecraft_embed.add_field(name="You", value="have been whitelisted on the SMP", inline=False) + minecraft_embed.add_field(name="Server Address", value="minecraft.lotusguild.org", inline=False) + minecraft_embed.add_field(name="Version", value="1.21.11", inline=False) + minecraft_embed.set_footer(text="Thanks for using Lotus Minecraft Server!") + await interaction.followup.send(embed=minecraft_embed) + except Exception as e: + logger.error(f"Error in minecraft command: {e}", exc_info=True) + if not interaction.response.is_done(): + await interaction.response.send_message("An error occurred.", ephemeral=True) + else: + await interaction.followup.send("An error occurred.", ephemeral=True) + + +# Track last usage per user +ask_cooldowns = {} +COOLDOWN_MINUTES = 2 + + +@client.tree.command(name="ask", description="Ask a question to Lotus LLM") +@app_commands.describe(question="Your question for the AI") +@has_role_check(COOL_KIDS_ROLE_ID) +async def ask(interaction: discord.Interaction, question: str): + try: + # Check cooldown + user_id = interaction.user.id + current_time = datetime.now() + if user_id in ask_cooldowns: + time_diff = current_time - ask_cooldowns[user_id] + if time_diff < timedelta(minutes=COOLDOWN_MINUTES): + remaining = COOLDOWN_MINUTES - (time_diff.seconds / 60) + await interaction.response.send_message( + f"Please wait {remaining:.1f} minutes before asking another question!", + ephemeral=True + ) + return + + await interaction.response.defer() + + # Select model based on user ID + model = "lotusllmben" if user_id == 460640040096104459 else "lotusllm" + logger.info(f"Sending question to Ollama: {question}") + + async with aiohttp.ClientSession() as session: + async with session.post( + "http://10.10.10.157:11434/api/generate", + json={ + "model": model, + "prompt": question, + "stream": True + } + ) as response: + full_response = "" + async for line in response.content: + try: + chunk = json.loads(line) + logger.info(f"Received chunk: {chunk}") + if "response" in chunk: + full_response += chunk["response"] + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON: {e}") + logger.error(f"Raw line: {line}") + + # Update cooldown timestamp + ask_cooldowns[user_id] = current_time + + embed = discord.Embed( + title="Lotus LLM", + description=full_response if full_response else "No response received from server", + color=discord.Color.from_rgb(152, 0, 0) + ) + embed.add_field(name="Question", value=question, inline=False) + embed.set_footer(text=f"Asked by {interaction.user.display_name}") + await interaction.followup.send(embed=embed) + except Exception as e: + logger.error(f"Error in ask command: {e}", exc_info=True) + if not interaction.response.is_done(): + await interaction.response.send_message("An error occurred.", ephemeral=True) + else: + await interaction.followup.send("An error occurred.", ephemeral=True) + + +@client.tree.command(name="8ball", description="Ask the magic 8-ball a question") +@app_commands.describe(question="What would you like to ask the 8ball?") +async def eight_ball(interaction: discord.Interaction, question: str): + try: + possible_responses = [ + # Positive answers + "It is certain", "Without a doubt", "You may rely on it", + "Yes definitely", "It is decidedly so", "As I see it, yes", + "Most likely", "Yes sir!", "Hell yeah my dude", "100% easily", + # Neutral answers + "Reply hazy try again", "Ask again later", "Better not tell you now", + "Cannot predict now", "Concentrate and ask again", "Idk bro", + # Negative answers + "Don't count on it", "My reply is no", "My sources say no", + "Outlook not so good", "Very doubtful", "Hell no", "Prolly not" + ] + + embed = discord.Embed( + color=discord.Color.from_rgb(152, 0, 0), + title="🎱 Magic 8-Ball" + ) + embed.add_field(name="Question", value=question, inline=False) + embed.add_field(name="Answer", value=random.choice(possible_responses), inline=False) + embed.set_footer(text=f"Asked by {interaction.user.display_name}") + + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in 8ball command: {e}", exc_info=True) + + +@client.tree.command(name="fortune", description="Get your fortune cookie message") +async def fortune(interaction: discord.Interaction): + try: + possible_responses = [ + # Humorous fortunes + "If you eat something & nobody sees you eat it, it has no calories", + "Your pet is plotting world domination", + "Error 404: Fortune not found. Try again after system reboot", + "The fortune you seek is in another cookie", + + # Classic fortunes with a twist + "A journey of a thousand miles begins with ordering delivery", + "You will find great fortune... in between your couch cushions", + "A true friend is someone who tells you when your stream is muted", + + # Gaming-themed fortunes + "Your next competitive match will be legendary", + "The cake is still a lie", + "Press Alt+F4 for instant success", + "You will not encounter any campers today", + "Your tank will have a healer", + "No one will steal your pentakill", + "Your random teammate will have a mic", + "You will find diamonds on your first dig", + "The boss will drop the rare loot", + "Your speedrun will be WR pace", + "No lag spikes in your next match", + "Your gaming chair will grant you powers", + "The RNG gods will bless you", + "You will not get third partied", + "Your squad will actually stick together", + "The enemy team will forfeit at 15", + "Your aim will be crispy today", + "You will escape the backrooms", + "The imposter will not sus you", + "Your Minecraft bed will remain unbroken", + "You will get Play of the Game", + + # Internet culture fortunes + "Your next meme will go viral", + "Someone is talking about you in their Discord server", + "Your FBI agent thinks you're hilarious", + "Your next TikTok will hit the FYP, if the government doesn't ban it first", + "Someone will actually read your Twitter thread", + "Your DMs will be blessed with quality memes today", + "Touch grass (respectfully)", + "The algorithm will be in your favor today", + "Your next Spotify shuffle will hit different", + "Someone saved your Instagram post", + "Your Reddit comment will get gold", + "POV: You're about to go viral", + "Main character energy detected", + "No cap, you're gonna have a great day fr fr", + "Your rizz levels are increasing", + "You will not get ratio'd today", + "Someone will actually use your custom emoji", + "Your next selfie will be iconic", + + # Original favorites + "Buy a dolphin - your life will have a porpoise", + "Stop procrastinating - starting tomorrow", + "Catch fire with enthusiasm - people will come for miles to watch you burn", + + # Tech & computer nerd fortunes + "Your code will compile on the first try today", + "A semicolon will save your day", + "The bug you've been hunting is just a typo", + "Your next Git commit will be perfect", + "You will find the solution on the first StackOverflow link", + "Your Docker container will build without errors", + "The cloud is just someone else's computer", + "Your backup strategy will soon prove its worth", + "A mechanical keyboard is in your future", + "You will finally understand regex... maybe", + "Your CSS will align perfectly on the first try", + "Someone will star your GitHub repo today", + "Your Linux installation will not break after updates", + "You will remember to push your changes before shutdown", + "Your code comments will actually make sense in 6 months", + "The missing curly brace is on line 247", + "Have you tried turning it off and on again?", + "Your next pull request will be merged without comments", + "Your keyboard RGB will sync perfectly today", + "You will find that memory leak", + "Your next algorithm will have O(1) complexity", + "The force quit was strong with this one", + "Ctrl+S will save you today", + "Your next Python script will need no debugging", + "Your next API call will return 200 OK" + ] + + embed = discord.Embed( + color=discord.Color.from_rgb(152, 0, 0), + title="🥠 Fortune Cookie" + ) + embed.add_field(name="Your Fortune", value=random.choice(possible_responses), inline=False) + embed.set_footer(text=f"Cracked open by {interaction.user.display_name}") + + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in fortune command: {e}", exc_info=True) + + +@client.tree.command(name="flip", description="Flip a coin") +async def flip(interaction: discord.Interaction): + try: + result = random.choice(["Heads", "Tails"]) + embed = discord.Embed(title="🪙 Coin Flip", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Result", value=result) + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in flip command: {e}", exc_info=True) + + +@client.tree.command(name="roll", description="Roll dice (e.g. 2d6)") +@app_commands.describe(dice="Format: NdS (N=number of dice, S=sides)") +async def roll(interaction: discord.Interaction, dice: str = "1d6"): + try: + num, sides = map(int, dice.lower().split('d')) + results = [random.randint(1, sides) for _ in range(num)] + embed = discord.Embed(title="🎲 Dice Roll", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Results", value=f"Rolls: {results}\nTotal: {sum(results)}") + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in roll command: {e}", exc_info=True) + await interaction.response.send_message("Please use format: NdS (example: 2d6)", ephemeral=True) + + +@client.tree.command(name="random", description="Generate a random number") +@app_commands.describe(min="Minimum number", max="Maximum number") +async def random_number(interaction: discord.Interaction, min: int = 1, max: int = 100): + try: + result = random.randint(min, max) + embed = discord.Embed(title="🎯 Random Number", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Result", value=str(result)) + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in random command: {e}", exc_info=True) + + +@client.tree.command(name="rps", description="Play Rock Paper Scissors") +@app_commands.describe(choice="Your choice: rock, paper, or scissors") +async def rps(interaction: discord.Interaction, choice: str): + try: + choices = ["rock", "paper", "scissors"] + bot_choice = random.choice(choices) + choice = choice.lower() + + if choice not in choices: + await interaction.response.send_message("Please choose rock, paper, or scissors!", ephemeral=True) + return + + embed = discord.Embed(title="✂️ Rock Paper Scissors", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Your Choice", value=choice.capitalize()) + embed.add_field(name="Bot's Choice", value=bot_choice.capitalize()) + + if choice == bot_choice: + result = "It's a tie!" + elif (choice == "rock" and bot_choice == "scissors") or \ + (choice == "paper" and bot_choice == "rock") or \ + (choice == "scissors" and bot_choice == "paper"): + result = "You win!" + else: + result = "Bot wins!" + + embed.add_field(name="Result", value=result, inline=False) + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in rps command: {e}", exc_info=True) + + +@client.tree.command(name="poll", description="Create a simple yes/no poll") +@app_commands.describe(question="The poll question") +async def poll(interaction: discord.Interaction, question: str): + try: + embed = discord.Embed(title="📊 Poll", description=question, color=discord.Color.from_rgb(152, 0, 0)) + embed.set_footer(text=f"Created by {interaction.user.display_name}") + await interaction.response.send_message(embed=embed) + message = await interaction.original_response() + await message.add_reaction("👍") + await message.add_reaction("👎") + except Exception as e: + logger.error(f"Error in poll command: {e}", exc_info=True) + + +@client.tree.command(name="kill", description="Kill another user") +@app_commands.describe(member="The user to kill") +async def kill(interaction: discord.Interaction, member: discord.Member): + try: + kills = ["media/kill.gif", "media/kill1.gif", "media/kill2.gif", "media/kill3.gif"] + available_kills = [k for k in kills if Path(k).exists()] + + if not available_kills: + logger.warning("No kill media files found") + await interaction.response.send_message(f"You killed {member}") + else: + await interaction.response.send_message( + f"You killed {member}", + file=discord.File(random.choice(available_kills)) + ) + except Exception as e: + logger.error(f"Error in kill command: {e}", exc_info=True) + + +@client.tree.command(name="punch", description="Punch another user") +@app_commands.describe(member="The user to punch") +async def punch(interaction: discord.Interaction, member: discord.Member): + try: + punches = ["media/punch.gif", "media/punch1.gif", "media/punch2.gif", "media/punch3.gif"] + available_punches = [p for p in punches if Path(p).exists()] + + if not available_punches: + logger.warning("No punch media files found") + await interaction.response.send_message(f"You punched {member}") + else: + await interaction.response.send_message( + f"You punched {member}", + file=discord.File(random.choice(available_punches)) + ) + except Exception as e: + logger.error(f"Error in punch command: {e}", exc_info=True) + + +@client.tree.command(name="hug", description="Hug another user") +@app_commands.describe(member="The user to hug") +async def hug(interaction: discord.Interaction, member: discord.Member): + try: + hugs = ["media/hug.gif", "media/hug1.gif", "media/hug2.gif", "media/hug3.gif"] + available_hugs = [h for h in hugs if Path(h).exists()] + + if not available_hugs: + logger.warning("No hug media files found") + await interaction.response.send_message(f"{member} has been squeezed tightly!") + else: + await interaction.response.send_message( + f"{member} has been squeezed tightly!", + file=discord.File(random.choice(available_hugs)) + ) + except Exception as e: + logger.error(f"Error in hug command: {e}", exc_info=True) + + +@client.tree.command(name="revive", description="Revive another user") +@app_commands.describe(member="The user to revive") +async def revive(interaction: discord.Interaction, member: discord.Member): + try: + revives = ["media/revive.gif", "media/revive1.gif", "media/revive2.gif", "media/revive3.gif"] + available_revives = [r for r in revives if Path(r).exists()] + + if not available_revives: + logger.warning("No revive media files found") + await interaction.response.send_message(f"{member} has been brought back to life") + else: + await interaction.response.send_message( + f"{member} has been brought back to life", + file=discord.File(random.choice(available_revives)) + ) + except Exception as e: + logger.error(f"Error in revive command: {e}", exc_info=True) + + +@client.tree.command(name="agent", description="Get a random Valorant agent") +async def agent(interaction: discord.Interaction): + try: + agents = { + "Duelists": ["Jett", "Phoenix", "Raze", "Reyna", "Yoru", "Neon", "Iso"], + "Controllers": ["Brimstone", "Viper", "Omen", "Astra", "Harbor"], + "Initiators": ["Sova", "Breach", "Skye", "KAY/O", "Fade", "Gekko"], + "Sentinels": ["Killjoy", "Cypher", "Sage", "Chamber", "Deadlock"] + } + + # Pick a random category and agent + category = random.choice(list(agents.keys())) + agent = random.choice(agents[category]) + + embed = discord.Embed( + title="🎯 Valorant Agent Picker", + color=discord.Color.from_rgb(152, 0, 0) + ) + embed.add_field(name="Selected Agent", value=agent, inline=False) + embed.add_field(name="Role", value=category, inline=False) + embed.set_footer(text=f"Selected for {interaction.user.display_name}") + + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in agent command: {e}", exc_info=True) + + +@client.tree.command(name="champion", description="Get a random League of Legends champion") +async def champion(interaction: discord.Interaction): + try: + champions = { + "Top": ["Aatrox", "Camille", "Darius", "Fiora", "Garen", "Irelia", "Jax", "K'Sante", "Malphite", "Mordekaiser", "Nasus", "Ornn", "Riven", "Sett", "Teemo"], + "Jungle": ["Bel'Veth", "Diana", "Elise", "Evelynn", "Graves", "Hecarim", "Kayn", "Kindred", "Lee Sin", "Master Yi", "Nidalee", "Rammus", "Viego", "Warwick"], + "Mid": ["Ahri", "Akali", "Annie", "Cassiopeia", "Fizz", "Katarina", "LeBlanc", "Lux", "Orianna", "Syndra", "Twisted Fate", "Veigar", "Yasuo", "Zed"], + "Bot": ["Aphelios", "Ashe", "Caitlyn", "Draven", "Ezreal", "Jhin", "Jinx", "Kai'Sa", "Lucian", "Miss Fortune", "Samira", "Tristana", "Vayne", "Xayah"], + "Support": ["Blitzcrank", "Brand", "Janna", "Leona", "Lulu", "Morgana", "Nautilus", "Pyke", "Rakan", "Senna", "Seraphine", "Soraka", "Thresh", "Yuumi"] + } + + # Pick a random lane and champion + lane = random.choice(list(champions.keys())) + champion = random.choice(champions[lane]) + + embed = discord.Embed( + title="⚔️ League Champion Picker", + color=discord.Color.from_rgb(152, 0, 0) + ) + embed.add_field(name="Selected Champion", value=champion, inline=False) + embed.add_field(name="Lane", value=lane, inline=False) + embed.set_footer(text=f"Selected for {interaction.user.display_name}") + + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in champion command: {e}", exc_info=True) + + +@client.tree.command(name="trivia", description="Play a trivia game") +async def trivia(interaction: discord.Interaction): + try: + class TriviaView(discord.ui.View): + def __init__(self, correct_answer): + super().__init__() + self.correct_answer = correct_answer + + @discord.ui.button(label="A", style=discord.ButtonStyle.primary) + async def answer_a(self, interaction: discord.Interaction, button: discord.ui.Button): + if "A" == self.correct_answer: + await interaction.response.send_message("Correct!", ephemeral=True) + else: + await interaction.response.send_message("Wrong!", ephemeral=True) + + # Example usage + embed = discord.Embed(title="Trivia Question", description="What is 2+2?") + embed.add_field(name="A", value="4") + embed.add_field(name="B", value="5") + await interaction.response.send_message(embed=embed, view=TriviaView("A")) + except Exception as e: + logger.error(f"Error in trivia command: {e}", exc_info=True) + + +@client.tree.command(name="userinfo", description="Get information about a user") +@app_commands.describe(member="The user to get information about") +async def userinfo(interaction: discord.Interaction, member: discord.Member): + try: + embed = discord.Embed(title="User Information", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Joined Server", value=member.joined_at.strftime("%Y-%m-%d")) + embed.add_field(name="Account Created", value=member.created_at.strftime("%Y-%m-%d")) + embed.add_field(name="Roles", value=", ".join([role.name for role in member.roles[1:]])) + await interaction.response.send_message(embed=embed) + except Exception as e: + logger.error(f"Error in userinfo command: {e}", exc_info=True) + + +@client.event +async def on_command_error(ctx, error): + """Handle prefix command errors.""" + try: + if isinstance(error, commands.CommandNotFound): + await ctx.send("Command not found!") + logger.error(f"Command error: {str(error)}") + elif isinstance(error, commands.MissingPermissions): + await ctx.send("You don't have permission to use this command!") + logger.error(f"Command error: {str(error)}") + except Exception as e: + logger.error(f"Error in on_command_error: {e}", exc_info=True) + + +@client.tree.error +async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): + """Handle slash command errors.""" + try: + if isinstance(error, app_commands.CheckFailure): + await interaction.response.send_message( + "You don't have permission to use this command!", + ephemeral=True + ) + logger.warning(f"Permission denied for {interaction.user} on command {interaction.command.name}") + elif isinstance(error, app_commands.CommandOnCooldown): + await interaction.response.send_message( + f"This command is on cooldown. Try again in {error.retry_after:.2f} seconds.", + ephemeral=True + ) + else: + await interaction.response.send_message( + "An error occurred while executing this command.", + ephemeral=True + ) + logger.error(f"App command error: {str(error)}", exc_info=True) + except Exception as e: + logger.error(f"Error in on_app_command_error: {e}", exc_info=True) + + +if __name__ == "__main__": + token = os.getenv('DISCORD_TOKEN') + if not token: + logger.error("DISCORD_TOKEN not found in environment variables") + exit(1) + + logger.info("Starting bot...") + client.run(token) diff --git a/deployment/discord_bot_deploy.sh b/deployment/discord_bot_deploy.sh new file mode 100644 index 0000000..1c53193 --- /dev/null +++ b/deployment/discord_bot_deploy.sh @@ -0,0 +1,57 @@ +#!/bin/bash +set -e + +BOTDIR="/root/code/discordBot" +SERVICE_NAME="discord-bot" + +echo "[Discord Bot] Pulling latest code..." + +# Backup .env if it exists +if [ -f "$BOTDIR/.env" ]; then + cp "$BOTDIR/.env" /tmp/.env.backup +fi + +# Backup media and logs folders +if [ -d "$BOTDIR/media" ]; then + cp -r "$BOTDIR/media" /tmp/media.backup +fi +if [ -d "$BOTDIR/logs" ]; then + cp -r "$BOTDIR/logs" /tmp/logs.backup +fi + +if [ ! -d "$BOTDIR/.git" ]; then + echo "Directory not a git repo — performing initial clone..." + rm -rf "$BOTDIR" + git clone https://code.lotusguild.org/LotusGuild/discordBot.git "$BOTDIR" +else + echo "Updating existing repo..." + cd "$BOTDIR" + git fetch --all + git reset --hard origin/main +fi + +# Restore .env if it was backed up +if [ -f /tmp/.env.backup ]; then + mv /tmp/.env.backup "$BOTDIR/.env" +fi + +# Restore media and logs folders +if [ -d /tmp/media.backup ]; then + rm -rf "$BOTDIR/media" + mv /tmp/media.backup "$BOTDIR/media" +fi +if [ -d /tmp/logs.backup ]; then + rm -rf "$BOTDIR/logs" + mv /tmp/logs.backup "$BOTDIR/logs" +fi + +echo "[Discord Bot] Installing/updating dependencies..." +cd "$BOTDIR" +pip3 install -r requirements.txt 2>/dev/null || echo "No requirements.txt found, skipping..." + +echo "[Discord Bot] Restarting service..." +systemctl restart "$SERVICE_NAME" + +echo "[Discord Bot] Deployment complete!" +echo "[Discord Bot] Service status:" +systemctl status "$SERVICE_NAME" --no-pager diff --git a/deployment/hooks.json b/deployment/hooks.json new file mode 100644 index 0000000..91389cb --- /dev/null +++ b/deployment/hooks.json @@ -0,0 +1,18 @@ +[ + { + "id": "discord-bot-deploy", + "execute-command": "/usr/local/bin/discord_bot_deploy.sh", + "command-working-directory": "/root/code/discordBot", + "response-message": "Deploying Discord bot...", + "trigger-rule": { + "match": { + "type": "payload-hash-sha256", + "secret": "CHANGE_THIS_SECRET_KEY", + "parameter": { + "source": "header", + "name": "X-Gitea-Signature" + } + } + } + } +]