Compare commits

...

1 Commits

Author SHA1 Message Date
715d778080 Clean up issue comments, remove dead code, fix audit logger bugs
- Remove all 15 "Issue #XX" reference comments from production code
- Remove dead code: CooldownManager class (unused, using discord.py cooldown),
  is_owner() function (unreferenced), unused imports (datetime_time, get)
- Remove unused env vars: ANNOUNCEMENT_CHANNEL_ID, PELICAN_URL, HYTALE_SERVER_UUID
- Fix AuditLogger.flush() dropping items when queue > 10 (now processes in batches)
- Fix AuditLogger.log() using .seconds instead of .total_seconds() (broke after 1+ days)
- Use unicode escapes for emoji in poll reactions for consistency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 20:49:55 -05:00

145
bot.py
View File

@@ -7,27 +7,23 @@ import logging
from logging.handlers import RotatingFileHandler from logging.handlers import RotatingFileHandler
from collections import Counter from collections import Counter
from datetime import datetime, timedelta from datetime import datetime, timedelta
from datetime import time as datetime_time
from pathlib import Path from pathlib import Path
from functools import wraps, partial from functools import wraps, partial
from dotenv import load_dotenv from dotenv import load_dotenv
from discord import app_commands from discord import app_commands
from discord.ext import commands, tasks from discord.ext import commands, tasks
from discord.utils import get
from itertools import cycle from itertools import cycle
from mcrcon import MCRcon from mcrcon import MCRcon
import aiohttp import aiohttp
# Create logs directory if it doesn't exist
Path("logs").mkdir(exist_ok=True) Path("logs").mkdir(exist_ok=True)
# --- Issue #21: Improve Logging Configuration ---
def setup_logging(): def setup_logging():
"""Configure logging with rotation""" """Configure logging with rotation"""
logger = logging.getLogger('discord_bot') logger = logging.getLogger('discord_bot')
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
# Rotating file handler (10MB max, keep 5 backups)
file_handler = RotatingFileHandler( file_handler = RotatingFileHandler(
'logs/discord.log', 'logs/discord.log',
maxBytes=10*1024*1024, maxBytes=10*1024*1024,
@@ -46,14 +42,14 @@ def setup_logging():
logger.addHandler(stream_handler) logger.addHandler(stream_handler)
return logger return logger
logger = setup_logging() logger = setup_logging()
load_dotenv() load_dotenv()
# Configuration - Environment variables with defaults # Configuration
GUILD_ID = int(os.getenv('GUILD_ID', '605864889927467181')) GUILD_ID = int(os.getenv('GUILD_ID', '605864889927467181'))
AUDIT_CHANNEL_ID = int(os.getenv('AUDIT_CHANNEL_ID', '1340861451392520233')) 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')) ADMIN_ROLE_ID = int(os.getenv('ADMIN_ROLE_ID', '605867042104541194'))
MINECRAFT_ROLE_ID = int(os.getenv('MINECRAFT_ROLE_ID', '821163520942145556')) MINECRAFT_ROLE_ID = int(os.getenv('MINECRAFT_ROLE_ID', '821163520942145556'))
HYTALE_ROLE_ID = int(os.getenv('HYTALE_ROLE_ID', '1460750779848589548')) HYTALE_ROLE_ID = int(os.getenv('HYTALE_ROLE_ID', '1460750779848589548'))
@@ -62,15 +58,11 @@ OWNER_ID = int(os.getenv('OWNER_ID', '238728085342519296'))
REACTION_MESSAGE_ID = int(os.getenv('REACTION_MESSAGE_ID', '744047519696420914')) REACTION_MESSAGE_ID = int(os.getenv('REACTION_MESSAGE_ID', '744047519696420914'))
MINECRAFT_RCON_HOST = os.getenv('MINECRAFT_RCON_HOST', '10.10.10.67') MINECRAFT_RCON_HOST = os.getenv('MINECRAFT_RCON_HOST', '10.10.10.67')
MINECRAFT_RCON_PASSWORD = os.getenv('MINECRAFT_RCON_PASSWORD', '') MINECRAFT_RCON_PASSWORD = os.getenv('MINECRAFT_RCON_PASSWORD', '')
PELICAN_URL = os.getenv('PELICAN_URL', 'http://10.10.10.67')
PELICAN_API_KEY = os.getenv('PELICAN_API_KEY', '') PELICAN_API_KEY = os.getenv('PELICAN_API_KEY', '')
HYTALE_SERVER_UUID = os.getenv('HYTALE_SERVER_UUID', '7a656836-c3f3-491e-ac55-66affe435e72')
STATUS_UPDATE_INTERVAL = int(os.getenv('STATUS_UPDATE_INTERVAL', '300')) STATUS_UPDATE_INTERVAL = int(os.getenv('STATUS_UPDATE_INTERVAL', '300'))
# --- Issue #7: Hardcoded URLs - Use env vars for internal services ---
OLLAMA_URL = os.getenv('OLLAMA_HOST', 'http://10.10.10.157:11434') OLLAMA_URL = os.getenv('OLLAMA_HOST', 'http://10.10.10.157:11434')
# --- Issue #11: Magic Numbers - Extract constants --- # Constants
MIN_USERNAME_LENGTH = 3 MIN_USERNAME_LENGTH = 3
MAX_USERNAME_LENGTH = 16 MAX_USERNAME_LENGTH = 16
MAX_INPUT_LENGTH = 500 MAX_INPUT_LENGTH = 500
@@ -82,7 +74,6 @@ USERNAME_CACHE_TTL_MINUTES = 60
AUDIT_BATCH_SIZE = 5 AUDIT_BATCH_SIZE = 5
AUDIT_FLUSH_INTERVAL = 30 AUDIT_FLUSH_INTERVAL = 30
# Emoji to role mapping for reaction roles
EMOJI_ROLE_MAP = { EMOJI_ROLE_MAP = {
"Overwatch": "(Overwatch)", "Overwatch": "(Overwatch)",
"Minecraft": "(Minecraft)", "Minecraft": "(Minecraft)",
@@ -108,7 +99,6 @@ EMOJI_ROLE_MAP = {
} }
# --- Issue #17: Configuration Validator ---
class ConfigValidator: class ConfigValidator:
REQUIRED = ['DISCORD_TOKEN'] REQUIRED = ['DISCORD_TOKEN']
OPTIONAL = { OPTIONAL = {
@@ -119,7 +109,6 @@ class ConfigValidator:
@classmethod @classmethod
def validate(cls): def validate(cls):
"""Validate configuration on startup"""
errors = [] errors = []
warnings = [] warnings = []
@@ -134,31 +123,6 @@ class ConfigValidator:
return errors, warnings return errors, warnings
# --- Issue #2: Memory Leak in Cooldown System ---
class CooldownManager:
def __init__(self, minutes: int):
self.cooldowns = {}
self.duration = timedelta(minutes=minutes)
def check(self, user_id: int) -> tuple:
"""Returns (can_use, remaining_minutes)"""
current_time = datetime.now()
if user_id in self.cooldowns:
time_diff = current_time - self.cooldowns[user_id]
if time_diff < self.duration:
remaining = self.duration.total_seconds() - time_diff.total_seconds()
return False, remaining / 60
return True, 0
def update(self, user_id: int):
self.cooldowns[user_id] = datetime.now()
# Cleanup old entries (>24 hours)
cutoff = datetime.now() - timedelta(hours=24)
self.cooldowns = {k: v for k, v in self.cooldowns.items() if v > cutoff}
# --- Issue #12: Cache Username Validations ---
class UsernameCache: class UsernameCache:
def __init__(self, ttl_minutes: int = 60): def __init__(self, ttl_minutes: int = 60):
self.cache = {} self.cache = {}
@@ -184,13 +148,11 @@ class UsernameCache:
username_cache = UsernameCache(ttl_minutes=USERNAME_CACHE_TTL_MINUTES) username_cache = UsernameCache(ttl_minutes=USERNAME_CACHE_TTL_MINUTES)
# --- Issue #13: Lazy Load Media Files ---
class MediaManager: class MediaManager:
def __init__(self): def __init__(self):
self._cache = {} self._cache = {}
def get_random(self, category: str) -> str | None: def get_random(self, category: str) -> str | None:
"""Get random media file from category"""
if category not in self._cache: if category not in self._cache:
patterns = [f"media/{category}.gif", f"media/{category}1.gif", patterns = [f"media/{category}.gif", f"media/{category}1.gif",
f"media/{category}2.gif", f"media/{category}3.gif"] f"media/{category}2.gif", f"media/{category}3.gif"]
@@ -203,7 +165,6 @@ class MediaManager:
media_manager = MediaManager() media_manager = MediaManager()
# --- Issue #14: Batch Audit Logs ---
class AuditLogger: class AuditLogger:
def __init__(self, channel_id: int, batch_size: int = AUDIT_BATCH_SIZE, def __init__(self, channel_id: int, batch_size: int = AUDIT_BATCH_SIZE,
flush_interval: int = AUDIT_FLUSH_INTERVAL): flush_interval: int = AUDIT_FLUSH_INTERVAL):
@@ -216,7 +177,7 @@ class AuditLogger:
async def log(self, message: str, color: int = 0x980000): async def log(self, message: str, color: int = 0x980000):
self.queue.append((message, color, datetime.now())) self.queue.append((message, color, datetime.now()))
if len(self.queue) >= self.batch_size or \ if len(self.queue) >= self.batch_size or \
(datetime.now() - self.last_flush).seconds >= self.flush_interval: (datetime.now() - self.last_flush).total_seconds() >= self.flush_interval:
await self.flush() await self.flush()
async def flush(self): async def flush(self):
@@ -227,23 +188,27 @@ class AuditLogger:
if not channel: if not channel:
return return
embed = discord.Embed(color=0x980000, timestamp=datetime.now()) # Process in batches of 10 (Discord embed field limit)
for msg, _, timestamp in self.queue[:10]: while self.queue:
embed.add_field(name=timestamp.strftime("%H:%M:%S"), value=msg[:1024], inline=False) batch = self.queue[:10]
self.queue = self.queue[10:]
try: embed = discord.Embed(color=0x980000, timestamp=datetime.now())
await channel.send(embed=embed) for msg, _, timestamp in batch:
except Exception as e: embed.add_field(name=timestamp.strftime("%H:%M:%S"), value=msg[:1024], inline=False)
logger.error(f"Failed to send audit log: {e}", exc_info=True)
try:
await channel.send(embed=embed)
except Exception as e:
logger.error(f"Failed to send audit log: {e}", exc_info=True)
break
self.queue.clear()
self.last_flush = datetime.now() self.last_flush = datetime.now()
audit_logger = AuditLogger(AUDIT_CHANNEL_ID) audit_logger = AuditLogger(AUDIT_CHANNEL_ID)
# --- Issue #20: Metrics Collection ---
class MetricsCollector: class MetricsCollector:
def __init__(self): def __init__(self):
self.command_counts = Counter() self.command_counts = Counter()
@@ -269,15 +234,12 @@ class MetricsCollector:
metrics = MetricsCollector() metrics = MetricsCollector()
# --- Issue #8: Input Sanitization ---
def sanitize_input(text: str, max_length: int = MAX_INPUT_LENGTH) -> str: def sanitize_input(text: str, max_length: int = MAX_INPUT_LENGTH) -> str:
"""Sanitize user input"""
text = text.strip()[:max_length] text = text.strip()[:max_length]
text = ''.join(char for char in text if char.isprintable()) text = ''.join(char for char in text if char.isprintable())
return text return text
# --- Issue #9: Inconsistent Error Handling ---
def handle_command_errors(func): def handle_command_errors(func):
@wraps(func) @wraps(func)
async def wrapper(interaction: discord.Interaction, *args, **kwargs): async def wrapper(interaction: discord.Interaction, *args, **kwargs):
@@ -295,15 +257,12 @@ def handle_command_errors(func):
return wrapper return wrapper
# --- Issue #5: Race Condition in RCON ---
def execute_rcon(host: str, password: str, command: str): def execute_rcon(host: str, password: str, command: str):
"""Execute RCON command synchronously (for use with executor)"""
with MCRcon(host, password, timeout=3) as mcr: with MCRcon(host, password, timeout=3) as mcr:
return mcr.command(command) return mcr.command(command)
async def rcon_command(host: str, password: str, command: str, timeout: float = RCON_TIMEOUT): async def rcon_command(host: str, password: str, command: str, timeout: float = RCON_TIMEOUT):
"""Execute RCON command with timeout in executor"""
try: try:
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
func = partial(execute_rcon, host, password, command) func = partial(execute_rcon, host, password, command)
@@ -314,14 +273,12 @@ async def rcon_command(host: str, password: str, command: str, timeout: float =
raise Exception(f"RCON error: {str(e)}") raise Exception(f"RCON error: {str(e)}")
# --- Issue #10: Duplicate Code for Media Files ---
async def send_interaction_media( async def send_interaction_media(
interaction: discord.Interaction, interaction: discord.Interaction,
member: discord.Member, member: discord.Member,
action: str, action: str,
media_prefix: str media_prefix: str
): ):
"""Generic function for interaction commands"""
messages = { messages = {
'kill': f"You killed {member}", 'kill': f"You killed {member}",
'punch': f"You punched {member}", 'punch': f"You punched {member}",
@@ -339,13 +296,11 @@ async def send_interaction_media(
await interaction.response.send_message(message) await interaction.response.send_message(message)
# --- Issue #15: Track whitelisted usernames for autocomplete ---
whitelisted_usernames = set() whitelisted_usernames = set()
WHITELIST_FILE = Path("data/whitelisted_usernames.json") WHITELIST_FILE = Path("data/whitelisted_usernames.json")
def load_whitelisted_usernames(): def load_whitelisted_usernames():
"""Load previously whitelisted usernames from file"""
global whitelisted_usernames global whitelisted_usernames
try: try:
if WHITELIST_FILE.exists(): if WHITELIST_FILE.exists():
@@ -356,7 +311,6 @@ def load_whitelisted_usernames():
def save_whitelisted_username(username: str): def save_whitelisted_username(username: str):
"""Save a whitelisted username to file"""
whitelisted_usernames.add(username) whitelisted_usernames.add(username)
try: try:
WHITELIST_FILE.parent.mkdir(parents=True, exist_ok=True) WHITELIST_FILE.parent.mkdir(parents=True, exist_ok=True)
@@ -370,7 +324,6 @@ async def minecraft_username_autocomplete(
interaction: discord.Interaction, interaction: discord.Interaction,
current: str current: str
) -> list: ) -> list:
"""Suggest previously whitelisted usernames"""
return [ return [
app_commands.Choice(name=username, value=username) app_commands.Choice(name=username, value=username)
for username in sorted(whitelisted_usernames) for username in sorted(whitelisted_usernames)
@@ -378,7 +331,6 @@ async def minecraft_username_autocomplete(
][:25] ][:25]
# --- Issue #4: Unclosed aiohttp Sessions ---
class CustomBot(commands.Bot): class CustomBot(commands.Bot):
def __init__(self): def __init__(self):
intents = discord.Intents.all() intents = discord.Intents.all()
@@ -398,7 +350,6 @@ class CustomBot(commands.Bot):
async def setup_hook(self): async def setup_hook(self):
self.http_session = aiohttp.ClientSession() self.http_session = aiohttp.ClientSession()
# --- Issue #3: Validate configs on startup ---
if not MINECRAFT_RCON_PASSWORD: if not MINECRAFT_RCON_PASSWORD:
logger.warning("MINECRAFT_RCON_PASSWORD not set - /minecraft command will fail") logger.warning("MINECRAFT_RCON_PASSWORD not set - /minecraft command will fail")
if not PELICAN_API_KEY: if not PELICAN_API_KEY:
@@ -437,7 +388,6 @@ class CustomBot(commands.Bot):
@tasks.loop(seconds=AUDIT_FLUSH_INTERVAL) @tasks.loop(seconds=AUDIT_FLUSH_INTERVAL)
async def flush_audit_logs(self): async def flush_audit_logs(self):
"""Periodically flush batched audit logs"""
await audit_logger.flush() await audit_logger.flush()
@flush_audit_logs.before_loop @flush_audit_logs.before_loop
@@ -448,33 +398,24 @@ class CustomBot(commands.Bot):
client = CustomBot() 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: 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) return EMOJI_ROLE_MAP.get(emoji_name, emoji_name)
def has_role_check(role_id: int): def has_role_check(role_id: int):
"""Check if user has a specific role (for slash commands)."""
async def predicate(interaction: discord.Interaction) -> bool: async def predicate(interaction: discord.Interaction) -> bool:
return any(role.id == role_id for role in interaction.user.roles) return any(role.id == role_id for role in interaction.user.roles)
return app_commands.check(predicate) return app_commands.check(predicate)
# --- Issue #6: Only log commands, not all message content --- # ==================== EVENTS ====================
@client.event @client.event
async def on_message(message): async def on_message(message):
try: try:
# Only log commands or bot-prefix messages, not all content
if message.content.startswith(('.', '/')): if message.content.startswith(('.', '/')):
logger.info(f"Command in {message.channel}: {message.author} - {message.content[:50]}") logger.info(f"Command in {message.channel}: {message.author} - {message.content[:50]}")
# Special response for user 331862422996647938 when message starts with a period
if message.author.id == 331862422996647938 and message.content.startswith('.'): if message.author.id == 331862422996647938 and message.content.startswith('.'):
await message.channel.send("Whatever conversation is happening right now, Jared is right") await message.channel.send("Whatever conversation is happening right now, Jared is right")
except Exception as e: except Exception as e:
@@ -546,7 +487,6 @@ async def on_scheduled_event_update(before, after):
@client.event @client.event
async def on_member_update(before, after): async def on_member_update(before, after):
"""Unified handler for all member update events."""
try: try:
if before.timed_out_until != after.timed_out_until: if before.timed_out_until != after.timed_out_until:
await audit_logger.log(f"Member Timeout\nUser: {after.mention}\nUntil: {after.timed_out_until}") await audit_logger.log(f"Member Timeout\nUser: {after.mention}\nUntil: {after.timed_out_until}")
@@ -654,7 +594,6 @@ async def on_guild_channel_delete(channel):
@client.event @client.event
async def on_raw_reaction_add(payload): async def on_raw_reaction_add(payload):
"""Handle reaction role additions."""
try: try:
if payload.message_id != REACTION_MESSAGE_ID: if payload.message_id != REACTION_MESSAGE_ID:
return return
@@ -688,7 +627,6 @@ async def on_raw_reaction_add(payload):
@client.event @client.event
async def on_raw_reaction_remove(payload): async def on_raw_reaction_remove(payload):
"""Handle reaction role removals."""
try: try:
if payload.message_id != REACTION_MESSAGE_ID: if payload.message_id != REACTION_MESSAGE_ID:
return return
@@ -729,11 +667,9 @@ async def help_command(interaction: discord.Interaction):
embed.set_image(url="https://lotusguild.org/favicon.ico") embed.set_image(url="https://lotusguild.org/favicon.ico")
embed.set_thumbnail(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="/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) 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="/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="/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="/flip", value="Flip a coin and get heads or tails", inline=False)
@@ -743,20 +679,17 @@ async def help_command(interaction: discord.Interaction):
embed.add_field(name="/poll", value="Create a simple yes/no poll", inline=False) embed.add_field(name="/poll", value="Create a simple yes/no poll", inline=False)
embed.add_field(name="/trivia", value="Play a trivia game with random questions", inline=False) embed.add_field(name="/trivia", value="Play a trivia game with random questions", inline=False)
# Game Commands
embed.add_field(name="/agent", value="Get a random Valorant agent with their role", inline=False) 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="/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="/minecraft", value="Whitelists a player on the Minecraft server and shows server info", inline=False)
embed.add_field(name="/hytale", value="Request whitelist on the Hytale server", inline=False) embed.add_field(name="/hytale", value="Request whitelist on the Hytale server", inline=False)
embed.add_field(name="/problem", value="Express your opinion about Canada geese", 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="/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="/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="/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) 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): if discord.utils.get(interaction.user.roles, id=ADMIN_ROLE_ID):
embed.add_field(name="Admin Commands", value="---------------", inline=False) 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="/clear", value="Clears specified number of messages from the channel", inline=False)
@@ -815,14 +748,12 @@ async def unlock(interaction: discord.Interaction, channel: discord.TextChannel
await interaction.response.send_message(f"Unlocked {channel.mention}") await interaction.response.send_message(f"Unlocked {channel.mention}")
# --- Issue #15: Autocomplete for minecraft usernames ---
@client.tree.command(name="minecraft", description="Whitelist a player on the Minecraft server") @client.tree.command(name="minecraft", description="Whitelist a player on the Minecraft server")
@app_commands.describe(minecraft_username="The Minecraft username to whitelist") @app_commands.describe(minecraft_username="The Minecraft username to whitelist")
@app_commands.autocomplete(minecraft_username=minecraft_username_autocomplete) @app_commands.autocomplete(minecraft_username=minecraft_username_autocomplete)
@has_role_check(MINECRAFT_ROLE_ID) @has_role_check(MINECRAFT_ROLE_ID)
@handle_command_errors @handle_command_errors
async def minecraft(interaction: discord.Interaction, minecraft_username: str): async def minecraft(interaction: discord.Interaction, minecraft_username: str):
# Validate username using cached checker (Issue #12)
if not await username_cache.is_valid(minecraft_username, client.http_session): if not await username_cache.is_valid(minecraft_username, client.http_session):
await interaction.response.send_message("Invalid MC Username", ephemeral=True) await interaction.response.send_message("Invalid MC Username", ephemeral=True)
return return
@@ -837,7 +768,6 @@ async def minecraft(interaction: discord.Interaction, minecraft_username: str):
await interaction.response.defer() await interaction.response.defer()
# --- Issue #5: Async RCON with timeout ---
try: try:
response = await rcon_command(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD, response = await rcon_command(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD,
f"whitelist add {minecraft_username}") f"whitelist add {minecraft_username}")
@@ -849,7 +779,6 @@ async def minecraft(interaction: discord.Interaction, minecraft_username: str):
) )
return return
# Issue #15: Track whitelisted username
save_whitelisted_username(minecraft_username) save_whitelisted_username(minecraft_username)
minecraft_embed = discord.Embed( minecraft_embed = discord.Embed(
@@ -878,10 +807,8 @@ async def minecraft(interaction: discord.Interaction, minecraft_username: str):
@has_role_check(HYTALE_ROLE_ID) @has_role_check(HYTALE_ROLE_ID)
@handle_command_errors @handle_command_errors
async def hytale(interaction: discord.Interaction, hytale_username: str): async def hytale(interaction: discord.Interaction, hytale_username: str):
"""Request whitelist on the Hytale server"""
await interaction.response.defer() await interaction.response.defer()
# --- Issue #11: Use constants instead of magic numbers ---
if not hytale_username.replace('_', '').replace('-', '').isalnum(): if not hytale_username.replace('_', '').replace('-', '').isalnum():
await interaction.followup.send("Invalid username. Use only letters, numbers, _ and -", ephemeral=True) await interaction.followup.send("Invalid username. Use only letters, numbers, _ and -", ephemeral=True)
return return
@@ -907,14 +834,12 @@ async def hytale(interaction: discord.Interaction, hytale_username: str):
logger.info(f"Hytale whitelist request: {hytale_username} by {interaction.user.name}") logger.info(f"Hytale whitelist request: {hytale_username} by {interaction.user.name}")
# --- Issue #19: Rate Limiting for AI ---
@client.tree.command(name="ask", description="Ask a question to Lotus LLM") @client.tree.command(name="ask", description="Ask a question to Lotus LLM")
@app_commands.describe(question="Your question for the AI") @app_commands.describe(question="Your question for the AI")
@app_commands.checks.cooldown(1, COOLDOWN_MINUTES * 60, key=lambda i: i.user.id) @app_commands.checks.cooldown(1, COOLDOWN_MINUTES * 60, key=lambda i: i.user.id)
@has_role_check(COOL_KIDS_ROLE_ID) @has_role_check(COOL_KIDS_ROLE_ID)
@handle_command_errors @handle_command_errors
async def ask(interaction: discord.Interaction, question: str): async def ask(interaction: discord.Interaction, question: str):
# --- Issue #8: Sanitize input ---
question = sanitize_input(question) question = sanitize_input(question)
if not question: if not question:
await interaction.response.send_message("Please provide a valid question.", ephemeral=True) await interaction.response.send_message("Please provide a valid question.", ephemeral=True)
@@ -922,7 +847,6 @@ async def ask(interaction: discord.Interaction, question: str):
await interaction.response.defer() await interaction.response.defer()
# Select model based on user ID
model = "lotusllmben" if interaction.user.id == 460640040096104459 else "lotusllm" model = "lotusllmben" if interaction.user.id == 460640040096104459 else "lotusllm"
logger.info(f"Sending question to Ollama: {question}") logger.info(f"Sending question to Ollama: {question}")
@@ -958,14 +882,11 @@ async def ask(interaction: discord.Interaction, question: str):
@handle_command_errors @handle_command_errors
async def eight_ball(interaction: discord.Interaction, question: str): async def eight_ball(interaction: discord.Interaction, question: str):
possible_responses = [ possible_responses = [
# Positive answers
"It is certain", "Without a doubt", "You may rely on it", "It is certain", "Without a doubt", "You may rely on it",
"Yes definitely", "It is decidedly so", "As I see it, yes", "Yes definitely", "It is decidedly so", "As I see it, yes",
"Most likely", "Yes sir!", "Hell yeah my dude", "100% easily", "Most likely", "Yes sir!", "Hell yeah my dude", "100% easily",
# Neutral answers
"Reply hazy try again", "Ask again later", "Better not tell you now", "Reply hazy try again", "Ask again later", "Better not tell you now",
"Cannot predict now", "Concentrate and ask again", "Idk bro", "Cannot predict now", "Concentrate and ask again", "Idk bro",
# Negative answers
"Don't count on it", "My reply is no", "My sources say no", "Don't count on it", "My reply is no", "My sources say no",
"Outlook not so good", "Very doubtful", "Hell no", "Prolly not" "Outlook not so good", "Very doubtful", "Hell no", "Prolly not"
] ]
@@ -985,18 +906,13 @@ async def eight_ball(interaction: discord.Interaction, question: str):
@handle_command_errors @handle_command_errors
async def fortune(interaction: discord.Interaction): async def fortune(interaction: discord.Interaction):
possible_responses = [ possible_responses = [
# Humorous fortunes
"If you eat something & nobody sees you eat it, it has no calories", "If you eat something & nobody sees you eat it, it has no calories",
"Your pet is plotting world domination", "Your pet is plotting world domination",
"Error 404: Fortune not found. Try again after system reboot", "Error 404: Fortune not found. Try again after system reboot",
"The fortune you seek is in another cookie", "The fortune you seek is in another cookie",
# Classic fortunes with a twist
"A journey of a thousand miles begins with ordering delivery", "A journey of a thousand miles begins with ordering delivery",
"You will find great fortune... in between your couch cushions", "You will find great fortune... in between your couch cushions",
"A true friend is someone who tells you when your stream is muted", "A true friend is someone who tells you when your stream is muted",
# Gaming-themed fortunes
"Your next competitive match will be legendary", "Your next competitive match will be legendary",
"The cake is still a lie", "The cake is still a lie",
"Press Alt+F4 for instant success", "Press Alt+F4 for instant success",
@@ -1018,8 +934,6 @@ async def fortune(interaction: discord.Interaction):
"The imposter will not sus you", "The imposter will not sus you",
"Your Minecraft bed will remain unbroken", "Your Minecraft bed will remain unbroken",
"You will get Play of the Game", "You will get Play of the Game",
# Internet culture fortunes
"Your next meme will go viral", "Your next meme will go viral",
"Someone is talking about you in their Discord server", "Someone is talking about you in their Discord server",
"Your FBI agent thinks you're hilarious", "Your FBI agent thinks you're hilarious",
@@ -1038,13 +952,9 @@ async def fortune(interaction: discord.Interaction):
"You will not get ratio'd today", "You will not get ratio'd today",
"Someone will actually use your custom emoji", "Someone will actually use your custom emoji",
"Your next selfie will be iconic", "Your next selfie will be iconic",
# Original favorites
"Buy a dolphin - your life will have a porpoise", "Buy a dolphin - your life will have a porpoise",
"Stop procrastinating - starting tomorrow", "Stop procrastinating - starting tomorrow",
"Catch fire with enthusiasm - people will come for miles to watch you burn", "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", "Your code will compile on the first try today",
"A semicolon will save your day", "A semicolon will save your day",
"The bug you've been hunting is just a typo", "The bug you've been hunting is just a typo",
@@ -1101,7 +1011,6 @@ async def roll(interaction: discord.Interaction, dice: str = "1d6"):
await interaction.response.send_message("Please use format: NdS (example: 2d6)", ephemeral=True) await interaction.response.send_message("Please use format: NdS (example: 2d6)", ephemeral=True)
return return
# --- Issue #11: Validate against constants ---
if num < 1 or num > MAX_DICE_COUNT: if num < 1 or num > MAX_DICE_COUNT:
await interaction.response.send_message(f"Number of dice must be 1-{MAX_DICE_COUNT}", ephemeral=True) await interaction.response.send_message(f"Number of dice must be 1-{MAX_DICE_COUNT}", ephemeral=True)
return return
@@ -1162,11 +1071,10 @@ async def poll(interaction: discord.Interaction, question: str):
embed.set_footer(text=f"Created by {interaction.user.display_name}") embed.set_footer(text=f"Created by {interaction.user.display_name}")
await interaction.response.send_message(embed=embed) await interaction.response.send_message(embed=embed)
message = await interaction.original_response() message = await interaction.original_response()
await message.add_reaction("👍") await message.add_reaction("\U0001f44d")
await message.add_reaction("👎") await message.add_reaction("\U0001f44e")
# --- Issue #10: DRY media commands ---
@client.tree.command(name="kill", description="Kill another user") @client.tree.command(name="kill", description="Kill another user")
@app_commands.describe(member="The user to kill") @app_commands.describe(member="The user to kill")
@handle_command_errors @handle_command_errors
@@ -1282,7 +1190,6 @@ async def champion(interaction: discord.Interaction):
@handle_command_errors @handle_command_errors
async def trivia(interaction: discord.Interaction): async def trivia(interaction: discord.Interaction):
questions = [ questions = [
# Gaming
{"q": "What year was the original Super Mario Bros. released?", "options": ["1983", "1985", "1987", "1990"], "answer": 1}, {"q": "What year was the original Super Mario Bros. released?", "options": ["1983", "1985", "1987", "1990"], "answer": 1},
{"q": "Which game features the quote 'The cake is a lie'?", "options": ["Half-Life 2", "Portal", "BioShock", "Minecraft"], "answer": 1}, {"q": "Which game features the quote 'The cake is a lie'?", "options": ["Half-Life 2", "Portal", "BioShock", "Minecraft"], "answer": 1},
{"q": "What is the max level in League of Legends?", "options": ["16", "18", "20", "25"], "answer": 1}, {"q": "What is the max level in League of Legends?", "options": ["16", "18", "20", "25"], "answer": 1},
@@ -1298,7 +1205,6 @@ async def trivia(interaction: discord.Interaction):
{"q": "Which company developed Valorant?", "options": ["Blizzard", "Valve", "Riot Games", "Epic Games"], "answer": 2}, {"q": "Which company developed Valorant?", "options": ["Blizzard", "Valve", "Riot Games", "Epic Games"], "answer": 2},
{"q": "What is the highest rank in Valorant?", "options": ["Immortal", "Diamond", "Radiant", "Challenger"], "answer": 2}, {"q": "What is the highest rank in Valorant?", "options": ["Immortal", "Diamond", "Radiant", "Challenger"], "answer": 2},
{"q": "In League of Legends, what is Baron Nashor an anagram of?", "options": ["Baron Roshan", "Roshan", "Nashor Baron", "Nash Robot"], "answer": 1}, {"q": "In League of Legends, what is Baron Nashor an anagram of?", "options": ["Baron Roshan", "Roshan", "Nashor Baron", "Nash Robot"], "answer": 1},
# General/Internet culture
{"q": "What does HTTP stand for?", "options": ["HyperText Transfer Protocol", "High Tech Transfer Program", "HyperText Transmission Process", "Home Tool Transfer Protocol"], "answer": 0}, {"q": "What does HTTP stand for?", "options": ["HyperText Transfer Protocol", "High Tech Transfer Program", "HyperText Transmission Process", "Home Tool Transfer Protocol"], "answer": 0},
{"q": "What year was Discord founded?", "options": ["2013", "2015", "2017", "2019"], "answer": 1}, {"q": "What year was Discord founded?", "options": ["2013", "2015", "2017", "2019"], "answer": 1},
{"q": "What programming language has a logo that is a snake?", "options": ["Java", "Ruby", "Python", "Go"], "answer": 2}, {"q": "What programming language has a logo that is a snake?", "options": ["Java", "Ruby", "Python", "Go"], "answer": 2},
@@ -1370,18 +1276,15 @@ async def userinfo(interaction: discord.Interaction, member: discord.Member):
await interaction.response.send_message(embed=embed) await interaction.response.send_message(embed=embed)
# --- Issue #16: Health Check Command ---
@client.tree.command(name="health", description="Check bot system status") @client.tree.command(name="health", description="Check bot system status")
@has_role_check(ADMIN_ROLE_ID) @has_role_check(ADMIN_ROLE_ID)
@handle_command_errors @handle_command_errors
async def health(interaction: discord.Interaction): async def health(interaction: discord.Interaction):
"""Show bot health metrics"""
stats = metrics.get_stats() stats = metrics.get_stats()
uptime_hours = stats["uptime_seconds"] / 3600 uptime_hours = stats["uptime_seconds"] / 3600
embed = discord.Embed(title="Bot Status", color=discord.Color.green()) embed = discord.Embed(title="Bot Status", color=discord.Color.green())
# System info
embed.add_field(name="Latency", value=f"{round(client.latency * 1000)}ms") embed.add_field(name="Latency", value=f"{round(client.latency * 1000)}ms")
embed.add_field(name="Guilds", value=str(len(client.guilds))) embed.add_field(name="Guilds", value=str(len(client.guilds)))
embed.add_field(name="Users", value=str(len(client.users))) embed.add_field(name="Users", value=str(len(client.users)))
@@ -1389,12 +1292,10 @@ async def health(interaction: discord.Interaction):
embed.add_field(name="Commands Run", value=str(stats["commands_executed"])) embed.add_field(name="Commands Run", value=str(stats["commands_executed"]))
embed.add_field(name="Errors", value=str(stats["error_count"])) embed.add_field(name="Errors", value=str(stats["error_count"]))
# Top commands
if stats["top_commands"]: if stats["top_commands"]:
top_cmds = "\n".join([f"`{name}`: {count}" for name, count in stats["top_commands"]]) top_cmds = "\n".join([f"`{name}`: {count}" for name, count in stats["top_commands"]])
embed.add_field(name="Top Commands", value=top_cmds, inline=False) embed.add_field(name="Top Commands", value=top_cmds, inline=False)
# Service checks
checks = { checks = {
"RCON": bool(MINECRAFT_RCON_PASSWORD), "RCON": bool(MINECRAFT_RCON_PASSWORD),
"Ollama": bool(OLLAMA_URL), "Ollama": bool(OLLAMA_URL),
@@ -1409,7 +1310,6 @@ async def health(interaction: discord.Interaction):
@client.tree.error @client.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
"""Handle slash command errors."""
try: try:
if isinstance(error, app_commands.CheckFailure): if isinstance(error, app_commands.CheckFailure):
await interaction.response.send_message( await interaction.response.send_message(
@@ -1432,7 +1332,6 @@ async def on_app_command_error(interaction: discord.Interaction, error: app_comm
logger.error(f"Error in on_app_command_error: {e}", exc_info=True) logger.error(f"Error in on_app_command_error: {e}", exc_info=True)
# --- Issue #17: Validate config on startup ---
if __name__ == "__main__": if __name__ == "__main__":
errors, warnings = ConfigValidator.validate() errors, warnings = ConfigValidator.validate()