diff --git a/.gitignore b/.gitignore index 5d008f7..fa7c9cf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,5 @@ venv/ */backups/ */fonts/ .txt -*/databases/ -*/old_backups/ logs/ -data/ \ No newline at end of file +data/ diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..a963489 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,81 @@ +# Lacie Discord Bot - Codebase Cleanup Plan + +## Phase 1: Foundation (infrastructure changes everything else depends on) + +### 1a. Set up centralized logging +- Create `utils/logger.py` with a configured logger factory +- Replace all ~154 `print()` statements with proper `logging` calls across every file + +### 1b. Add `__init__.py` to all 13 packages +- `birthday/`, `commands/`, `embed/`, `events/`, `image/`, `lilac-tools/`, `moderation/`, `profiles/`, `reminders/`, `sparkle/`, `stats/`, `suggestion/`, `wordle/`, `xp/`, `utils/` +- Each `__init__.py` gets a module-level docstring explaining the package's purpose + +### 1c. Create `utils/constants.py` +- Move hardcoded IDs (lilac_id, guild IDs, channel IDs) to a single constants file +- Update all files that reference these IDs + +--- + +## Phase 2: File renames & import standardization + +### 2a. Rename files for snake_case consistency +- `embed/embedcolor.py` → `embed/embed_color.py` +- `commands/roletrack.py` → `commands/role_track.py` +- `events/roletrack.py` → delete (already marked for deletion in git) +- Update all imports referencing renamed files + +### 2b. Standardize import style +- Use absolute imports everywhere (e.g., `from embed.embed_color import ...`) +- Group imports: stdlib → third-party → local, separated by blank lines +- Remove unused imports (`import sys` in generate_role_color_images.py, botban.py, etc.) + +### 2c. Standardize database path construction +- Use `pathlib.Path` consistently instead of mixed `os.path.join` / `Path` + +--- + +## Phase 3: Code quality fixes + +### 3a. Fix bare `except:` clauses (~40 instances) +- Replace with specific exception types (`except Exception as e:`, `except sqlite3.Error:`, etc.) + +### 3b. Fix deprecated patterns +- Replace `bot.loop.create_task()` with `cog_load()` async method or `asyncio.create_task()` + +### 3c. Remove dead code +- Clean up unused variables, unreachable code +- Remove the deleted `events/roletrack.py` + +### 3d. Rename constants to UPPER_SNAKE_CASE +- e.g., `meow_list` → `MEOW_LIST`, `lilac_id` → move to constants + +--- + +## Phase 4: Comments & docstrings (the big one) + +### 4a. Add module-level docstrings to every `.py` file +- Brief description of what the module does + +### 4b. Add class docstrings to all Cog classes +- Describe what the cog provides + +### 4c. Add function/command docstrings where missing +- Focus on non-obvious logic, complex functions +- Add inline comments for complex algorithms (minesweeper flood fill, XP calculations, spam detection, etc.) + +### 4d. Document database schemas +- Add comments in database initialization code describing table structures + +--- + +## Phase 5: bot.py cleanup + +### 5a. Clean up bot.py +- Add module docstring and section comments +- DRY up the duplicate cog folder lists (on_ready and reload command) +- Add proper comments for each section + +--- + +## Order of operations +Phases 1-5 are sequential - each builds on the prior. Within each phase, tasks are independent and can be parallelized. diff --git a/birthday/__init__.py b/birthday/__init__.py new file mode 100644 index 0000000..4fc83a1 --- /dev/null +++ b/birthday/__init__.py @@ -0,0 +1 @@ +"""Birthday tracking and celebration features.""" diff --git a/birthday/birthday.py b/birthday/birthday.py index 1426963..b9c30b5 100644 --- a/birthday/birthday.py +++ b/birthday/birthday.py @@ -1,11 +1,13 @@ +"""Birthday tracking cog that sends birthday wishes and manages birthday roles.""" import discord from discord.ext import commands, tasks from discord import app_commands import sqlite3 from datetime import datetime, timezone, timedelta +from embed.embed_color import get_embed_color import asyncio import pytz -import os +from pathlib import Path from dotenv import load_dotenv from moderation.loader import ModerationBase @@ -15,7 +17,7 @@ BIRTHDAY_ROLE_ID = 1113751318918602762 class Birthday(commands.Cog): def __init__(self, bot): self.bot = bot - self.db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "birthdays.db") + self.db_path = Path(__file__).parent.parent / "data" / "birthdays.db" self._init_db() self.check_birthdays.start() self.remove_birthday_roles.start() @@ -239,7 +241,7 @@ class Birthday(commands.Cog): embed = discord.Embed( title=f"🎂 Birthdays in {month_name}", description="\n".join(lines), - color=discord.Color.magenta() + color=get_embed_color(interaction.user.id) ) await interaction.response.send_message(embed=embed) diff --git a/bot.py b/bot.py index ed72550..f432964 100644 --- a/bot.py +++ b/bot.py @@ -1,143 +1,126 @@ +"""Main entry point for the Lacie Discord bot.""" import os from dotenv import load_dotenv import discord from discord.ext import commands import asyncio import glob -import traceback from xp.database import get_db as get_xp_db +from xp.add_xp import add_xp from moderation.loader import ModerationBase -from aiohttp import web - -# Import sparkle DB to ensure it exists from sparkle.database import get_db as get_sparkle_db - -# Import and register the XP command groups onto the tree from xp.groups import xp_group, xp_admin_group +from utils.logger import get_logger, setup_logging +# --- Startup --- load_dotenv() +setup_logging() + +logger = get_logger(__name__) TOKEN = os.getenv("TOKEN") +# All cog folders to load on startup and reload +COG_FOLDERS = [ + "commands", + "moderation", + "xp", + "sparkle", + "image", + "suggestion", + "birthday", + "embed", + "profiles", + "events", + "stats", + "wordle", + "reminders", + "lilac-tools", +] + +# --- Bot setup --- bot = commands.Bot( - command_prefix="!", - intents=discord.Intents.all(), + command_prefix="!", + intents=discord.Intents.all(), help_command=None, activity=discord.Activity( type=discord.ActivityType.playing, name="Paper Lily - Chapter 2" )) -# Add both groups to the command tree now, before any cogs load +# Register shared slash command groups defined in xp/groups.py bot.tree.add_command(xp_group) bot.tree.add_command(xp_admin_group) +# --- Cog loading --- async def load_cogs(folder: str): - """Load all cogs in the folder except utility files""" - non_cog_files = {"add_xp.py", "database.py", "utils.py", "__init__.py", - "import_old_data.py", "repair_db.py", "reset_db.py", "groups.py", - "loader.py"} + non_cog_files = {"add_xp.py", "database.py", "utils.py", "__init__.py", "groups.py", "loader.py"} for file in glob.glob(f"{folder}/*.py"): filename = os.path.basename(file) if filename in non_cog_files: - print(f"Skipping {filename} (utility file)") + logger.debug(f"Skipping {filename} (utility file)") continue module_name = f"{folder}.{os.path.splitext(filename)[0]}" try: - # Try to reload if already loaded, otherwise load fresh if module_name in bot.extensions: await bot.reload_extension(module_name) - print(f"Reloaded {module_name}") + logger.info(f"Reloaded {module_name}") else: await bot.load_extension(module_name) - print(f"Loaded {module_name}") + logger.info(f"Loaded {module_name}") except Exception as e: - print(f"Failed to load {module_name}: {e}") - traceback.print_exc() + logger.exception(f"Failed to load {module_name}: {e}") +# --- Events and commands --- @bot.event async def on_ready(): - print(f"Logged in as {bot.user}!") + logger.info(f"Logged in as {bot.user}!") - # Ensure XP database connection works for lifetime in (True, False): try: conn, cur = get_xp_db(lifetime) conn.close() - print(f"XP database connection successful (lifetime={lifetime})") + logger.info(f"XP database connection successful (lifetime={lifetime})") except Exception as e: - print(f"XP database connection failed (lifetime={lifetime}): {e}") + logger.error(f"XP database connection failed (lifetime={lifetime}): {e}") - # Ensure Sparkle DB exists try: conn = get_sparkle_db() conn.close() - print("Sparkle database initialized successfully.") + logger.info("Sparkle database initialized successfully.") except Exception as e: - print(f"Failed to initialize sparkle database: {e}") + logger.error(f"Failed to initialize sparkle database: {e}") + + for folder in COG_FOLDERS: + await load_cogs(folder) - # Load all cogs - await load_cogs("commands") - # await load_cogs("wordbomb") - await load_cogs("moderation") - await load_cogs("xp") - await load_cogs("sparkle") - await load_cogs("image") - await load_cogs("suggestion") - await load_cogs("birthday") - await load_cogs("embed") - await load_cogs("profiles") - await load_cogs("events") - await load_cogs("stats") - await load_cogs("wordle") - await load_cogs("reminders") - await load_cogs("lilac-tools") - - # Sync slash commands after loading cogs try: synced = await bot.tree.sync() - print(f"Synced {len(synced)} slash commands") + logger.info(f"Synced {len(synced)} slash commands") for cmd in synced: - print(f" - {cmd.name}") + logger.debug(f" - {cmd.name}") except Exception as e: - print(f"Failed to sync slash commands: {e}") - traceback.print_exc() + logger.exception(f"Failed to sync slash commands: {e}") @bot.event async def on_command_error(ctx, error): if isinstance(error, commands.CommandNotFound): return - print(f"Command error: {error}") - traceback.print_exc() + logger.error(f"Command error: {error}", exc_info=True) @bot.command(name="reload") @ModerationBase.is_admin() async def reload(ctx): """Reload commands cogs and sync slash commands""" - await load_cogs("commands") - await load_cogs("moderation") - await load_cogs("xp") - await load_cogs("sparkle") - await load_cogs("image") - await load_cogs("suggestion") - await load_cogs("birthday") - await load_cogs("embed") - await load_cogs("profiles") - await load_cogs("events") - await load_cogs("wordle") - await load_cogs("reminders") - await load_cogs("stats") - await load_cogs("lilac-tools") - + for folder in COG_FOLDERS: + await load_cogs(folder) + try: synced = await bot.tree.sync() await ctx.send(f"Cogs reloaded successfully! Synced {len(synced)} slash commands.") except Exception as e: await ctx.send(f"Cogs reloaded but failed to sync slash commands: {e}") -# Hook XP into messages -from xp.add_xp import add_xp - @bot.event async def on_message(message): if message.author.bot: @@ -145,17 +128,16 @@ async def on_message(message): try: await add_xp(message.author) except Exception as e: - print(f"XP error: {e}") - traceback.print_exc() + logger.error(f"XP error: {e}", exc_info=True) await bot.process_commands(message) +# --- Entry point --- async def main(): try: async with bot: await bot.start(TOKEN) except Exception as e: - print(f"Bot startup error: {e}") - traceback.print_exc() + logger.exception(f"Bot startup error: {e}") if __name__ == "__main__": asyncio.run(main()) \ No newline at end of file diff --git a/commands/__init__.py b/commands/__init__.py new file mode 100644 index 0000000..5bcfb9a --- /dev/null +++ b/commands/__init__.py @@ -0,0 +1 @@ +"""General-purpose bot commands (fun, utility, moderation helpers).""" diff --git a/commands/bonk.py b/commands/bonk.py index 6e1bdb5..ea0523e 100644 --- a/commands/bonk.py +++ b/commands/bonk.py @@ -1,12 +1,14 @@ +"""Bonk command cog that lets users bonk each other with an image.""" import discord from discord import app_commands from discord.ext import commands import os +from pathlib import Path class Bonk(commands.Cog): def __init__(self, bot): self.bot = bot - self.bonk_path = os.path.join(os.path.dirname(__file__), "..", "media", "kat_bonk.png") + self.bonk_path = Path(__file__).parent.parent / "media" / "kat_bonk.png" @app_commands.command(name="bonk", description="Bonk another user!") @app_commands.describe(user="The user you want to bonk") diff --git a/commands/connect4.py b/commands/connect4.py index 8b6dc97..499c8f6 100644 --- a/commands/connect4.py +++ b/commands/connect4.py @@ -1,3 +1,4 @@ +"""Connect 4 game implementation as a Discord slash command.""" import discord from discord.ext import commands from discord import app_commands diff --git a/commands/emote_credits.py b/commands/emote_credits.py index 05d4c05..2f86dc1 100644 --- a/commands/emote_credits.py +++ b/commands/emote_credits.py @@ -1,3 +1,4 @@ +"""Emote credits cog for tracking and displaying custom emote artists.""" import discord from discord import app_commands from discord.ext import commands @@ -5,6 +6,7 @@ from moderation.loader import ModerationBase import re import aiosqlite from pathlib import Path +from embed.embed_color import get_embed_color class EmoteCredits(ModerationBase, commands.Cog): def __init__(self, bot): @@ -12,8 +14,10 @@ class EmoteCredits(ModerationBase, commands.Cog): self.bot = bot self.db_path = Path(__file__).parent.parent / "data" / "emote_credits.db" self.approval_channel_id = 1424145004976275617 - bot.loop.create_task(self._init_db()) - + + async def cog_load(self): + await self._init_db() + async def _init_db(self): """Initialize the database""" async with aiosqlite.connect(self.db_path) as conn: @@ -87,7 +91,7 @@ class EmoteCredits(ModerationBase, commands.Cog): embed = discord.Embed( title=f"🎨 Credit for: {emoji_name}", description=f"**Artist:** {credit}", - color=discord.Color.blue() + color=get_embed_color(interaction.user.id) ) embed.set_footer(text="Full credits document: https://docs.google.com/document/d/1o6dJS3G82rA03oHQn3Lu3ywK0SpepZnxnmP28R8Nnpc/edit?tab=t.0") await interaction.followup.send(embed=embed) @@ -137,7 +141,7 @@ class EmoteCredits(ModerationBase, commands.Cog): approval_embed = discord.Embed( title="🎨 New Credit Submission", - color=discord.Color.blue() + color=get_embed_color(interaction.user.id) ) approval_embed.add_field(name="Emote Name", value=f"`{emoji_name}`", inline=False) approval_embed.add_field(name="Artist", value=artist, inline=False) @@ -343,7 +347,7 @@ class CreditApprovalView(discord.ui.View): color=discord.Color.green() ) await submitter.send(embed=notify_embed) - except: + except Exception: pass @discord.ui.button(label="Deny", style=discord.ButtonStyle.red, custom_id="deny_credit") @@ -373,7 +377,7 @@ class CreditApprovalView(discord.ui.View): color=discord.Color.red() ) await submitter.send(embed=notify_embed) - except: + except Exception: pass diff --git a/commands/generate_role_color_images.py b/commands/generate_role_color_images.py index c065a8a..46f79dc 100644 --- a/commands/generate_role_color_images.py +++ b/commands/generate_role_color_images.py @@ -1,15 +1,17 @@ +"""Admin command to generate the color role preview image.""" import discord from discord import app_commands from discord.ext import commands from pathlib import Path -import traceback from PIL import Image, ImageDraw, ImageFont -import os import io from math import floor, ceil from moderation.loader import ModerationBase import importlib import sys +from utils.logger import get_logger + +logger = get_logger(__name__) class ColorImageGen(commands.Cog): def __init__(self, bot): @@ -43,7 +45,7 @@ class ColorImageGen(commands.Cog): color_roles = [] # Styling - font_path_ttf = os.path.join(self.FONTS_PATH, "Renogare-Regular.otf") + font_path_ttf = self.FONTS_PATH / "Renogare-Regular.otf" FONT_SIZE = 300 font = ImageFont.truetype(font_path_ttf, FONT_SIZE) COLUMN_SIZE = 5 @@ -104,7 +106,7 @@ class ColorImageGen(commands.Cog): ) except Exception as e: - print(f"[ERROR] /color list\n{traceback.format_exc()}") + logger.error(f"Error generating color image: {e}", exc_info=True) msg = f"❌ Error in `/color list`: `{e}`" if self.DEBUG else "❌ Something went wrong loading color images." await ctx.send(msg) diff --git a/commands/infractions.py b/commands/infractions.py index 1525dd2..a74c100 100644 --- a/commands/infractions.py +++ b/commands/infractions.py @@ -1,8 +1,9 @@ +"""Command for users to view their own moderation infractions via DM.""" import discord from discord import app_commands from discord.ext import commands import sqlite3 -import os +from pathlib import Path class InfractionsCommand(commands.Cog): @@ -11,7 +12,7 @@ class InfractionsCommand(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot # Path to moderation database (go up to project root, then into moderation/) - self.db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "moderation.db") + self.db_path = Path(__file__).parent.parent / "data" / "moderation.db" @app_commands.command(name="infractions", description="View your infractions in this server") async def infractions(self, interaction: discord.Interaction): diff --git a/commands/meow.py b/commands/meow.py index 6068f57..a42bf33 100644 --- a/commands/meow.py +++ b/commands/meow.py @@ -1,18 +1,19 @@ +"""Simple meow command cog that responds with a random cat sound.""" import discord from discord.ext import commands from discord import app_commands import random +MEOW_LIST = ["Meowwwww~", "Purrrrrr", "Nyaaaaaa", "Meow Meow", "Nya!", "Meow :3"] + class Meow(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot @app_commands.command(name="meow") async def meow(self, interaction: discord.Interaction): - - meow_list=["Meowwwww~", "Purrrrrr", "Nyaaaaaa", "Meow Meow", "Nya!", "Meow :3"] - meow_index = random.randrange(0,5) - await interaction.response.send_message(meow_list[meow_index]) + meow_index = random.randrange(0, len(MEOW_LIST)) + await interaction.response.send_message(MEOW_LIST[meow_index]) async def setup(bot: commands.Bot): await bot.add_cog(Meow(bot)) \ No newline at end of file diff --git a/commands/minesweeper.py b/commands/minesweeper.py index 0150c87..9486693 100644 --- a/commands/minesweeper.py +++ b/commands/minesweeper.py @@ -1,10 +1,58 @@ +"""Minesweeper game cog with an interactive Discord UI.""" import discord from discord.ext import commands from discord import app_commands import random import asyncio +import aiosqlite +from pathlib import Path +from embed.embed_color import get_embed_color import re from datetime import datetime, timedelta +from utils.logger import get_logger + +logger = get_logger(__name__) + +DB_PATH = Path(__file__).parent.parent / "data" / "minesweeper.db" + + +async def _update_stats(user_id: int, win: bool): + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS minesweeper_stats ( + user_id INTEGER PRIMARY KEY, + played INTEGER DEFAULT 0, + wins INTEGER DEFAULT 0, + current_streak INTEGER DEFAULT 0, + max_streak INTEGER DEFAULT 0 + ) + """) + cursor = await db.execute( + "SELECT played, wins, current_streak, max_streak FROM minesweeper_stats WHERE user_id=?", + (user_id,) + ) + row = await cursor.fetchone() + if not row: + played, wins, streak, max_streak = 0, 0, 0, 0 + else: + played, wins, streak, max_streak = row + + played += 1 + if win: + wins += 1 + streak += 1 + max_streak = max(max_streak, streak) + else: + streak = 0 + + await db.execute(""" + INSERT INTO minesweeper_stats (user_id, played, wins, current_streak, max_streak) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + played=excluded.played, wins=excluded.wins, + current_streak=excluded.current_streak, max_streak=excluded.max_streak + """, (user_id, played, wins, streak, max_streak)) + await db.commit() class MinesweeperGame: def __init__(self, rows: int = 13, cols: int = 13, mines: int = 20): @@ -230,12 +278,13 @@ class MinesweeperView(discord.ui.View): self.timed_out = True self.game.game_over = True - + await _update_stats(self.player.id, False) + for child in self.children: child.disabled = True - + self.stop() - + if self.message: embed = self.create_embed() embed.color = discord.Color.orange() @@ -248,7 +297,7 @@ class MinesweeperView(discord.ui.View): # Message was deleted pass except Exception as e: - print(f"Error updating timed out game: {e}") + logger.error(f"Error updating timed out game: {e}") def reset_timeout(self): """Reset the timeout timer - called when a move is made""" @@ -270,11 +319,12 @@ class MinesweeperView(discord.ui.View): for child in self.children: child.disabled = True self.stop() - + await _update_stats(self.player.id, False) + embed = self.create_embed() embed.color = discord.Color.red() embed.title = "💀 Game Over - Forfeited" - + await interaction.response.edit_message(embed=embed, view=self) @discord.ui.button(label="How to Play", style=discord.ButtonStyle.secondary, emoji="❓") @@ -282,7 +332,7 @@ class MinesweeperView(discord.ui.View): help_embed = discord.Embed( title="🎮 How to Play Minesweeper", description="Send messages in this channel to make moves!", - color=discord.Color.blue() + color=get_embed_color(interaction.user.id) ) help_embed.add_field( name="📝 Move Format", @@ -332,7 +382,7 @@ class MinesweeperView(discord.ui.View): embed = discord.Embed( title="💣 Minesweeper", description=f"{self.player.mention}'s game", - color=discord.Color.blue() + color=get_embed_color(self.player.id) ) board = self.game.render_board() @@ -438,7 +488,7 @@ class Minesweeper(commands.Cog): # Valid input - delete the player's message to reduce clutter try: await message.delete() - except: + except Exception: pass # Make the move @@ -455,7 +505,9 @@ class Minesweeper(commands.Cog): # Cancel timeout task since game is over if view.timeout_task and not view.timeout_task.done(): view.timeout_task.cancel() - + + await _update_stats(player_id, view.game.won) + for child in view.children: child.disabled = True view.stop() @@ -463,7 +515,7 @@ class Minesweeper(commands.Cog): embed = view.create_embed() try: await view.message.edit(embed=embed, view=view) - except: + except Exception: pass def parse_move(self, text: str) -> tuple[int, int, bool] | None: @@ -497,5 +549,45 @@ class Minesweeper(commands.Cog): return (col, row, is_flag) + @app_commands.command(name="minesweeper_stats", description="View your Minesweeper stats") + async def minesweeper_stats(self, interaction: discord.Interaction): + user_id = interaction.user.id + async with aiosqlite.connect(DB_PATH) as db: + await db.execute(""" + CREATE TABLE IF NOT EXISTS minesweeper_stats ( + user_id INTEGER PRIMARY KEY, + played INTEGER DEFAULT 0, + wins INTEGER DEFAULT 0, + current_streak INTEGER DEFAULT 0, + max_streak INTEGER DEFAULT 0 + ) + """) + cursor = await db.execute( + "SELECT played, wins, current_streak, max_streak FROM minesweeper_stats WHERE user_id=?", + (user_id,) + ) + row = await cursor.fetchone() + + if not row or row[0] == 0: + await interaction.response.send_message("You haven't played Minesweeper yet!", ephemeral=True) + return + + played, wins, streak, max_streak = row + losses = played - wins + win_rate = round(wins / played * 100, 1) if played else 0 + + embed = discord.Embed( + title=f"{interaction.user.display_name}'s Minesweeper Stats", + color=get_embed_color(user_id) + ) + embed.add_field(name="Games Played", value=str(played)) + embed.add_field(name="Wins", value=str(wins)) + embed.add_field(name="Losses", value=str(losses)) + embed.add_field(name="Win Rate", value=f"{win_rate}%") + embed.add_field(name="Current Streak", value=str(streak)) + embed.add_field(name="Best Streak", value=str(max_streak)) + await interaction.response.send_message(embed=embed) + + async def setup(bot): await bot.add_cog(Minesweeper(bot)) \ No newline at end of file diff --git a/commands/ping.py b/commands/ping.py index 27690b9..37203f0 100644 --- a/commands/ping.py +++ b/commands/ping.py @@ -1,14 +1,16 @@ +"""Ping command that shows WebSocket, API, and database latency.""" import discord from discord.ext import commands from discord import app_commands import time import aiosqlite -import os +from embed.embed_color import get_embed_color +from pathlib import Path class Ping(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "suggestions.db") + self.db_path = Path(__file__).parent.parent / "data" / "suggestions.db" @app_commands.command(name="ping", description="Check the bot's latency") async def ping(self, interaction: discord.Interaction): @@ -33,7 +35,7 @@ class Ping(commands.Cog): embed = discord.Embed( title="Pong!", - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user) + color=get_embed_color(interaction.user.id) ) embed.add_field(name="WebSocket Latency", value=f"{ws_latency}ms", inline=True) embed.add_field(name="API Latency", value=f"{api_latency}ms", inline=True) diff --git a/commands/role_color.py b/commands/role_color.py index 343a24e..971f116 100644 --- a/commands/role_color.py +++ b/commands/role_color.py @@ -1,13 +1,16 @@ +"""Color role management cog allowing users to pick decorative color roles.""" import discord from discord import app_commands from discord.ext import commands from pathlib import Path -import traceback from PIL import Image, ImageDraw, ImageFont -import os import io from math import floor, ceil from moderation.loader import ModerationBase +from embed.embed_color import get_embed_color +from utils.logger import get_logger + +logger = get_logger(__name__) DEBUG = False @@ -18,7 +21,7 @@ COLOR_ROLE_NAMES = [ "Dark Air", "White-ish" ] -FONTS_PATH = os.path.join(os.path.dirname(__file__), "..", "fonts") +FONTS_PATH = Path(__file__).parent.parent / "fonts" class ColorRoles(commands.Cog): def __init__(self, bot): @@ -87,7 +90,7 @@ class ColorRoles(commands.Cog): ephemeral=True ) except Exception as e: - print(f"[ERROR] /color set\n{traceback.format_exc()}") + logger.error(f"/color set: {e}", exc_info=True) msg = f"Error: `{e}`" if DEBUG else "Something went wrong." await interaction.followup.send(msg, ephemeral=True) @@ -148,7 +151,7 @@ class ColorRoles(commands.Cog): ephemeral=False ) except Exception as e: - print(f"[ERROR] /color setfor\n{traceback.format_exc()}") + logger.error(f"/color setfor: {e}", exc_info=True) msg = f"Error: `{e}`" if DEBUG else "Something went wrong." await interaction.followup.send(msg, ephemeral=True) @@ -199,7 +202,7 @@ class ColorRoles(commands.Cog): ephemeral=True ) except Exception as e: - print(f"[ERROR] /color remove\n{traceback.format_exc()}") + logger.error(f"/color remove: {e}", exc_info=True) msg = f"Error: `{e}`" if DEBUG else "Something went wrong." await interaction.followup.send(msg, ephemeral=True) @@ -244,7 +247,7 @@ class ColorRoles(commands.Cog): ephemeral=False ) except Exception as e: - print(f"[ERROR] /color removefor\n{traceback.format_exc()}") + logger.error(f"/color removefor: {e}", exc_info=True) msg = f"Error: `{e}`" if DEBUG else "Something went wrong." await interaction.followup.send(msg, ephemeral=True) @@ -280,7 +283,7 @@ class ColorRoles(commands.Cog): embed1 = discord.Embed( title="Available Color Roles (Part 1)", description="Use `/color set` to pick one!", - color=discord.Color.purple() + color=get_embed_color(interaction.user.id) ) embed1.set_image(url=f"attachment://{color_image_files[0].name}") await interaction.followup.send(embed=embed1, file=file1, ephemeral=False) @@ -290,7 +293,7 @@ class ColorRoles(commands.Cog): embed2 = discord.Embed( title="Available Color Roles (Part 2)", description="More colors to choose from!", - color=discord.Color.purple() + color=get_embed_color(interaction.user.id) ) embed2.set_image(url=f"attachment://{color_image_files[1].name}") await interaction.followup.send(embed=embed2, file=file2, ephemeral=False) @@ -300,13 +303,13 @@ class ColorRoles(commands.Cog): embed = discord.Embed( title="Available Color Roles", description="Use `/color set` to pick one!", - color=discord.Color.purple() + color=get_embed_color(interaction.user.id) ) embed.set_image(url="attachment://colorimage.png") await interaction.followup.send(embed=embed, file=file, ephemeral=False) except Exception as e: - print(f"[ERROR] /color list\n{traceback.format_exc()}") + logger.error(f"/color list: {e}", exc_info=True) msg = f"Error in `/color list`: `{e}`" if DEBUG else "Something went wrong loading color images." await interaction.followup.send(msg, ephemeral=True) diff --git a/commands/roletrack.py b/commands/role_track.py similarity index 89% rename from commands/roletrack.py rename to commands/role_track.py index e5d9b0a..d14a0ac 100644 --- a/commands/roletrack.py +++ b/commands/role_track.py @@ -1,15 +1,22 @@ +"""Role tracking cog that monitors and records role assignment history.""" import discord from discord import app_commands from discord.ext import commands import aiosqlite -import os +from pathlib import Path +from embed.embed_color import get_embed_color +from utils.logger import get_logger + +logger = get_logger(__name__) class RoleTrack(commands.Cog): def __init__(self, bot): self.bot = bot - self.db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "roletrack.db") - bot.loop.create_task(self.init_db()) - + self.db_path = Path(__file__).parent.parent / "data" / "roletrack.db" + + async def cog_load(self): + await self.init_db() + async def init_db(self): async with aiosqlite.connect(self.db_path) as db: # role_ids stored as comma-separated string @@ -63,7 +70,7 @@ class RoleTrack(commands.Cog): embed = discord.Embed( title="✅ Roles Synced", description=f"Successfully saved {role_count} role(s) to the tracking system.", - color=discord.Color.green() + color=get_embed_color(interaction.user.id) ) embed.add_field( name="What does this do?", @@ -85,7 +92,7 @@ class RoleTrack(commands.Cog): embed = discord.Embed( title="❌ Sync Failed", description=f"An error occurred while syncing your roles: {str(e)}", - color=discord.Color.red() + color=get_embed_color(interaction.user.id) ) await interaction.followup.send(embed=embed, ephemeral=True) @@ -101,10 +108,10 @@ class RoleTrack(commands.Cog): embed = discord.Embed( title="📋 Saved Roles", description="No roles are currently saved for you in the database.", - color=discord.Color.orange() + color=get_embed_color(interaction.user.id) ) else: - roles = [] + roles = [] for role_id in saved_role_ids: role = interaction.guild.get_role(role_id) if role: @@ -115,7 +122,7 @@ class RoleTrack(commands.Cog): embed = discord.Embed( title="📋 Saved Roles", description=f"Found {len(saved_role_ids)} role(s) in the database:", - color=discord.Color.blue() + color=get_embed_color(interaction.user.id) ) embed.add_field( name="Roles", @@ -129,7 +136,7 @@ class RoleTrack(commands.Cog): embed = discord.Embed( title="❌ Check Failed", description=f"An error occurred: {str(e)}", - color=discord.Color.red() + color=get_embed_color(interaction.user.id) ) await interaction.followup.send(embed=embed, ephemeral=True) @@ -162,7 +169,7 @@ class RoleTrack(commands.Cog): embed = discord.Embed( title="🎭 Roles Restored", description=f"Welcome back to **{member.guild.name}**! Your roles have been automatically restored.", - color=discord.Color.blue() + color=get_embed_color(member.id) ) embed.add_field( name="Restored Roles", @@ -170,7 +177,7 @@ class RoleTrack(commands.Cog): inline=False ) await member.send(embed=embed) - except: + except Exception: # User has DMs disabled, that's fine pass @@ -178,7 +185,7 @@ class RoleTrack(commands.Cog): # Bot doesn't have permission to add roles pass except Exception as e: - print(f"Error restoring roles for {member}: {e}") + logger.error(f"Error restoring roles for {member}: {e}") @commands.Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member): diff --git a/embed/__init__.py b/embed/__init__.py new file mode 100644 index 0000000..67baaeb --- /dev/null +++ b/embed/__init__.py @@ -0,0 +1 @@ +"""Embed color management for consistent bot embed styling.""" diff --git a/embed/embedcolor.py b/embed/embed_color.py similarity index 86% rename from embed/embedcolor.py rename to embed/embed_color.py index 8a36851..fc9047f 100644 --- a/embed/embedcolor.py +++ b/embed/embed_color.py @@ -1,10 +1,23 @@ +"""Embed color preference system allowing users to set custom embed colors.""" import discord import sqlite3 -import os +from pathlib import Path from discord.ext import commands from discord import app_commands -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "embed_colors.db") +DB_PATH = Path(__file__).parent.parent / "data" / "embed_colors.db" + + +def get_embed_color(user_id: int) -> discord.Color: + db = sqlite3.connect(DB_PATH) + cursor = db.cursor() + cursor.execute("SELECT color FROM user_embed_colors WHERE user_id = ?", (user_id,)) + result = cursor.fetchone() + db.close() + if result and result[0]: + return discord.Color(int(result[0], 16)) + return discord.Color.blurple() + class EmbedColor(commands.Cog): def __init__(self, bot): diff --git a/events/__init__.py b/events/__init__.py new file mode 100644 index 0000000..a0d6945 --- /dev/null +++ b/events/__init__.py @@ -0,0 +1 @@ +"""Event listeners and automated monitoring (welcome, webhooks, status alerts).""" diff --git a/events/archipelago_monitor.py b/events/archipelago_monitor.py index fa54c52..63c279c 100644 --- a/events/archipelago_monitor.py +++ b/events/archipelago_monitor.py @@ -8,15 +8,15 @@ import discord from discord.ext import commands, tasks import asyncio import re -import logging import os from dotenv import load_dotenv from typing import Optional, Set from pathlib import Path +from utils.logger import get_logger load_dotenv() -logger = logging.getLogger('ArchipelagoMonitor') +logger = get_logger(__name__) class ArchipelagoMonitor(commands.Cog): @@ -44,24 +44,16 @@ class ArchipelagoMonitor(commands.Cog): 'server_start': re.compile(r'Hosting game at (.+?) \(Password: (.+?)\)'), } - # Print startup info - print(f"[Archipelago] Initializing log monitor...") - print(f"[Archipelago] Enabled: {self.enabled}") - print(f"[Archipelago] Channel ID: {self.channel_id}") - print(f"[Archipelago] Log directory: {self.log_directory}") - + logger.info(f"Initializing log monitor - Enabled: {self.enabled}, Channel: {self.channel_id}, Log dir: {self.log_directory}") + if self.enabled and self.channel_id: - logger.info(f"Archipelago monitor enabled - Channel: {self.channel_id}, Log dir: {self.log_directory}") - print(f"[Archipelago] Monitor will start when bot is ready") + logger.info("Monitor will start when bot is ready") self.monitor_log.start() - print(f"[Archipelago] Monitor task started") else: - logger.info("Archipelago monitor disabled (check .env configuration)") - print(f"[Archipelago] Monitor DISABLED - check your .env file") if not self.enabled: - print(f"[Archipelago] - ARCHIPELAGO_ENABLED is not true") + logger.info("Monitor DISABLED: ARCHIPELAGO_ENABLED is not true") if not self.channel_id: - print(f"[Archipelago] - ARCHIPELAGO_CHANNEL_ID is not set") + logger.info("Monitor DISABLED: ARCHIPELAGO_CHANNEL_ID is not set") async def cog_unload(self): """Called when the cog is unloaded.""" diff --git a/events/botban.py b/events/botban.py index 4432c9f..bdaa7b9 100644 --- a/events/botban.py +++ b/events/botban.py @@ -1,7 +1,10 @@ +"""Auto-ban cog that bans users who receive a specific bot-trap role.""" import discord from discord.ext import commands from datetime import datetime, timedelta, timezone -import sys +from utils.logger import get_logger + +logger = get_logger(__name__) ROLE_ID_TO_BAN = 1439354601672282335 LOG_CHANNEL_ID = 1440055015711703242 @@ -95,8 +98,7 @@ class AutoBanOnRole(commands.Cog): @commands.Cog.listener() async def on_ready(self): - sys.stdout.write("[INFO] AutoBanOnRole cog loaded and ready!\n") - sys.stdout.flush() + logger.info("AutoBanOnRole cog loaded and ready!") async def setup(bot: commands.Bot): await bot.add_cog(AutoBanOnRole(bot)) \ No newline at end of file diff --git a/events/chain.py b/events/chain.py index 7641bdf..207e112 100644 --- a/events/chain.py +++ b/events/chain.py @@ -1,3 +1,4 @@ +"""Chain message event listener (reacts to chain messages in channels).""" import discord from discord.ext import commands diff --git a/events/git_webhook.py b/events/git_webhook.py index 02f05ef..dcce22c 100644 --- a/events/git_webhook.py +++ b/events/git_webhook.py @@ -1,3 +1,4 @@ +"""Webhook server that listens for GitHub push events and posts to Discord.""" import discord from discord.ext import commands from aiohttp import web @@ -5,9 +6,12 @@ import hmac import hashlib import os from dotenv import load_dotenv +from utils.logger import get_logger load_dotenv() +logger = get_logger(__name__) + COMMIT_CHANNEL_IDS = [876777562599194644, 1437941632849940563, 1470441786810826884] WEBHOOK_SECRET = os.getenv("GITHUB_WEBHOOK_SECRET", "") # Updated port to be 8000 to prevent conflicts (should work) @@ -29,9 +33,8 @@ class GitWebhook(commands.Cog): await self.runner.setup() self.site = web.TCPSite(self.runner, '0.0.0.0', WEBHOOK_PORT) await self.site.start() - print(f"✅ Git webhook server started on port {WEBHOOK_PORT}") - print(f" Configure GitHub to send webhooks to: http://159.195.45.90:{WEBHOOK_PORT}/webhook") - print(f" Sending notifications to {len(COMMIT_CHANNEL_IDS)} channel(s)") + logger.info(f"Git webhook server started on port {WEBHOOK_PORT}") + logger.info(f"Sending notifications to {len(COMMIT_CHANNEL_IDS)} channel(s)") async def cog_unload(self): """Stop the webhook server when cog unloads.""" @@ -39,7 +42,7 @@ class GitWebhook(commands.Cog): await self.site.stop() if self.runner: await self.runner.cleanup() - print("🛑 Git webhook server stopped") + logger.info("Git webhook server stopped") def verify_signature(self, payload_body, signature_header): """Verify GitHub webhook signature for security.""" @@ -73,9 +76,9 @@ class GitWebhook(commands.Cog): # Handle GitHub ping event (test from GitHub) if 'zen' in data and 'hook_id' in data: - print("✅ Received GitHub ping event - webhook is configured correctly!") + logger.info("Received GitHub ping event - webhook is configured correctly!") return web.json_response({"status": "pong"}, status=200) - + # Get all Discord channels channels = [] for channel_id in COMMIT_CHANNEL_IDS: @@ -83,10 +86,10 @@ class GitWebhook(commands.Cog): if channel: channels.append(channel) else: - print(f"⚠️ Channel {channel_id} not found!") - + logger.warning(f"Channel {channel_id} not found!") + if not channels: - print(f"❌ No valid channels found!") + logger.error("No valid channels found!") return web.json_response({"error": "No channels found"}, status=500) # Handle GitHub push events @@ -99,13 +102,11 @@ class GitWebhook(commands.Cog): await self.handle_gitlab_push(data, channels) return web.json_response({"status": "success"}, status=200) - print(f"⚠️ Unknown webhook format. Keys in data: {list(data.keys())}") + logger.warning(f"Unknown webhook format. Keys in data: {list(data.keys())}") return web.json_response({"error": "Unknown webhook format"}, status=400) - + except Exception as e: - print(f"❌ Webhook error: {e}") - import traceback - traceback.print_exc() + logger.error(f"Webhook error: {e}", exc_info=True) return web.json_response({"error": str(e)}, status=500) async def handle_github_push(self, data, channels): @@ -168,7 +169,7 @@ class GitWebhook(commands.Cog): try: await channel.send(embed=embed) except Exception as e: - print(f"❌ Failed to send to channel {channel.id}: {e}") + logger.error(f"Failed to send to channel {channel.id}: {e}") async def handle_gitlab_push(self, data, channels): """Handle GitLab push webhook.""" @@ -229,7 +230,7 @@ class GitWebhook(commands.Cog): try: await channel.send(embed=embed) except Exception as e: - print(f"❌ Failed to send to channel {channel.id}: {e}") + logger.error(f"Failed to send to channel {channel.id}: {e}") async def setup(bot): await bot.add_cog(GitWebhook(bot)) \ No newline at end of file diff --git a/events/goodbot.py b/events/goodbot.py index b0e8053..61e7225 100644 --- a/events/goodbot.py +++ b/events/goodbot.py @@ -1,3 +1,4 @@ +"""Goodbot/badbot event listener that reacts to compliments or insults.""" import discord from discord.ext import commands diff --git a/events/roletrack.py b/events/roletrack.py deleted file mode 100644 index c848fc2..0000000 --- a/events/roletrack.py +++ /dev/null @@ -1,140 +0,0 @@ -import discord -from discord.ext import commands -import sqlite3 -import os -import asyncio - -class RoleTrackEvents(commands.Cog): - def __init__(self, bot): - self.bot = bot - self.db_path = os.path.join(os.path.dirname(__file__), '..', 'roletrack.db') - - def is_opted_in(self, user_id: int, guild_id: int) -> bool: - """Check if a user is opted into role tracking""" - conn = sqlite3.connect(self.db_path) - c = conn.cursor() - - c.execute(''' - SELECT opted_in FROM role_tracking_users - WHERE user_id = ? AND guild_id = ? - ''', (user_id, guild_id)) - - result = c.fetchone() - conn.close() - - return result[0] == 1 if result else False - - def get_tracked_roles(self, user_id: int, guild_id: int): - """Retrieve tracked roles for a user""" - conn = sqlite3.connect(self.db_path) - c = conn.cursor() - - c.execute(''' - SELECT role_id, role_name FROM tracked_roles - WHERE user_id = ? AND guild_id = ? - ''', (user_id, guild_id)) - - roles = c.fetchall() - conn.close() - return roles - - def save_user_roles(self, member: discord.Member): - """Save all roles for a user (excluding @everyone)""" - if not self.is_opted_in(member.id, member.guild.id): - return - - conn = sqlite3.connect(self.db_path) - c = conn.cursor() - - # Clear existing roles for this user in this guild - c.execute(''' - DELETE FROM tracked_roles - WHERE user_id = ? AND guild_id = ? - ''', (member.id, member.guild.id)) - - # Save current roles (excluding @everyone) - for role in member.roles: - if role.id != member.guild.id: # Skip @everyone role - c.execute(''' - INSERT INTO tracked_roles (user_id, guild_id, role_id, role_name) - VALUES (?, ?, ?, ?) - ''', (member.id, member.guild.id, role.id, role.name)) - - conn.commit() - conn.close() - - @commands.Cog.listener() - async def on_member_join(self, member: discord.Member): - """Restore roles when a tracked user rejoins""" - if member.bot: - return - - # Check if user is opted in - if not self.is_opted_in(member.id, member.guild.id): - return - - # Get their tracked roles - tracked_roles = self.get_tracked_roles(member.id, member.guild.id) - - if not tracked_roles: - return - - # Wait a moment for Discord to fully process the join - await asyncio.sleep(1) - - # Restore roles - roles_to_add = [] - missing_roles = [] - - for role_id, role_name in tracked_roles: - role = member.guild.get_role(role_id) - if role: - # Check if bot can assign this role (bot's highest role must be higher) - if role < member.guild.me.top_role and not role.is_default(): - roles_to_add.append(role) - else: - missing_roles.append(role_name) - - # Add roles - if roles_to_add: - try: - await member.add_roles(*roles_to_add, reason="Role tracking: User rejoined server") - print(f"Restored {len(roles_to_add)} roles for {member.name} ({member.id})") - except discord.Forbidden: - print(f"Failed to restore roles for {member.name}: Missing permissions") - except discord.HTTPException as e: - print(f"Failed to restore roles for {member.name}: {e}") - - # Log if there were missing roles - if missing_roles: - print(f"Could not restore the following roles for {member.name} (roles no longer exist): {', '.join(missing_roles)}") - - @commands.Cog.listener() - async def on_member_update(self, before: discord.Member, after: discord.Member): - """Update tracked roles when a user's roles change""" - if after.bot: - return - - # Check if user is opted in - if not self.is_opted_in(after.id, after.guild.id): - return - - # Check if roles actually changed - if before.roles == after.roles: - return - - # Save updated roles - self.save_user_roles(after) - print(f"Updated tracked roles for {after.name} ({after.id})") - - @commands.Cog.listener() - async def on_member_remove(self, member: discord.Member): - """Optional: Log when a tracked user leaves""" - if member.bot: - return - - if self.is_opted_in(member.id, member.guild.id): - print(f"Tracked user {member.name} ({member.id}) left the server. Roles are saved for restoration.") - -async def setup(bot): - await bot.add_cog(RoleTrackEvents(bot)) \ No newline at end of file diff --git a/events/status_alerts.py b/events/status_alerts.py index c60a36f..7c4adf3 100644 --- a/events/status_alerts.py +++ b/events/status_alerts.py @@ -9,6 +9,9 @@ import os from pathlib import Path from datetime import datetime from zoneinfo import ZoneInfo +from utils.logger import get_logger + +logger = get_logger(__name__) class StatusMonitor(commands.Cog): def __init__(self, bot): @@ -86,7 +89,7 @@ class StatusMonitor(commands.Cog): try: await admin.send(embed=embed) except Exception as e: - print(f"Failed to send down alert for {service_name}: {e}") + logger.error(f"Failed to send down alert for {service_name}: {e}") # Service went DEGRADED elif current_status == "degraded" and previous_status == "up": @@ -104,7 +107,7 @@ class StatusMonitor(commands.Cog): try: await admin.send(embed=embed) except Exception as e: - print(f"Failed to send degraded alert for {service_name}: {e}") + logger.error(f"Failed to send degraded alert for {service_name}: {e}") # Service RECOVERED elif current_status == "up" and previous_status in ["down", "degraded"]: @@ -146,20 +149,20 @@ class StatusMonitor(commands.Cog): try: await admin.send(embed=embed) except Exception as e: - print(f"Failed to send recovery alert for {service_name}: {e}") + logger.error(f"Failed to send recovery alert for {service_name}: {e}") # Update last known status self.last_status[service_name] = current_status except Exception as e: - print(f"[StatusMonitor] Error checking status: {e}") + logger.error(f"Error checking status: {e}", exc_info=True) @check_status.before_loop async def before_check_status(self): """Wait for bot to be ready before starting the loop""" await self.bot.wait_until_ready() - print("[StatusMonitor] Starting status monitoring loop...") - + logger.info("Starting status monitoring loop...") + # Initialize last_status with current state on startup try: if self.db_path.exists(): @@ -181,9 +184,9 @@ class StatusMonitor(commands.Cog): self.last_status[row['service_name']] = row['status'] conn.close() - print(f"[StatusMonitor] Initialized with {len(self.last_status)} services") + logger.info(f"Initialized with {len(self.last_status)} services") except Exception as e: - print(f"[StatusMonitor] Failed to initialize: {e}") + logger.error(f"Failed to initialize: {e}", exc_info=True) @commands.command(name="statustest") @commands.is_owner() diff --git a/events/welcome.py b/events/welcome.py index 3ae0f05..f1baea6 100644 --- a/events/welcome.py +++ b/events/welcome.py @@ -1,3 +1,4 @@ +"""Welcome event cog that greets new members and assigns default roles.""" import discord from discord.ext import commands diff --git a/image/__init__.py b/image/__init__.py new file mode 100644 index 0000000..3fd5974 --- /dev/null +++ b/image/__init__.py @@ -0,0 +1 @@ +"""Image generation and avatar manipulation commands.""" diff --git a/image/avatar.py b/image/avatar.py index 8224942..322f77e 100644 --- a/image/avatar.py +++ b/image/avatar.py @@ -1,22 +1,27 @@ +"""Avatar manipulation commands (bitcrush, edge detect, explode, filters, etc.).""" import discord from discord import app_commands from discord.ext import commands from PIL import Image, ImageOps, ImageSequence import io +from embed.embed_color import get_embed_color import asyncio import aiohttp -import traceback import os +from pathlib import Path import numpy as np from scipy.ndimage import uniform_filter import cv2 +from utils.logger import get_logger + +logger = get_logger(__name__) class AvatarCommands(commands.Cog): def __init__(self, bot): self.bot = bot self.session = None - self.explosion_path = os.path.join(os.path.dirname(__file__), "..", "media", "explosion-deltarune.gif") - self.obama_path = os.path.join(os.path.dirname(__file__), "..", "media", "obama.jpg") + self.explosion_path = Path(__file__).parent.parent / "media" / "explosion-deltarune.gif" + self.obama_path = Path(__file__).parent.parent / "media" / "obama.jpg" async def cog_load(self): self.session = aiohttp.ClientSession() @@ -55,13 +60,9 @@ class AvatarCommands(commands.Cog): avatar = self.get_avatar_url(target, avatar_type) avatar_url = avatar.url - embed_color = discord.Color.blue() - if self.bot.get_cog("EmbedColor"): - embed_color = self.bot.get_cog("EmbedColor").get_user_color(interaction.user) - embed = discord.Embed( title=f"{target.display_name}'s Avatar", - color=embed_color + color=get_embed_color(interaction.user.id) ) embed.set_image(url=avatar_url) embed.add_field(name="Direct Link", value=f"[Open Avatar]({avatar_url})") @@ -108,9 +109,9 @@ class AvatarCommands(commands.Cog): file=file ) except Exception: - traceback.print_exc() + logger.exception("Error in avatar bitcrush") await interaction.followup.send("An error occurred while processing the image.", ephemeral=True) - + def _bitcrush_image(self, image_bytes: bytes, bits: int) -> bytes: img = Image.open(io.BytesIO(image_bytes)).convert("RGB") colors = 2 ** bits @@ -169,9 +170,9 @@ class AvatarCommands(commands.Cog): file=file ) except Exception: - traceback.print_exc() + logger.exception("Error in avatar canny_edge") await interaction.followup.send("An error occurred while processing the image.", ephemeral=True) - + def _canny_edge_detection(self, image_bytes: bytes, threshold1: int, threshold2: int) -> bytes: # Load image and convert to grayscale img = Image.open(io.BytesIO(image_bytes)).convert("RGB") @@ -226,7 +227,7 @@ class AvatarCommands(commands.Cog): await interaction.followup.send(f"{user.display_name} just got exploded!", file=file) except Exception: - traceback.print_exc() + logger.exception("Error in avatar explode") await interaction.followup.send("An error occurred while processing the explosion.", ephemeral=True) def _explode_avatar(self, avatar_bytes: bytes) -> bytes: @@ -288,9 +289,9 @@ class AvatarCommands(commands.Cog): file=file ) except Exception: - traceback.print_exc() + logger.exception("Error in avatar grayscale") await interaction.followup.send("An error occurred while processing the image.", ephemeral=True) - + def _grayscale_image(self, image_bytes: bytes) -> bytes: img = Image.open(io.BytesIO(image_bytes)).convert("RGB") grayscaled = ImageOps.grayscale(img) @@ -331,9 +332,9 @@ class AvatarCommands(commands.Cog): file=file ) except Exception: - traceback.print_exc() + logger.exception("Error in avatar inverse") await interaction.followup.send("An error occurred while processing the image.", ephemeral=True) - + def _invert_image(self, image_bytes: bytes) -> bytes: img = Image.open(io.BytesIO(image_bytes)).convert("RGB") inverted = ImageOps.invert(img) @@ -382,9 +383,9 @@ class AvatarCommands(commands.Cog): file=file ) except Exception: - traceback.print_exc() + logger.exception("Error in avatar kuwahara") await interaction.followup.send("An error occurred while processing the image.", ephemeral=True) - + def _kuwahara_filter(self, image_bytes: bytes, kernel_size: int) -> bytes: img = Image.open(io.BytesIO(image_bytes)).convert("RGB") img_array = np.array(img, dtype=np.float32) @@ -470,7 +471,7 @@ class AvatarCommands(commands.Cog): await interaction.followup.send(file=discord.File(buf, filename="obama_mosaic.png")) except Exception: - traceback.print_exc() + logger.exception("Error in avatar obamify") await interaction.followup.send("An error occurred during mosaic generation.", ephemeral=True) async def _fetch_avatar(self, url: str) -> Image.Image: diff --git a/lilac-tools/__init__.py b/lilac-tools/__init__.py new file mode 100644 index 0000000..71788c4 --- /dev/null +++ b/lilac-tools/__init__.py @@ -0,0 +1 @@ +"""Administrative tools and bot management commands for the bot owner.""" diff --git a/lilac-tools/admin.py b/lilac-tools/admin.py index 205b4eb..91e9b80 100644 --- a/lilac-tools/admin.py +++ b/lilac-tools/admin.py @@ -1,3 +1,4 @@ +"""Owner-only admin tools: database browser, terminal, eval, file manager.""" import discord from discord.ext import commands import aiosqlite @@ -5,8 +6,10 @@ import asyncio import os import glob from typing import Optional -import traceback import re +from utils.logger import get_logger + +logger = get_logger(__name__) ADMIN_USER_ID = 252130669919076352 @@ -100,7 +103,7 @@ class AdminCommands(commands.Cog): db_files = await loop.run_in_executor(None, _glob) self.db_connections = {os.path.basename(f).replace('.db', ''): f for f in db_files} - print(f"Discovered databases: {list(self.db_connections.keys())}") + logger.info(f"Discovered databases: {list(self.db_connections.keys())}") @commands.command(name="admin_help", aliases=["adminhelp", "ahelp"]) @is_owner() @@ -398,7 +401,7 @@ class AdminCommands(commands.Cog): await ctx.send(embed=embed) except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @commands.command(name="dbinfo") @is_owner() @@ -438,7 +441,7 @@ class AdminCommands(commands.Cog): await ctx.send(embed=embed) except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @commands.command(name="dbquery") @is_owner() @@ -476,7 +479,7 @@ class AdminCommands(commands.Cog): await ctx.send(f"```\n{output}\n```") except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") def _write_file(self, filename, content): """Helper to write file synchronously in executor""" @@ -500,7 +503,7 @@ class AdminCommands(commands.Cog): await ctx.send(f"✅ Query executed successfully. Rows affected: {affected_rows}") except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @commands.command(name="terminal", aliases=["term", "sh"]) @is_owner() @@ -544,7 +547,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @commands.command(name="eval") @is_owner() @@ -611,7 +614,7 @@ class AdminCommands(commands.Cog): except Exception as e: error_msg = f"```python\n{type(e).__name__}: {str(e)}\n```" await ctx.send(f"❌ Error:\n{error_msg}") - traceback.print_exc() + logger.exception("Error in admin command") @commands.command(name="pwd") @is_owner() @@ -764,7 +767,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @code.command(name="search", aliases=["grep", "find-all"]) @is_owner() @@ -805,7 +808,7 @@ class AdminCommands(commands.Cog): }) if len(results) >= 100: # Limit results return results, "truncated" - except: + except Exception: continue return results, None @@ -860,7 +863,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @code.command(name="find") @is_owner() @@ -927,11 +930,11 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @code.command(name="edit") @is_owner() @@ -1079,7 +1082,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @code.command(name="insert") @is_owner() @@ -1124,7 +1127,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @code.command(name="delete", aliases=["del"]) @is_owner() @@ -1172,7 +1175,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @code.command(name="replace") @is_owner() @@ -1233,7 +1236,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @code.command(name="backup") @is_owner() @@ -1265,7 +1268,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @code.command(name="diff") @is_owner() @@ -1319,7 +1322,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") @commands.command(name="grep") @is_owner() @@ -1356,7 +1359,7 @@ class AdminCommands(commands.Cog): results.append(f"{filepath}:{i}: {line.rstrip()}") if len(results) >= 50: # Limit results return results, "truncated" - except: + except Exception: continue return results, None @@ -1385,7 +1388,7 @@ class AdminCommands(commands.Cog): except Exception as e: await ctx.send(f"❌ Error: {str(e)}") - traceback.print_exc() + logger.exception("Error in admin command") async def setup(bot): await bot.add_cog(AdminCommands(bot)) \ No newline at end of file diff --git a/moderation/__init__.py b/moderation/__init__.py new file mode 100644 index 0000000..3b29207 --- /dev/null +++ b/moderation/__init__.py @@ -0,0 +1 @@ +"""Moderation tools and admin permission management.""" diff --git a/moderation/ban.py b/moderation/ban.py index 2a531d7..d85319c 100644 --- a/moderation/ban.py +++ b/moderation/ban.py @@ -1,3 +1,4 @@ +"""Ban command with confirmation prompt and DM notification.""" import discord from discord.ext import commands from discord.ui import View, Button @@ -62,7 +63,7 @@ class BanCommand(ModerationBase): f"Reason: {reason or 'No reason provided'}\n\n" f"If you believe this ban was unfiair and would like to appeal, join here: https://discord.gg/FYpfBzpjvq" ) - except: + except Exception: await ctx.send("Could not DM the user.") # Perform the ban diff --git a/moderation/cleanban.py b/moderation/cleanban.py index 2db31de..eba0b52 100644 --- a/moderation/cleanban.py +++ b/moderation/cleanban.py @@ -1,3 +1,4 @@ +"""Cleanban command that bans and deletes recent messages.""" import discord from discord.ext import commands from discord.ui import View, Button @@ -69,7 +70,7 @@ class CleanBanCommand(ModerationBase): f"Messages from the past {days} day(s) have been deleted.\n" f"Reason: {reason or 'No reason provided'}\n\n" ) - except: + except Exception: await ctx.send("Could not DM the user.") # Perform the ban with message deletion diff --git a/moderation/infraction.py b/moderation/infraction.py index 1e4a05c..4ec13f0 100644 --- a/moderation/infraction.py +++ b/moderation/infraction.py @@ -1,7 +1,11 @@ +"""Infraction logging and retrieval system for moderation actions.""" import discord from discord.ext import commands, tasks from .loader import ModerationBase from datetime import datetime, timedelta +from utils.logger import get_logger + +logger = get_logger(__name__) class InfractionCommand(ModerationBase): @@ -69,7 +73,7 @@ class InfractionCommand(ModerationBase): await self.check_user_eligibility(guild_id, user_id) except Exception as e: - print(f"Error in auto-removal check: {e}") + logger.error(f"Error in auto-removal check: {e}", exc_info=True) @check_auto_removals.before_loop async def before_check_auto_removals(self): @@ -136,7 +140,7 @@ class InfractionCommand(ModerationBase): await self.send_removal_approval(guild_id, user_id, inf_id, inf_type, reason, timestamp_str, mod_id) except Exception as e: - print(f"Error checking eligibility for user {user_id} in guild {guild_id}: {e}") + logger.error(f"Error checking eligibility for user {user_id} in guild {guild_id}: {e}", exc_info=True) async def send_removal_approval(self, guild_id: int, user_id: int, inf_id: int, inf_type: str, reason: str, timestamp: str, mod_id: int): @@ -144,7 +148,7 @@ class InfractionCommand(ModerationBase): try: approval_channel = self.bot.get_channel(self.approval_channel_id) if not approval_channel: - print(f"Approval channel {self.approval_channel_id} not found") + logger.error(f"Approval channel {self.approval_channel_id} not found") return # Get guild, user, and moderator info @@ -155,13 +159,13 @@ class InfractionCommand(ModerationBase): try: user = await self.bot.fetch_user(user_id) user_tag = f"{user.name}#{user.discriminator}" - except: + except Exception: user_tag = f"Unknown User ({user_id})" try: moderator = await self.bot.fetch_user(mod_id) mod_tag = f"{moderator.name}#{moderator.discriminator}" - except: + except Exception: mod_tag = f"Unknown Mod ({mod_id})" # Create approval embed @@ -193,7 +197,7 @@ class InfractionCommand(ModerationBase): self.conn.commit() except Exception as e: - print(f"Error sending removal approval: {e}") + logger.error(f"Error sending removal approval: {e}", exc_info=True) @commands.command(name="inf") @ModerationBase.is_admin() @@ -551,7 +555,7 @@ class InfractionRemovalView(discord.ui.View): notify_embed.set_footer(text=f"You stayed clean for 4 months! Keep up the good behavior.") await user.send(embed=notify_embed) - except: + except Exception: pass # User has DMs disabled or bot can't reach them except Exception as e: diff --git a/moderation/kick.py b/moderation/kick.py index 7eaafb5..706f433 100644 --- a/moderation/kick.py +++ b/moderation/kick.py @@ -1,3 +1,4 @@ +"""Kick command with confirmation prompt and DM notification.""" import discord from discord.ext import commands from discord.ui import View, Button @@ -41,7 +42,7 @@ class KickCommand(ModerationBase): try: await user.send(f"You have been **kicked** from **{ctx.guild.name}**.\nReason: {reason or 'No reason provided'}") - except: + except Exception: await ctx.send("Could not DM the user.") await ctx.guild.kick(user, reason=reason) diff --git a/moderation/loader.py b/moderation/loader.py index 000197d..a4905fb 100644 --- a/moderation/loader.py +++ b/moderation/loader.py @@ -1,8 +1,11 @@ +"""Shared ModerationBase cog with DB connection and is_admin() decorator.""" from discord.ext import commands import os import sqlite3 +from pathlib import Path from dotenv import load_dotenv from datetime import datetime +from utils.constants import LILAC_ID load_dotenv() @@ -13,14 +16,12 @@ ADMIN_ROLE_IDS = { if role_id.strip().isdigit() } -lilac_id = 252130669919076352 - class ModerationBase(commands.Cog): """Base cog for moderation commands with shared DB and utilities""" def __init__(self, bot: commands.Bot): self.bot = bot - self.db_path = os.path.join(os.path.dirname(__file__), "moderation.db") + self.db_path = Path(__file__).parent / "moderation.db" self.conn = sqlite3.connect(self.db_path) self.conn.row_factory = sqlite3.Row self.c = self.conn.cursor() @@ -70,7 +71,7 @@ class ModerationBase(commands.Cog): await send_message("Unable to check permissions in this context.", ephemeral=is_interaction) return False - is_lilac = user.id == lilac_id + is_lilac = user.id == LILAC_ID has_admin_role = any( role.id in ADMIN_ROLE_IDS diff --git a/moderation/lock.py b/moderation/lock.py index 292285a..a4719b3 100644 --- a/moderation/lock.py +++ b/moderation/lock.py @@ -1,18 +1,21 @@ +"""Channel lock/unlock commands that save and restore permission overwrites.""" import discord from discord.ext import commands -import traceback from .loader import ModerationBase -import os +from pathlib import Path import sqlite3 import json import asyncio from functools import partial +from utils.logger import get_logger + +logger = get_logger(__name__) class LockCog(commands.Cog): def __init__(self, bot): self.bot = bot - self.db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "moderation.db") + self.db_path = Path(__file__).parent.parent / "data" / "moderation.db" self.conn = sqlite3.connect(self.db_path, check_same_thread=False) self.conn.row_factory = sqlite3.Row self.c = self.conn.cursor() @@ -195,7 +198,7 @@ class LockCog(commands.Cog): failed_targets.append(target.name) if failed_targets: - print(f"Warning: Could not clear permissions for: {', '.join(failed_targets)}") + logger.warning(f"Could not clear permissions for: {', '.join(failed_targets)}") # Deny everyone from talking try: @@ -215,7 +218,7 @@ class LockCog(commands.Cog): # If we can't set ritual member, at least try to undo the everyone change try: await channel.set_permissions(everyone_role, overwrite=None) - except: + except Exception: pass await ctx.message.add_reaction("❌") return await ctx.send(f"Cannot modify {ritual_member_role.name} permissions! My role needs to be higher than that role.", allowed_mentions=discord.AllowedMentions.none()) @@ -226,23 +229,23 @@ class LockCog(commands.Cog): # Try to send in channel try: await ctx.send(f"#{channel.name} has been locked! Only {ritual_member_role.name} can talk.", allowed_mentions=discord.AllowedMentions.none()) - except: + except Exception: pass except discord.Forbidden as e: await ctx.message.add_reaction("❌") try: await ctx.send(f"Missing permissions! My role needs to be higher than the roles I'm trying to modify.", allowed_mentions=discord.AllowedMentions.none()) - except: + except Exception: pass - traceback.print_exc() + logger.exception("Error in lock/unlock command") except Exception as e: await ctx.message.add_reaction("❌") try: await ctx.send(f"Error: {e}", allowed_mentions=discord.AllowedMentions.none()) - except: + except Exception: pass - traceback.print_exc() + logger.exception("Error in lock/unlock command") @commands.command(name="unlock") @ModerationBase.is_admin() @@ -290,23 +293,23 @@ class LockCog(commands.Cog): if len(failed_targets) > 5: msg += f" and {len(failed_targets) - 5} more" await ctx.send(msg, allowed_mentions=discord.AllowedMentions.none()) - except: + except Exception: pass except discord.Forbidden as e: await ctx.message.add_reaction("❌") try: await ctx.send(f"Missing permissions! My role needs to be higher than the roles I'm trying to modify.", allowed_mentions=discord.AllowedMentions.none()) - except: + except Exception: pass - traceback.print_exc() + logger.exception("Error in lock/unlock command") except Exception as e: await ctx.message.add_reaction("❌") try: await ctx.send(f"Error: {e}", allowed_mentions=discord.AllowedMentions.none()) - except: + except Exception: pass - traceback.print_exc() + logger.exception("Error in lock/unlock command") async def setup(bot): diff --git a/moderation/logger.py b/moderation/logger.py index 90236c1..56078f5 100644 --- a/moderation/logger.py +++ b/moderation/logger.py @@ -1,17 +1,20 @@ +"""Moderation event logger that records Discord actions to the database.""" import discord from discord.ext import commands import sqlite3 -import os +from pathlib import Path from datetime import datetime, timezone from typing import Optional -import traceback +from utils.logger import get_logger + +_logger = get_logger(__name__) class Logger(commands.Cog): """Core logging system that listens to Discord events and logs them""" def __init__(self, bot: commands.Bot): self.bot = bot - self.db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "moderation.db") + self.db_path = Path(__file__).parent.parent / "data" / "moderation.db" self.initialize_db() # Cache for deleted messages (for bulk delete context) self.message_cache = {} @@ -76,10 +79,9 @@ class Logger(commands.Cog): try: msg = await channel.send(embed=embed) except discord.Forbidden as e: - print(f"[ERROR] Missing permissions to send log in channel {channel_id}: {e}") + _logger.error(f"Missing permissions to send log in channel {channel_id}: {e}") except Exception as e: - print(f"[ERROR] Error sending log: {e}") - traceback.print_exc() + _logger.error(f"Error sending log: {e}", exc_info=True) @commands.Cog.listener() async def on_message_delete(self, message: discord.Message): @@ -188,7 +190,7 @@ class Logger(commands.Cog): except Exception as e: error_text = f"⚠️ **Error while logging member join:**\n`{type(e).__name__}: {e}`" - traceback.print_exc() + _logger.error(f"Error logging member join: {e}", exc_info=True) # Attempt to send the error to the designated debug channel try: @@ -196,9 +198,9 @@ class Logger(commands.Cog): if channel: await channel.send(error_text) else: - print("[ERROR] Could not find error logging channel (1424145004976275617).") + _logger.error("Could not find error logging channel (1424145004976275617).") except Exception as send_err: - print(f"[ERROR] Failed to send error message to debug channel: {send_err}") + _logger.error(f"Failed to send error message to debug channel: {send_err}") @commands.Cog.listener() async def on_member_remove(self, member: discord.Member): diff --git a/moderation/modhelp.py b/moderation/modhelp.py new file mode 100644 index 0000000..3b43fc3 --- /dev/null +++ b/moderation/modhelp.py @@ -0,0 +1,66 @@ +import discord +from discord.ext import commands +from .loader import ModerationBase + + +class ModHelp(ModerationBase): + @commands.command(name="modhelp") + @ModerationBase.is_admin() + async def modhelp(self, ctx): + embeds = [] + + def embed(title, color=discord.Color.blurple()): + e = discord.Embed(title=title, color=color) + return e + + e1 = embed("Moderation Commands — Member Actions", discord.Color.red()) + e1.add_field(name="!ban [reason]", value="Ban a user. Accepts mention, ID, or name. Works even if they're not in the server. Sends a DM.", inline=False) + e1.add_field(name="!cleanban [days] [reason]", value="Ban a user and delete their recent messages. `days` is 1–7 (default 1). Sends a DM.", inline=False) + e1.add_field(name="!kick [reason]", value="Kick a user from the server. Sends a DM.", inline=False) + e1.add_field(name="!unban [reason]", value="Unban a user. Accepts mention, ID, or username.", inline=False) + e1.add_field(name="!warn [reason]", value="Issue a warning to a user. Sends a DM and logs the infraction.", inline=False) + e1.add_field(name="!mute [reason]", value="Mute a user for a set duration. Format: `1w`, `5d`, `12h`, `30m`. Auto-unmutes when time is up.", inline=False) + e1.add_field(name="!unmute ", value="Manually remove a mute from a user.", inline=False) + embeds.append(e1) + + e2 = embed("Moderation Commands — Purge", discord.Color.orange()) + e2.add_field(name="!purge ", value="Delete all messages up to (and including) the specified message ID in the current channel.", inline=False) + e2.add_field(name="!purgemember ", value="Delete messages from a specific user up to the specified message ID. Aliases: `purgeuser`, `purgeu`, `purgem`", inline=False) + e2.add_field(name="!purgebot ", value="Delete bot messages up to the specified message ID. Aliases: `purgebots`, `purgeb`", inline=False) + e2.add_field(name="!purgecontains ", value="Delete messages containing specific text up to the specified message ID. Aliases: `purgec`, `purgetext`", inline=False) + e2.add_field(name="!purgeembeds ", value="Delete messages that contain embeds up to the specified message ID. Aliases: `purgee`, `purgeembed`", inline=False) + e2.add_field(name="!purgememberall ", value="Delete **all** messages from a user across every text channel. Requires confirmation. Aliases: `purgeuserall`, `purgeua`, `purgeallm`", inline=False) + embeds.append(e2) + + e3 = embed("Moderation Commands — Channel & Infractions", discord.Color.gold()) + e3.add_field(name="!lock [channel]", value="Lock a channel so only staff can send messages. Defaults to the current channel. Saves original permissions for restore.", inline=False) + e3.add_field(name="!unlock [channel]", value="Unlock a previously locked channel and restore its original permissions. Defaults to the current channel.", inline=False) + e3.add_field(name="!checkperms [channel]", value="Check the bot's permissions in a channel and display role hierarchy info. Defaults to the current channel.", inline=False) + e3.add_field(name="\u200b", value="**Infractions**", inline=False) + e3.add_field(name="!inf search ", value="Show all active infractions for a user in this server.", inline=False) + e3.add_field(name="!inf search_full ", value="Show the full infraction history for a user, including removed infractions.", inline=False) + e3.add_field(name="!inf list", value="List all active infractions in this server.", inline=False) + e3.add_field(name="!inf delete ", value="Permanently delete an infraction by its ID. The user is notified via DM.", inline=False) + embeds.append(e3) + + e4 = embed("Moderation Commands — Logging", discord.Color.blue()) + e4.add_field(name="!log set <#channel> ", value="Route a log type to a channel. Use `!log types` to see all available types.", inline=False) + e4.add_field(name="!log remove ", value="Remove the channel assignment for a log type.", inline=False) + e4.add_field(name="!log list", value="Show all configured log types and their channels.", inline=False) + e4.add_field(name="!log types", value="List every available log type.", inline=False) + e4.add_field(name="!log exclude ", value="Exclude a channel from being logged. Events in that channel won't appear in any log.", inline=False) + e4.add_field(name="!log unexclude ", value="Remove a channel from the exclusion list.", inline=False) + e4.add_field(name="!log excluded", value="List all channels currently excluded from logging.", inline=False) + e4.add_field(name="!log clear", value="Remove **all** logging configurations for this server. Requires confirmation.", inline=False) + embeds.append(e4) + + e5 = embed("Moderation Commands — Tools", discord.Color.green()) + e5.add_field(name="!send_embed <#channel> ", value="Send an embed (built with the embed builder) to a channel. Previews before sending. Requires confirmation.", inline=False) + e5.set_footer(text="Arguments in <> are required. Arguments in [] are optional.") + embeds.append(e5) + + await ctx.send(embeds=embeds) + + +async def setup(bot: commands.Bot): + await bot.add_cog(ModHelp(bot)) diff --git a/moderation/mute.py b/moderation/mute.py index 28f1122..2838217 100644 --- a/moderation/mute.py +++ b/moderation/mute.py @@ -1,13 +1,16 @@ +"""Mute command with duration parsing and scheduled unmute task.""" import discord from discord.ext import commands, tasks from discord.ui import View, Button import asyncio import re import sqlite3 -import os +from pathlib import Path from datetime import timedelta, datetime from .loader import ModerationBase +DB_PATH = Path(__file__).parent.parent / "data" / "moderation.db" + MUTE_ROLE_ID = 982702037517090836 class MuteCommand(ModerationBase): @@ -77,14 +80,13 @@ class MuteCommand(ModerationBase): await user.add_roles(mute_role, reason=reason) try: await user.send(f"You have been muted in **{ctx.guild.name}** for **{duration}**.\nReason: {reason or 'No reason provided'}") - except: + except Exception: await ctx.send("Could not DM the user.") await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "mute", reason) await ctx.send(f"{user.mention} has been muted for **{duration}**.") - db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "moderation.db") - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute(""" CREATE TABLE IF NOT EXISTS mutes ( @@ -111,8 +113,7 @@ class MuteCommand(ModerationBase): async def schedule_unmute(self, user_id, guild_id, channel_id, delay): await asyncio.sleep(delay) - db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "moderation.db") - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("SELECT 1 FROM mutes WHERE user_id = ? AND guild_id = ?", (user_id, guild_id)) exists = c.fetchone() @@ -140,13 +141,12 @@ class MuteCommand(ModerationBase): await logger.log_moderation_action( guild_id, "unmute", member, self.bot.user, "Mute duration expired" ) - except: + except Exception: pass @tasks.loop(minutes=1) async def check_mutes(self): - db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "moderation.db") - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(DB_PATH) c = conn.cursor() now = datetime.utcnow().isoformat() c.execute("SELECT user_id, guild_id, channel_id, unmute_time FROM mutes WHERE unmute_time <= ?", (now,)) diff --git a/moderation/spam_protection.py b/moderation/spam_protection.py index 3ea3e41..b4582a9 100644 --- a/moderation/spam_protection.py +++ b/moderation/spam_protection.py @@ -1,3 +1,4 @@ +"""Automatic spam detection and rate-limiting with auto-mute.""" import discord from discord.ext import commands, tasks from discord.ui import View, Button @@ -5,7 +6,10 @@ import asyncio from datetime import datetime, timedelta, timezone from collections import defaultdict, deque import sqlite3 -import os +from pathlib import Path +from utils.logger import get_logger + +logger = get_logger(__name__) MUTE_ROLE_ID = 982702037517090836 STAFF_CHANNEL_ID = 876780367296745493 @@ -17,7 +21,7 @@ class SpamProtection(commands.Cog): def __init__(self, bot: commands.Bot): self.bot = bot - self.db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "moderation.db") + self.db_path = Path(__file__).parent.parent / "data" / "moderation.db" # Track user message patterns # user_id -> deque of (timestamp, channel_id, content) @@ -149,7 +153,7 @@ class SpamProtection(commands.Cog): f"Automatically muted for 1 day." ) except Exception as e: - print(f"[ERROR] Failed to apply default spam action: {e}") + logger.error(f"Failed to apply default spam action: {e}", exc_info=True) @commands.Cog.listener() async def on_message(self, message: discord.Message): @@ -192,7 +196,7 @@ class SpamProtection(commands.Cog): except asyncio.QueueEmpty: break except Exception as e: - print(f"[ERROR] Error in message queue processing: {e}") + logger.error(f"Error in message queue processing: {e}", exc_info=True) async def _process_message(self, message: discord.Message): """Actually process the message for spam detection""" @@ -269,16 +273,16 @@ class SpamProtection(commands.Cog): # Apply mute role mute_role = guild.get_role(MUTE_ROLE_ID) if not mute_role: - print(f"[ERROR] Mute role {MUTE_ROLE_ID} not found in guild {guild.id}") + logger.error(f"Mute role {MUTE_ROLE_ID} not found in guild {guild.id}") return - + try: await member.add_roles(mute_role, reason="Automatic spam detection") except discord.Forbidden: - print(f"[ERROR] Missing permissions to mute {member.id}") + logger.error(f"Missing permissions to mute {member.id}") return except Exception as e: - print(f"[ERROR] Failed to mute spammer: {e}") + logger.error(f"Failed to mute spammer: {e}", exc_info=True) return # Log the automatic mute action @@ -295,13 +299,13 @@ class SpamProtection(commands.Cog): f"You have been automatically muted in **{guild.name}** for spam detection. " f"A staff member will review your case shortly." ) - except: + except Exception: pass # Can't DM user # Create staff alert staff_channel = guild.get_channel(STAFF_CHANNEL_ID) if not staff_channel: - print(f"[ERROR] Staff channel {STAFF_CHANNEL_ID} not found") + logger.error(f"Staff channel {STAFF_CHANNEL_ID} not found") return # Build embed @@ -410,7 +414,7 @@ class SpamProtection(commands.Cog): view.alert_message_id = msg.id except Exception as e: - print(f"[ERROR] Failed to send spam alert: {e}") + logger.error(f"Failed to send spam alert: {e}", exc_info=True) class SpamActionView(View): """Interactive buttons for staff to handle spam reports""" @@ -592,7 +596,7 @@ class SpamActionView(View): f"You have been **banned** from **{self.guild.name}** for spam.\n" f"Reason: {reason}" ) - except: + except Exception: pass # Ban user diff --git a/moderation/unmmute.py b/moderation/unmmute.py index 5b02695..b78a194 100644 --- a/moderation/unmmute.py +++ b/moderation/unmmute.py @@ -1,10 +1,13 @@ +"""Unmute command with confirmation prompt.""" import discord from discord.ext import commands from discord.ui import View, Button import sqlite3 -import os +from pathlib import Path from .loader import ModerationBase +DB_PATH = Path(__file__).parent.parent / "data" / "moderation.db" + MUTE_ROLE_ID = 982702037517090836 class UnmuteCommand(ModerationBase): @@ -49,15 +52,14 @@ class UnmuteCommand(ModerationBase): if mute_role in user.roles: await user.remove_roles(mute_role, reason="Manual unmute issued") - db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "moderation.db") - conn = sqlite3.connect(db_path) + conn = sqlite3.connect(DB_PATH) c = conn.cursor() c.execute("DELETE FROM mutes WHERE user_id = ? AND guild_id = ?", (user.id, ctx.guild.id)) conn.commit() conn.close() try: await user.send(f"You have been **unmuted** in **{ctx.guild.name}**.") - except: + except Exception: await ctx.send("Could not DM the user.") await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "unmute", "Manual unmute issued") diff --git a/moderation/warn.py b/moderation/warn.py index 3116f98..c742414 100644 --- a/moderation/warn.py +++ b/moderation/warn.py @@ -1,3 +1,4 @@ +"""Warn command that logs infractions and notifies the user.""" import discord from discord.ext import commands from discord.ui import View, Button @@ -41,7 +42,7 @@ class WarnCommand(ModerationBase): try: await user.send(f"You have been **warned** in **{ctx.guild.name}**.\nReason: {reason or 'No reason provided'}") - except: + except Exception: await ctx.send("Could not DM the user.") await self.log_infraction(ctx.guild.id, user.id, ctx.author.id, "warn", reason) diff --git a/profiles/__init__.py b/profiles/__init__.py new file mode 100644 index 0000000..4317a17 --- /dev/null +++ b/profiles/__init__.py @@ -0,0 +1 @@ +"""User profile card generation and customization.""" diff --git a/profiles/database.py b/profiles/database.py index ed98399..dae74ef 100644 --- a/profiles/database.py +++ b/profiles/database.py @@ -1,7 +1,8 @@ +"""Profile database helpers for connecting to the profile SQLite database.""" import sqlite3 -import os +from pathlib import Path -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "profile.db") +DB_PATH = Path(__file__).parent.parent / "data" / "profile.db" def get_db(): db = sqlite3.connect(DB_PATH) diff --git a/profiles/profiles.py b/profiles/profiles.py index 14a85db..ac3a3ce 100644 --- a/profiles/profiles.py +++ b/profiles/profiles.py @@ -1,3 +1,4 @@ +"""User profile card generation with custom fonts and avatar overlays.""" import discord from discord.ext import commands from discord import app_commands @@ -6,10 +7,11 @@ import aiohttp import io import os import asyncio +from pathlib import Path from textwrap import wrap from .database import get_db, setup_db -FONTS_PATH = os.path.join(os.path.dirname(__file__), "fonts") +FONTS_PATH = Path(__file__).parent / "fonts" def list_fonts(): fonts = [] @@ -243,7 +245,7 @@ class Profiles(commands.GroupCog, name="profile"): # Validate it's a proper hex color int(bg_color[1:], 16) base_color = bg_color - except: + except Exception: pass # Use default if invalid img = Image.new("RGB", (img_width, img_height), base_color) @@ -307,7 +309,7 @@ class Profiles(commands.GroupCog, name="profile"): else: username_font = ImageFont.load_default() break - except: + except Exception: username_font = ImageFont.load_default() break @@ -376,7 +378,7 @@ class Profiles(commands.GroupCog, name="profile"): role_font = ImageFont.load_default() else: role_font = ImageFont.load_default() - except: + except Exception: role_font = ImageFont.load_default() for role in display_roles: diff --git a/reminders/__init__.py b/reminders/__init__.py new file mode 100644 index 0000000..4b6268e --- /dev/null +++ b/reminders/__init__.py @@ -0,0 +1 @@ +"""Reminder scheduling and notification system.""" diff --git a/reminders/reminder.py b/reminders/reminder.py index b058083..8a9ead6 100644 --- a/reminders/reminder.py +++ b/reminders/reminder.py @@ -1,3 +1,4 @@ +"""Reminder cog that lets users set timed reminders via slash commands.""" import discord from discord import app_commands from discord.ext import commands, tasks @@ -5,6 +6,10 @@ import aiosqlite from datetime import datetime, timedelta, timezone import re from pathlib import Path +from embed.embed_color import get_embed_color +from utils.logger import get_logger + +logger = get_logger(__name__) def parse_timeframe(timeframe: str) -> timedelta: @@ -85,7 +90,7 @@ class ReminderCog(commands.Cog): ephemeral=True ) except Exception as e: - print(f"Error in reminder_set: {e}") + logger.error(f"Error in reminder_set: {e}", exc_info=True) if not interaction.response.is_done(): await interaction.response.send_message(f"❌ Error: {e}", ephemeral=True) @@ -105,7 +110,7 @@ class ReminderCog(commands.Cog): embed = discord.Embed( title="📝 Your Reminders", - color=discord.Color.blurple(), + color=get_embed_color(interaction.user.id), timestamp=datetime.now(timezone.utc) ) @@ -218,7 +223,7 @@ class ReminderCog(commands.Cog): embed = discord.Embed( title="⏰ Reminder!", description=message, - color=discord.Color.blue(), + color=get_embed_color(user_id), timestamp=datetime.now(timezone.utc) ) await user.send(embed=embed) diff --git a/sparkle/__init__.py b/sparkle/__init__.py new file mode 100644 index 0000000..f496bf8 --- /dev/null +++ b/sparkle/__init__.py @@ -0,0 +1 @@ +"""Sparkle currency system for rewarding community engagement.""" diff --git a/sparkle/database.py b/sparkle/database.py index 2e9465d..a69abfc 100644 --- a/sparkle/database.py +++ b/sparkle/database.py @@ -1,13 +1,12 @@ +"""Sparkle currency database helpers for the sparkle economy.""" import sqlite3 -import os +from pathlib import Path -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "sparkle.db") +DB_PATH = Path(__file__).parent.parent / "data" / "sparkle.db" def get_db(): - """Return a SQLite3 connection with initialized tables.""" conn = sqlite3.connect(DB_PATH) - - # Create sparkles table + conn.execute(""" CREATE TABLE IF NOT EXISTS sparkles ( server_id TEXT, @@ -18,8 +17,7 @@ def get_db(): PRIMARY KEY (server_id, user_id) ) """) - - # Create sparkle_events table for tracking individual sparkle occurrences + conn.execute(""" CREATE TABLE IF NOT EXISTS sparkle_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -30,8 +28,7 @@ def get_db(): timestamp INTEGER NOT NULL ) """) - - # Create index for faster queries + conn.execute(""" CREATE INDEX IF NOT EXISTS idx_sparkle_events_server ON sparkle_events(server_id, timestamp) diff --git a/sparkle/sparkle_commands.py b/sparkle/sparkle_commands.py index 193d252..6424112 100644 --- a/sparkle/sparkle_commands.py +++ b/sparkle/sparkle_commands.py @@ -1,3 +1,4 @@ +"""Sparkle currency commands for giving, checking, and viewing sparkle stats.""" import discord from discord.ext import commands from discord import app_commands @@ -5,6 +6,9 @@ from discord.utils import escape_markdown from .database import get_db import asyncio import datetime +import sqlite3 +from pathlib import Path +from embed.embed_color import get_embed_color class SparkleCommands(commands.Cog): @@ -54,7 +58,7 @@ class SparkleCommands(commands.Cog): epic, rare, regular, total = result embed = discord.Embed( title=f"{user.display_name}'s Sparkles", - color=discord.Color.gold() + color=get_embed_color(interaction.user.id) ) embed.set_thumbnail(url=user.display_avatar.url) embed.add_field( @@ -83,7 +87,7 @@ class SparkleCommands(commands.Cog): "💫 **Epic Sparkle** – 1/100,000 chance\n\n" "Track your sparkles using `/sparkle check` or `/sparkle leaderboard`." ), - color=discord.Color.purple() + color=get_embed_color(interaction.user.id) ) embed.set_footer(text="Keep chatting to test your luck!") await interaction.response.send_message(embed=embed) @@ -125,7 +129,7 @@ class SparkleCommands(commands.Cog): embed = discord.Embed( title=f"✨ {escape_markdown(interaction.guild.name)} Sparkle Leaderboard", - color=discord.Color.gold() + color=get_embed_color(interaction.user.id) ) medal = {1: "🥇", 2: "🥈", 3: "🥉"} @@ -180,9 +184,7 @@ class SparkleCommands(commands.Cog): conn.close() # Get total message count from stats.db (located in ../stats/) - import os - import sqlite3 - stats_db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "stats.db") + stats_db_path = Path(__file__).parent.parent / "data" / "stats.db" total_messages = 0 try: stats_conn = sqlite3.connect(stats_db_path) @@ -193,7 +195,7 @@ class SparkleCommands(commands.Cog): if result and result[0]: total_messages = result[0] stats_conn.close() - except: + except Exception: pass # If stats.db doesn't exist or has issues, just use 0 return total_epic, total_rare, total_regular, events, total_messages @@ -217,7 +219,7 @@ class SparkleCommands(commands.Cog): embed = discord.Embed( title=f"📊 Sparkle Stats for {interaction.guild.name}", - color=discord.Color.purple() + color=get_embed_color(interaction.user.id) ) embed.add_field( diff --git a/stats/__init__.py b/stats/__init__.py new file mode 100644 index 0000000..3ce2f63 --- /dev/null +++ b/stats/__init__.py @@ -0,0 +1 @@ +"""Server statistics tracking and reporting.""" diff --git a/stats/stats.py b/stats/stats.py index ffd4830..a50f618 100644 --- a/stats/stats.py +++ b/stats/stats.py @@ -1,18 +1,24 @@ +"""Statistics tracking cog: message counts, word frequencies, bot uptime.""" import discord from discord.ext import commands from discord import app_commands from datetime import datetime, timezone import sqlite3 +from embed.embed_color import get_embed_color import os import json +from pathlib import Path import asyncio import aiofiles import re from collections import Counter from typing import Dict, List, Tuple +from utils.logger import get_logger -BASE_DIR = os.path.dirname(__file__) -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "stats.db") +logger = get_logger(__name__) + +BASE_DIR = Path(__file__).parent +DB_PATH = Path(__file__).parent.parent / "data" / "stats.db" class Stats(commands.Cog): def __init__(self, bot): @@ -41,8 +47,10 @@ class Stats(commands.Cog): } self.init_db() - self.stats_file = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "bot_stats.json") - self.bot.loop.create_task(self.update_stats_file()) + self.stats_file = Path(__file__).parent.parent / "data" / "bot_stats.json" + + async def cog_load(self): + asyncio.create_task(self.update_stats_file()) def init_db(self): db = sqlite3.connect(DB_PATH) @@ -243,9 +251,9 @@ class Stats(commands.Cog): file_path = os.path.join(stats_dir, filename) if file_path != self.stats_file: os.remove(file_path) - print(f"Removed old stats file: {filename}") + logger.info(f"Removed old stats file: {filename}") except Exception as e: - print(f"Error cleaning up old stats files: {e}") + logger.error(f"Error cleaning up old stats files: {e}", exc_info=True) async def update_stats_file(self): await self.bot.wait_until_ready() @@ -257,7 +265,7 @@ class Stats(commands.Cog): async with aiofiles.open(self.stats_file, 'w') as f: await f.write(json.dumps(stats, indent=2, default=str)) except Exception as e: - print(f"Error updating stats file: {e}") + logger.error(f"Error updating stats file: {e}", exc_info=True) await asyncio.sleep(30) # Update every 30 seconds @@ -346,7 +354,7 @@ class Stats(commands.Cog): embed = discord.Embed( title=f"🌟 {guild.name} Statistics 🌟", - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user), + color=get_embed_color(interaction.user.id), timestamp=datetime.now(timezone.utc) ) @@ -389,7 +397,7 @@ class Stats(commands.Cog): embed = discord.Embed( title="📊 Message Statistics", - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user), + color=get_embed_color(interaction.user.id), timestamp=datetime.now(timezone.utc) ) @@ -413,7 +421,7 @@ class Stats(commands.Cog): embed = discord.Embed( title="🔤 Word Frequency Statistics", - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user), + color=get_embed_color(interaction.user.id), timestamp=datetime.now(timezone.utc) ) @@ -437,7 +445,7 @@ class Stats(commands.Cog): embed = discord.Embed( title="📊 Most Active Channels", - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user), + color=get_embed_color(interaction.user.id), timestamp=datetime.now(timezone.utc) ) diff --git a/suggestion/__init__.py b/suggestion/__init__.py new file mode 100644 index 0000000..957350f --- /dev/null +++ b/suggestion/__init__.py @@ -0,0 +1 @@ +"""Suggestion submission and review system.""" diff --git a/suggestion/suggest.py b/suggestion/suggest.py index bf3dca2..f5d327c 100644 --- a/suggestion/suggest.py +++ b/suggestion/suggest.py @@ -1,11 +1,16 @@ +"""Suggestion system cog with voting, approval, and completion workflow.""" import discord from discord import app_commands from discord.ext import commands import aiosqlite from datetime import datetime -import os +from embed.embed_color import get_embed_color +from pathlib import Path from typing import Optional import traceback +from utils.logger import get_logger + +logger = get_logger(__name__) ADMIN_ID = 252130669919076352 ADMIN_CHANNEL_ID = 1470441786810826884 @@ -32,7 +37,7 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"): reason_text = self.reason.value or None - db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "suggestions.db") + db_path = Path(__file__).parent.parent / "data" / "suggestions.db" async with aiosqlite.connect(db_path) as db: await db.execute("UPDATE suggestions SET status = ?, reason = ? WHERE id = ?", ("Denied", reason_text, self.suggestion_id)) await db.commit() @@ -72,7 +77,7 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"): ) await orig_msg.edit(embed=updated_embed, view=disabled_view) except Exception as e: - print(f"Failed to edit admin message: {e}") + logger.error(f"Failed to edit admin message: {e}") # Send DM to user try: @@ -82,7 +87,7 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"): dm_note += f"\n**Reason:** {reason_text}" await user.send(dm_note) except Exception as e: - print(f"Failed to DM user: {e}") + logger.error(f"Failed to DM user: {e}") # Send message in original channel channel = self.bot.get_channel(self.channel_id) @@ -93,14 +98,14 @@ class DenyModal(discord.ui.Modal, title="Reason for denying suggestion"): msg += f"\n**Reason:** {reason_text}" await channel.send(msg) except Exception as e: - print(f"Failed to send message in channel: {e}") + logger.error(f"Failed to send message in channel: {e}") except Exception as e: error_msg = f"❌ Error denying suggestion: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error denying suggestion: {e}", exc_info=True) try: await interaction.followup.send(error_msg[:2000], ephemeral=True) - except: + except Exception: pass @@ -152,7 +157,7 @@ class SuggestionButtons(discord.ui.View): # Defer immediately to prevent timeout await interaction.response.defer(ephemeral=True) - db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "suggestions.db") + db_path = Path(__file__).parent.parent / "data" / "suggestions.db" async with aiosqlite.connect(db_path) as db: await db.execute("UPDATE suggestions SET status = ? WHERE id = ?", ("Approved", self.suggestion_id)) await db.commit() @@ -189,14 +194,14 @@ class SuggestionButtons(discord.ui.View): ) await orig_msg.edit(embed=updated_embed, view=complete_view) except Exception as e: - print(f"Failed to edit admin message: {e}") + logger.error(f"Failed to edit admin message: {e}") # Send DM to user try: user = await self.bot.fetch_user(self.user_id) await user.send(f"✅ Your suggestion (ID: {self.suggestion_id}) — `{self.suggestion_text}` has been **approved!**") except Exception as e: - print(f"Failed to DM user: {e}") + logger.error(f"Failed to DM user: {e}") # Send message in original channel channel = self.bot.get_channel(self.channel_id) @@ -204,17 +209,17 @@ class SuggestionButtons(discord.ui.View): try: await channel.send(f"✅ Suggestion **#{self.suggestion_id}** (`{self.suggestion_text}`) has been **approved!**") except Exception as e: - print(f"Failed to send message in channel: {e}") + logger.error(f"Failed to send message in channel: {e}") except Exception as e: error_msg = f"❌ Error approving suggestion: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error approving suggestion: {e}", exc_info=True) try: if not interaction.response.is_done(): await interaction.response.send_message(error_msg[:2000], ephemeral=True) else: await interaction.followup.send(error_msg[:2000], ephemeral=True) - except: + except Exception: pass async def deny(self, interaction: discord.Interaction): @@ -239,7 +244,7 @@ class SuggestionButtons(discord.ui.View): try: orig_msg = await admin_channel.fetch_message(self.admin_message_id) original_embed = orig_msg.embeds[0] if orig_msg.embeds else None - except: + except Exception: original_embed = None else: original_embed = None @@ -256,13 +261,13 @@ class SuggestionButtons(discord.ui.View): await interaction.response.send_modal(modal) except Exception as e: error_msg = f"❌ Error opening deny modal: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error opening deny modal: {e}", exc_info=True) try: if not interaction.response.is_done(): await interaction.response.send_message(error_msg[:2000], ephemeral=True) else: await interaction.followup.send(error_msg[:2000], ephemeral=True) - except: + except Exception: pass async def complete(self, interaction: discord.Interaction): @@ -284,7 +289,7 @@ class SuggestionButtons(discord.ui.View): # Defer immediately to prevent timeout await interaction.response.defer(ephemeral=True) - db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "suggestions.db") + db_path = Path(__file__).parent.parent / "data" / "suggestions.db" async with aiosqlite.connect(db_path) as db: # Check if it's approved async with db.execute("SELECT status FROM suggestions WHERE id = ?", (self.suggestion_id,)) as cursor: @@ -333,14 +338,14 @@ class SuggestionButtons(discord.ui.View): ) await orig_msg.edit(embed=updated_embed, view=disabled_view) except Exception as e: - print(f"Failed to edit admin message: {e}") + logger.error(f"Failed to edit admin message: {e}") # Send DM to user try: user = await self.bot.fetch_user(self.user_id) await user.send(f"🎉 Your suggestion (ID: {self.suggestion_id}) — `{self.suggestion_text}` has been **implemented!**") except Exception as e: - print(f"Failed to DM user: {e}") + logger.error(f"Failed to DM user: {e}") # Send message in original channel channel = self.bot.get_channel(self.channel_id) @@ -348,17 +353,17 @@ class SuggestionButtons(discord.ui.View): try: await channel.send(f"🎉 Suggestion **#{self.suggestion_id}** (`{self.suggestion_text}`) has been marked as **completed!**") except Exception as e: - print(f"Failed to send message in channel: {e}") + logger.error(f"Failed to send message in channel: {e}") except Exception as e: error_msg = f"❌ Error completing suggestion: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error completing suggestion: {e}", exc_info=True) try: if not interaction.response.is_done(): await interaction.response.send_message(error_msg[:2000], ephemeral=True) else: await interaction.followup.send(error_msg[:2000], ephemeral=True) - except: + except Exception: pass @@ -404,7 +409,7 @@ class Suggestion(commands.GroupCog, name="suggest"): def __init__(self, bot): self.bot = bot - self.db_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "suggestions.db") + self.db_path = Path(__file__).parent.parent / "data" / "suggestions.db" self.db = None async def cog_load(self): @@ -438,7 +443,7 @@ class Suggestion(commands.GroupCog, name="suggest"): show_complete=(status == "Approved") ) self.bot.add_view(view, message_id=admin_msg_id) - print(f"Re-registered view for suggestion #{sid} (message {admin_msg_id}, status: {status})") + logger.info(f"Re-registered view for suggestion #{sid} (message {admin_msg_id}, status: {status})") async def cog_unload(self): if self.db: @@ -466,7 +471,7 @@ class Suggestion(commands.GroupCog, name="suggest"): embed = discord.Embed( title=f"New Suggestion (ID: {suggestion_id})", description=idea, - color=discord.Color.blurple(), + color=get_embed_color(interaction.user.id), timestamp=datetime.utcnow() ) embed.add_field(name="Suggested by", value=f"{interaction.user} ({interaction.user.id})") @@ -488,15 +493,14 @@ class Suggestion(commands.GroupCog, name="suggest"): admin_message_id=sent.id ) self.bot.add_view(persistent_view, message_id=sent.id) - print(f"Registered persistent view for suggestion #{suggestion_id} (message {sent.id})") + logger.info(f"Registered persistent view for suggestion #{suggestion_id} (message {sent.id})") except Exception as e: - print(f"Failed to send message to admin channel: {e}") - traceback.print_exc() + logger.error(f"Failed to send message to admin channel: {e}", exc_info=True) except Exception as e: error_msg = f"❌ An error occurred: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error in suggest command: {e}", exc_info=True) await interaction.followup.send(error_msg[:2000]) @app_commands.command(name="view", description="View full details of a suggestion") @@ -529,7 +533,7 @@ class Suggestion(commands.GroupCog, name="suggest"): try: user = await self.bot.fetch_user(user_id) embed.add_field(name="Suggested by", value=f"{user.mention} ({user})", inline=True) - except: + except Exception: embed.add_field(name="Suggested by", value=f"<@{user_id}>", inline=True) embed.add_field(name="Status", value=status, inline=True) @@ -542,7 +546,7 @@ class Suggestion(commands.GroupCog, name="suggest"): except Exception as e: error_msg = f"❌ An error occurred: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error in viewsuggestion command: {e}", exc_info=True) await interaction.followup.send(error_msg[:2000]) @app_commands.command(name="complete", description="Mark an approved suggestion as completed (Admin only)") @@ -612,12 +616,12 @@ class Suggestion(commands.GroupCog, name="suggest"): ) await orig_msg.edit(embed=updated_embed, view=disabled_view) except Exception as e: - print(f"Failed to edit admin message: {e}") + logger.error(f"Failed to edit admin message: {e}") try: user = await self.bot.fetch_user(user_id) await user.send(f"🎉 Your suggestion (ID: {suggestion_id}) — `{suggestion_text}` has been **implemented!**") - except: + except Exception: pass channel = self.bot.get_channel(channel_id) @@ -626,7 +630,7 @@ class Suggestion(commands.GroupCog, name="suggest"): except Exception as e: error_msg = f"❌ An error occurred: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error in completesuggestion command: {e}", exc_info=True) await interaction.followup.send(error_msg[:2000]) @app_commands.command(name="list", description="List suggestions with optional status filter") @@ -674,7 +678,7 @@ class Suggestion(commands.GroupCog, name="suggest"): except Exception as e: error_msg = f"❌ An error occurred: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error in listsuggestions command: {e}", exc_info=True) await interaction.followup.send(error_msg[:2000]) @app_commands.command(name="todo", description="View your approved suggestions to-do list") @@ -728,7 +732,7 @@ class Suggestion(commands.GroupCog, name="suggest"): user_approved.append((sid, uid, suggestion_text)) break except Exception as e: - print(f"Failed to fetch message {admin_msg_id}: {e}") + logger.error(f"Failed to fetch message {admin_msg_id}: {e}") continue if not user_approved: @@ -764,7 +768,7 @@ class Suggestion(commands.GroupCog, name="suggest"): except Exception as e: error_msg = f"❌ An error occurred: {str(e)}\n```{traceback.format_exc()}```" - print(error_msg) + logger.error(f"Error in todolist command: {e}", exc_info=True) await interaction.followup.send(error_msg[:2000]) diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..b75821c --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1 @@ +"""Shared utilities for the Lacie Discord bot.""" diff --git a/utils/constants.py b/utils/constants.py new file mode 100644 index 0000000..b3c6e15 --- /dev/null +++ b/utils/constants.py @@ -0,0 +1,33 @@ +""" +Centralized constants for the Lacie Discord bot. + +All hardcoded IDs (user IDs, guild IDs, channel IDs, role IDs) +are collected here so they can be managed in one place. +""" + +# --- Owner / Admin --- +LILAC_ID = 252130669919076352 + +# --- Guild --- +GUILD_ID = 876772600704020530 + +# --- Channels --- +WELCOME_CHANNEL_ID = 876772600704020533 +FALLBACK_CHANNEL_ID = 876772600704020533 +LOG_CHANNEL_ID = 1440055015711703242 +ADMIN_CHANNEL_ID = 1470441786810826884 +APPROVAL_CHANNEL_ID = 1424145004976275617 +BACKUP_CHANNEL_ID = 946421558778417172 +NOTIFICATION_CHANNEL_ID = 1424145004976275617 +COMMIT_CHANNEL_IDS = [876777562599194644, 1437941632849940563, 1470441786810826884] + +# --- Roles --- +BIRTHDAY_ROLE_ID = 1113751318918602762 +BOT_TRAP_ROLE_ID = 1439354601672282335 +ADMIN_ROLE_ID = 1470439484549234866 + +# --- Emojis --- +SALT_EMOJI_ID = 1074583707459010560 + +# --- Thresholds --- +NEW_MEMBER_THRESHOLD_DAYS = 7 diff --git a/utils/logger.py b/utils/logger.py new file mode 100644 index 0000000..33790ca --- /dev/null +++ b/utils/logger.py @@ -0,0 +1,44 @@ +""" +Centralized logging configuration for the Lacie Discord bot. + +Provides a configured logger factory so all modules use consistent +logging instead of bare print() statements. +""" + +import logging +import sys + + +def get_logger(name: str) -> logging.Logger: + """Get a configured logger for the given module name. + + Args: + name: The module name, typically __name__ from the calling module. + + Returns: + A configured logging.Logger instance. + """ + logger = logging.getLogger(name) + return logger + + +def setup_logging(level: int = logging.INFO) -> None: + """Configure the root logger with a console handler. + + Call this once at bot startup (in bot.py) before any other logging. + + Args: + level: The logging level to use. Defaults to INFO. + """ + root = logging.getLogger() + root.setLevel(level) + + if not root.handlers: + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(level) + formatter = logging.Formatter( + "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s", + datefmt="%Y-%m-%d %H:%M:%S" + ) + handler.setFormatter(formatter) + root.addHandler(handler) diff --git a/wordle/__init__.py b/wordle/__init__.py new file mode 100644 index 0000000..e6b9fbf --- /dev/null +++ b/wordle/__init__.py @@ -0,0 +1 @@ +"""Wordle game implementation for Discord.""" diff --git a/wordle/wordle.py b/wordle/wordle.py index b467ad1..5988e38 100644 --- a/wordle/wordle.py +++ b/wordle/wordle.py @@ -1,16 +1,22 @@ +"""Wordle game cog implementing the daily word-guessing game for Discord.""" import discord from discord import app_commands from discord.ext import commands import aiosqlite import aiohttp +from embed.embed_color import get_embed_color import os import datetime +from pathlib import Path +from utils.logger import get_logger + +logger = get_logger(__name__) # Configuration constants WORD_LIST_URL = "https://raw.githubusercontent.com/tabatkins/wordle-list/main/words" WORDLE_DIR = "wordle" WORD_LIST_PATH = os.path.join(WORDLE_DIR, "words.txt") -DB_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "wordle.db") +DB_PATH = Path(__file__).parent.parent / "data" / "wordle.db" # Emoji squares for displaying guess results SQUARES = {"green": "🟩", "yellow": "🟨", "gray": "⬛"} @@ -22,9 +28,10 @@ class Wordle(commands.Cog): self.bot = bot # Create wordle directory if it doesn't exist os.makedirs(WORDLE_DIR, exist_ok=True) - # Initialize database and word list asynchronously - self.bot.loop.create_task(self._init_db()) - self.bot.loop.create_task(self._ensure_wordlist()) + + async def cog_load(self): + await self._init_db() + await self._ensure_wordlist() async def _init_db(self): async with aiosqlite.connect(DB_PATH) as db: @@ -60,7 +67,7 @@ class Wordle(commands.Cog): with open(WORD_LIST_PATH, "r", encoding="utf-8") as f: self.words = [w.strip() for w in f.read().splitlines() if len(w.strip()) == 5] - print(f"[Wordle] Loaded {len(self.words)} words.") + logger.info(f"Loaded {len(self.words)} words.") def get_daily_word(self): """ @@ -234,7 +241,7 @@ class Wordle(commands.Cog): embed = discord.Embed( title=f"{interaction.user.display_name}'s Wordle {datetime.date.today()} Result", description="\n".join(["".join(SQUARES[c] for c in self.compare_guess(g, target)) for g in guesses]), - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user) + color=get_embed_color(interaction.user.id) ) await interaction.channel.send(embed=embed) return @@ -249,7 +256,7 @@ class Wordle(commands.Cog): embed = discord.Embed( title=f"{interaction.user.display_name}'s Wordle {datetime.date.today()} Result", description="\n".join(["".join(SQUARES[c] for c in self.compare_guess(g, target)) for g in guesses]), - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user) + color=get_embed_color(interaction.user.id) ) await interaction.channel.send(embed=embed) return @@ -342,7 +349,7 @@ class Wordle(commands.Cog): # Build and send stats embed embed = discord.Embed( title=f"{interaction.user.display_name}'s Wordle Stats", - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user) + color=get_embed_color(interaction.user.id) ) embed.add_field(name="Games Played", value=str(played)) embed.add_field(name="Wins", value=str(wins)) @@ -374,7 +381,7 @@ class Wordle(commands.Cog): # Build and send server stats embed embed = discord.Embed( title="Server Wordle Stats", - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user) + color=get_embed_color(interaction.user.id) ) embed.add_field(name="Total Games Played", value=str(total_played)) embed.add_field(name="Total Wins", value=str(total_wins)) diff --git a/xp/__init__.py b/xp/__init__.py new file mode 100644 index 0000000..5e44899 --- /dev/null +++ b/xp/__init__.py @@ -0,0 +1 @@ +"""XP and leveling system with role rewards and leaderboards.""" diff --git a/xp/backup_xp.py b/xp/backup_xp.py index 2b0eb17..347a02c 100644 --- a/xp/backup_xp.py +++ b/xp/backup_xp.py @@ -1,12 +1,17 @@ +"""XP database backup cog with scheduled daily backups to Discord.""" import discord from discord import app_commands from discord.ext import commands, tasks import os import shutil +from pathlib import Path from datetime import datetime, timedelta import pytz from moderation.loader import ModerationBase from .groups import xp_admin_group +from utils.logger import get_logger + +logger = get_logger(__name__) BACKUP_CHANNEL_ID = 946421558778417172 NOTIFICATION_CHANNEL_ID = 1424145004976275617 @@ -18,11 +23,10 @@ BACKUP_HOUR = 10 # 10 AM EST class BackupXP(commands.Cog): def __init__(self, bot): self.bot = bot - self.base_dir = os.path.dirname(os.path.abspath(__file__)) - self.db_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data") - self.backup_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "backups", "xp") - self.last_backup_file = os.path.join(self.backup_dir, "last_backup.txt") - self.last_auto_backup_file = os.path.join(self.backup_dir, "last_auto_backup.txt") + self.db_dir = Path(__file__).parent.parent / "data" + self.backup_dir = Path(__file__).parent.parent / "data" / "backups" / "xp" + self.last_backup_file = self.backup_dir / "last_backup.txt" + self.last_auto_backup_file = self.backup_dir / "last_auto_backup.txt" os.makedirs(self.backup_dir, exist_ok=True) # Register the manual backup command onto the shared xpadmin group xp_admin_group.add_command(app_commands.command(name="backup", description="Backup both lifetime and annual XP databases")(self.backup_xp)) @@ -54,33 +58,33 @@ class BackupXP(commands.Cog): # Get current time in EST now_est = datetime.now(EST) - print(f"[Backup] Time check: {now_est.strftime('%Y-%m-%d %I:%M %p %Z')} (Hour: {now_est.hour}, Minute: {now_est.minute})") - + logger.debug(f"Time check: {now_est.strftime('%Y-%m-%d %I:%M %p %Z')} (Hour: {now_est.hour}, Minute: {now_est.minute})") + # Check if it's between 10:00 AM and 10:15 AM EST if now_est.hour == BACKUP_HOUR and now_est.minute < 15: - print(f"[Backup] Inside backup window - Checking for backup") + logger.debug("Inside backup window - Checking for backup") await self.check_last_backup() else: - print(f"[Backup] Outside backup window (need hour={BACKUP_HOUR} and minute<15)") + logger.debug(f"Outside backup window (need hour={BACKUP_HOUR} and minute<15)") @auto_backup_task.before_loop async def before_auto_backup(self): """Wait until the bot is ready before starting the loop.""" await self.bot.wait_until_ready() - print(f"[Backup] Auto backup task started. Will check every 15 minutes for 10 AM EST backup window.") + logger.info("Auto backup task started. Will check every 15 minutes for 10 AM EST backup window.") now_est = datetime.now(EST) - print(f"[Backup] Current time: {now_est.strftime('%Y-%m-%d %I:%M %p %Z')}") + logger.info(f"Current time: {now_est.strftime('%Y-%m-%d %I:%M %p %Z')}") async def check_last_backup(self): """Check if an auto backup has been done today at 10 AM EST""" now = datetime.now() now_est = datetime.now(EST) - print(f"[Backup] Checking auto backup status at {now_est.strftime('%Y-%m-%d %I:%M %p %Z')}") - + logger.info(f"Checking auto backup status at {now_est.strftime('%Y-%m-%d %I:%M %p %Z')}") + if not os.path.exists(self.last_auto_backup_file): # No previous auto backup — make initial backup - print("[Backup] No last_auto_backup.txt found, creating initial auto backup") + logger.info("No last_auto_backup.txt found, creating initial auto backup") await self.create_backup(log_channel=True, reason="Auto daily backup (10 AM EST)", is_auto=True) await self.cleanup_old_backups() return @@ -93,32 +97,28 @@ class BackupXP(commands.Cog): # Check if we've already done an auto backup today last_time_est = last_time.astimezone(EST) - print(f"[Backup] Last auto backup: {last_time_est.strftime('%Y-%m-%d %I:%M %p %Z')}") - print(f"[Backup] Last auto backup date: {last_time_est.date()}, Today: {now_est.date()}") - + logger.info(f"Last auto backup: {last_time_est.strftime('%Y-%m-%d %I:%M %p %Z')}") + logger.debug(f"Last auto backup date: {last_time_est.date()}, Today: {now_est.date()}") + if last_time_est.date() != now_est.date(): # Haven't done auto backup today yet, so do it now - print("[Backup] Starting daily auto backup...") + logger.info("Starting daily auto backup...") await self.create_backup(log_channel=True, reason="Auto daily backup (10 AM EST)", is_auto=True) await self.cleanup_old_backups() else: - print("[Backup] Already auto-backed up today, skipping") + logger.info("Already auto-backed up today, skipping") async def create_backup(self, log_channel=False, reason=None, is_auto=False): """Handles the actual backup logic""" lifetime_db = os.path.join(self.db_dir, "lifetime.db") annual_db = os.path.join(self.db_dir, "annual.db") - print(f"[Backup] Looking for databases:") - print(f"[Backup] Lifetime: {lifetime_db}") - print(f"[Backup] Annual: {annual_db}") - print(f"[Backup] Lifetime exists: {os.path.exists(lifetime_db)}") - print(f"[Backup] Annual exists: {os.path.exists(annual_db)}") - + logger.debug(f"Looking for databases: lifetime={lifetime_db} (exists={os.path.exists(lifetime_db)}), annual={annual_db} (exists={os.path.exists(annual_db)})") + missing = [db for db in [lifetime_db, annual_db] if not os.path.exists(db)] if missing: error_msg = f"❌ Missing database files: {', '.join(os.path.basename(m) for m in missing)}" - print(f"[Backup] {error_msg}") + logger.error(error_msg) return False, error_msg timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") @@ -130,9 +130,7 @@ class BackupXP(commands.Cog): shutil.copy2(lifetime_db, lifetime_backup) shutil.copy2(annual_db, annual_backup) - print(f"[Backup] Files copied successfully") - print(f"[Backup] {os.path.basename(lifetime_backup)}") - print(f"[Backup] {os.path.basename(annual_backup)}") + logger.info(f"Files copied successfully: {os.path.basename(lifetime_backup)}, {os.path.basename(annual_backup)}") # Record last backup time with open(self.last_backup_file, "w") as f: @@ -148,12 +146,12 @@ class BackupXP(commands.Cog): annual_size = os.path.getsize(annual_db) / (1024 * 1024) total_size = lifetime_size + annual_size - print(f"[Backup] Backup completed successfully") + logger.info("Backup completed successfully") return True, f"✅ Backup complete! (`{lifetime_size:.2f}` MB lifetime, `{annual_size:.2f}` MB annual)" - + except Exception as e: error_msg = f"❌ Backup failed: `{e}`" - print(f"[Backup] {error_msg}") + logger.error(error_msg, exc_info=True) return False, error_msg async def cleanup_old_backups(self): @@ -180,13 +178,13 @@ class BackupXP(commands.Cog): if now - file_time > MAX_BACKUP_AGE: os.remove(filepath) deleted_count += 1 - print(f"[Backup] Deleted old backup: {filename}") - + logger.info(f"Deleted old backup: {filename}") + if deleted_count > 0: - print(f"[Backup] Cleaned up {deleted_count} old backup file(s)") - + logger.info(f"Cleaned up {deleted_count} old backup file(s)") + except Exception as e: - print(f"[Backup] Error during cleanup: {e}") + logger.error(f"Error during cleanup: {e}", exc_info=True) async def setup(bot: commands.Bot): await bot.add_cog(BackupXP(bot)) \ No newline at end of file diff --git a/xp/calculate.py b/xp/calculate.py index 31238e2..d85df36 100644 --- a/xp/calculate.py +++ b/xp/calculate.py @@ -1,3 +1,4 @@ +"""XP calculation helpers: awarding XP, handling cooldowns, leveling up.""" import discord from discord import app_commands from discord.ext import commands, tasks @@ -5,6 +6,7 @@ from .database import get_db from .utils import xp_for_level, can_get_xp, get_multiplier, load_config from .groups import xp_group import time +from embed.embed_color import get_embed_color class CalculateCommand(commands.Cog): def __init__(self, bot): @@ -116,11 +118,7 @@ class CalculateCommand(commands.Cog): {bar} ({progress:.2f}%)""" - # Safe color handling - color_cog = self.bot.get_cog("EmbedColor") - color = color_cog.get_user_color(interaction.user) if color_cog else discord.Color.blurple() - - embed = discord.Embed(description=response, color=color) + embed = discord.Embed(description=response, color=get_embed_color(interaction.user.id)) embed.set_author(name=user.display_name, icon_url=user.display_avatar.url) await interaction.followup.send(embed=embed) diff --git a/xp/database.py b/xp/database.py index 257767c..7e6d844 100644 --- a/xp/database.py +++ b/xp/database.py @@ -1,9 +1,10 @@ +"""XP database connection helpers for multiple leaderboard time periods.""" import sqlite3 -import os +from pathlib import Path -DB_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data") +DB_DIR = Path(__file__).parent.parent / "data" -os.makedirs(DB_DIR, exist_ok=True) +DB_DIR.mkdir(parents=True, exist_ok=True) def get_db(db_type="lifetime"): if isinstance(db_type, bool): diff --git a/xp/exclude_channels.py b/xp/exclude_channels.py index 794a1bf..e3ee544 100644 --- a/xp/exclude_channels.py +++ b/xp/exclude_channels.py @@ -1,17 +1,18 @@ +"""Utilities for managing channels excluded from XP gain.""" import discord from discord.ext import commands import json -import os +from pathlib import Path from dotenv import load_dotenv from moderation.loader import ModerationBase load_dotenv() -EXCLUDED_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "excluded_channels.json") +EXCLUDED_FILE = Path(__file__).parent.parent / "data" / "excluded_channels.json" def load_excluded_channels(): """Load excluded channel IDs from JSON file.""" - if not os.path.exists(EXCLUDED_FILE): + if not EXCLUDED_FILE.exists(): return [] with open(EXCLUDED_FILE, "r") as f: return json.load(f) diff --git a/xp/groups.py b/xp/groups.py index 39c1448..c853364 100644 --- a/xp/groups.py +++ b/xp/groups.py @@ -1,3 +1,4 @@ +"""Shared slash command group definitions for the XP system.""" from discord import app_commands # Public XP commands: /xp rank, /xp top, /xp calculate, /xp sync diff --git a/xp/import_export.py b/xp/import_export.py index f74e445..85586b9 100644 --- a/xp/import_export.py +++ b/xp/import_export.py @@ -1,3 +1,4 @@ +"""XP import/export commands for backing up and restoring user XP data.""" import json import asyncio from io import BytesIO @@ -8,6 +9,9 @@ from xp.utils import xp_for_level from xp.database import get_db from moderation.loader import ModerationBase from .groups import xp_admin_group +from utils.logger import get_logger + +logger = get_logger(__name__) class XPImportExport(commands.Cog): @@ -37,9 +41,9 @@ class XPImportExport(commands.Cog): } return users except Exception as e: - print(f"Error in _db_work: {e}") + logger.error(f"Error in _db_work: {e}", exc_info=True) raise - + # Run everything in thread pool users = await asyncio.to_thread(_db_work) return {"users": users} @@ -56,39 +60,31 @@ class XPImportExport(commands.Cog): # Defer immediately await interaction.response.defer() - print(f"Export started for {xp_type.value}") - + logger.info(f"Export started for {xp_type.value}") + lifetime = xp_type.value == "lifetime" - - # Get data asynchronously - print("Fetching data from database...") + + logger.debug("Fetching data from database...") data = await self._export_data(lifetime) - print(f"Data fetched: {len(data['users'])} users") - - # Create file with pretty-printed JSON - print("Encoding JSON...") + logger.info(f"Data fetched: {len(data['users'])} users") + json_str = json.dumps(data, indent=2) json_bytes = json_str.encode("utf-8") - print(f"JSON size: {len(json_bytes)} bytes") - - print("Creating Discord file...") + logger.debug(f"JSON size: {len(json_bytes)} bytes") + file = discord.File(fp=BytesIO(json_bytes), filename=f"{xp_type.value}_xp_export.json") - - # Public response - print("Sending response...") + await interaction.followup.send( f"✅ Exported `{xp_type.value}` XP data ({len(data['users'])} users).", file=file ) - print("Export complete!") - + logger.info("Export complete!") + except Exception as e: - print(f"Error in export_xp: {e}") - import traceback - traceback.print_exc() + logger.error(f"Error in export_xp: {e}", exc_info=True) try: await interaction.followup.send(f"❌ An error occurred during export: {str(e)}") - except: + except Exception: pass # ==================== IMPORT ==================== @@ -130,9 +126,9 @@ class XPImportExport(commands.Cog): conn.close() return len(insert_data) except Exception as e: - print(f"Error in _db_work: {e}") + logger.error(f"Error in _db_work: {e}", exc_info=True) raise - + # Run database work in thread pool count = await asyncio.to_thread(_db_work) return count @@ -154,50 +150,44 @@ class XPImportExport(commands.Cog): # Defer immediately await interaction.response.defer() - print(f"Import started for {xp_type.value}") - + logger.info(f"Import started for {xp_type.value}") + lifetime = xp_type.value == "lifetime" if not attachment.filename.endswith(".json"): await interaction.followup.send("❌ Please upload a valid `.json` file.") return - # Download and parse JSON file - print("Downloading attachment...") + logger.debug("Downloading attachment...") file_bytes = await attachment.read() - print(f"Downloaded {len(file_bytes)} bytes") - + logger.debug(f"Downloaded {len(file_bytes)} bytes") + try: - print("Parsing JSON...") data = json.loads(file_bytes.decode("utf-8")) except json.JSONDecodeError as e: await interaction.followup.send(f"❌ Invalid JSON file format: {str(e)}") return users_data = data.get("users", {}) - + if not users_data: await interaction.followup.send("❌ No user data found in the JSON file.") return - - print(f"Found {len(users_data)} users to import") - - # Import data asynchronously - print("Importing data...") + + logger.info(f"Found {len(users_data)} users to import") + count = await self._import_data(users_data, lifetime) - print(f"Import complete: {count} users") + logger.info(f"Import complete: {count} users") await interaction.followup.send( f"✅ Imported `{xp_type.value}` XP data from `{attachment.filename}` — {count} users imported (existing data overwritten)." ) - + except Exception as e: - print(f"Error in import_xp: {e}") - import traceback - traceback.print_exc() + logger.error(f"Error in import_xp: {e}", exc_info=True) try: await interaction.followup.send(f"❌ An error occurred during import: {str(e)}") - except: + except Exception: pass def cog_unload(self): diff --git a/xp/leaderboard.py b/xp/leaderboard.py index fc8be73..c21f9d5 100644 --- a/xp/leaderboard.py +++ b/xp/leaderboard.py @@ -1,3 +1,4 @@ +"""XP leaderboard commands showing top users by various time periods.""" import discord from discord.ext import commands from discord import app_commands @@ -5,6 +6,7 @@ from discord.ui import View, Button from .database import get_db from .groups import xp_group import math +from embed.embed_color import get_embed_color class LeaderboardView(View): def __init__(self, embed_pages): @@ -110,7 +112,7 @@ class Leaderboard(commands.Cog): embed = discord.Embed( title=f"{board_display_name} Leaderboard (Page {page_num + 1}/{total_pages})", - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user) + color=get_embed_color(interaction.user.id) ) if page_num == 0 and page_rows: diff --git a/xp/rank.py b/xp/rank.py index ae50bbc..7a920a4 100644 --- a/xp/rank.py +++ b/xp/rank.py @@ -1,12 +1,16 @@ +"""XP rank card generation and rank display commands.""" import discord import time import math -import traceback from discord.ext import commands from discord import app_commands from .database import get_db from .utils import xp_for_level, get_multiplier, MULTIPLIERS, COOLDOWN from .groups import xp_group +from embed.embed_color import get_embed_color +from utils.logger import get_logger + +logger = get_logger(__name__) class Rank(commands.Cog): def __init__(self, bot): @@ -109,7 +113,7 @@ class Rank(commands.Cog): f"➡️ **Next level:** `{next_level_xp:,}` ({needed:,} more)\n" f"🕒 **Cooldown:** {cooldown}" ), - color=self.bot.get_cog("EmbedColor").get_user_color(interaction.user) + color=get_embed_color(interaction.user.id) ) if lifetime and multipliers_text: @@ -128,8 +132,7 @@ class Rank(commands.Cog): await interaction.followup.send(embed=embed) except Exception as e: - print(f"Rank command error: {e}") - traceback.print_exc() + logger.exception(f"Rank command error: {e}") try: if interaction.response.is_done(): await interaction.followup.send( @@ -139,7 +142,7 @@ class Rank(commands.Cog): await interaction.response.send_message( "An error occurred while fetching rank data.", ephemeral=True ) - except: + except Exception: pass def cog_unload(self): diff --git a/xp/reset_task.py b/xp/reset_task.py index ad08a7d..1ea215c 100644 --- a/xp/reset_task.py +++ b/xp/reset_task.py @@ -1,7 +1,11 @@ +"""Scheduled XP reset tasks for daily, weekly, and monthly leaderboards.""" from discord.ext import tasks, commands from datetime import datetime, timezone from .database import reset_leaderboard, get_last_reset import calendar +from utils.logger import get_logger + +logger = get_logger(__name__) class ResetTask(commands.Cog): def __init__(self, bot): @@ -23,7 +27,7 @@ class ResetTask(commands.Cog): last_reset_date = datetime.fromtimestamp(last_reset, timezone.utc).date() if last_reset > 0 else None if last_reset_date != now.date(): reset_leaderboard("daily") - print(f"[XP System] Daily leaderboard reset at {now}") + logger.info(f"Daily leaderboard reset at {now}") # Weekly reset - Monday at midnight UTC if self.should_reset_weekly(now): @@ -32,7 +36,7 @@ class ResetTask(commands.Cog): current_week = now.isocalendar()[1] if last_reset_week != current_week: reset_leaderboard("weekly") - print(f"[XP System] Weekly leaderboard reset at {now}") + logger.info(f"Weekly leaderboard reset at {now}") # Monthly reset - First day of month at midnight UTC if self.should_reset_monthly(now): @@ -40,7 +44,7 @@ class ResetTask(commands.Cog): last_reset_month = datetime.fromtimestamp(last_reset, timezone.utc).month if last_reset > 0 else None if last_reset_month != now.month: reset_leaderboard("monthly") - print(f"[XP System] Monthly leaderboard reset at {now}") + logger.info(f"Monthly leaderboard reset at {now}") def should_reset_daily(self, now): """Check if it's time for daily reset (00:00 UTC).""" diff --git a/xp/restoredb.py b/xp/restoredb.py index 54a92e2..6fb2991 100644 --- a/xp/restoredb.py +++ b/xp/restoredb.py @@ -1,18 +1,19 @@ +"""XP database restore commands for recovering from backups.""" import discord from discord import app_commands from discord.ext import commands from discord.ui import View, Button import os import shutil +from pathlib import Path from moderation.loader import ModerationBase from .groups import xp_admin_group class RestoreXP(commands.Cog): def __init__(self, bot): self.bot = bot - self.base_dir = os.path.dirname(os.path.abspath(__file__)) - self.db_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data") - self.backup_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "backups", "xp") + self.db_dir = Path(__file__).parent.parent / "data" + self.backup_dir = Path(__file__).parent.parent / "data" / "backups" / "xp" # Register onto the shared xpadmin group cmd = app_commands.command(name="restore", description="Restore a lifetime or annual XP database from backup")(self.restorebackup) # Attach the autocompleters to the command object before adding diff --git a/xp/sync.py b/xp/sync.py index 6f6c1d0..5967f4d 100644 --- a/xp/sync.py +++ b/xp/sync.py @@ -1,13 +1,17 @@ +"""XP role sync commands that assign Discord roles based on XP level.""" import discord from discord.ext import commands from discord import app_commands -from moderation.loader import ModerationBase, ADMIN_ROLE_IDS, lilac_id +from moderation.loader import ModerationBase, ADMIN_ROLE_IDS +from utils.constants import LILAC_ID from .database import get_db from .utils import load_config, xp_for_level from .groups import xp_group from discord.utils import get -import traceback import asyncio +from utils.logger import get_logger + +logger = get_logger(__name__) class XPSync(commands.Cog): """Sync XP role rewards for users.""" @@ -19,7 +23,7 @@ class XPSync(commands.Cog): def check_is_admin(self, user: discord.Member) -> bool: """Check if user has admin permissions.""" - is_lilac = user.id == lilac_id + is_lilac = user.id == LILAC_ID has_admin_role = any(role.id in ADMIN_ROLE_IDS for role in user.roles) return has_admin_role or is_lilac @@ -58,9 +62,9 @@ class XPSync(commands.Cog): # Small delay between role additions to avoid rate limits await asyncio.sleep(0.5) except discord.Forbidden: - print(f"Cannot assign {role.name} to {member} - missing permissions") + logger.warning(f"Cannot assign {role.name} to {member} - missing permissions") except discord.HTTPException as e: - print(f"HTTP error assigning {role.name} to {member}: {e}") + logger.error(f"HTTP error assigning {role.name} to {member}: {e}") return (level, roles_added) @@ -86,27 +90,27 @@ class XPSync(commands.Cog): # NOW defer the interaction after permission checks pass await interaction.response.defer(ephemeral=False) - print(f"[SYNC] Deferred interaction for user {interaction.user.id}") + logger.debug(f"Deferred interaction for user {interaction.user.id}") # Fetch target member try: target_member = interaction.guild.get_member(target_user.id) if not target_member: - print(f"[SYNC] Member not in cache, fetching from API") + logger.debug("Member not in cache, fetching from API") target_member = await interaction.guild.fetch_member(target_user.id) except discord.NotFound: return await interaction.followup.send( - f"{target_user.mention} is not in this server.", + f"{target_user.mention} is not in this server.", ephemeral=True ) except discord.HTTPException as e: - print(f"[SYNC] HTTP error fetching member: {e}") + logger.error(f"HTTP error fetching member: {e}") return await interaction.followup.send( - f"Error fetching member: {e}", + f"Error fetching member: {e}", ephemeral=True ) - print(f"[SYNC] Starting role sync for {target_member.id}") + logger.debug(f"Starting role sync for {target_member.id}") # Sync roles with timeout protection try: @@ -115,13 +119,13 @@ class XPSync(commands.Cog): timeout=25.0 # 25 seconds to stay under Discord's 30s limit ) except asyncio.TimeoutError: - print(f"[SYNC] Timeout during role sync for {target_member.id}") + logger.warning(f"Timeout during role sync for {target_member.id}") return await interaction.followup.send( - "Role sync took too long. Please try again or contact an admin.", + "Role sync took too long. Please try again or contact an admin.", ephemeral=True ) - print(f"[SYNC] Completed sync for {target_member.id}: Level {level}, Roles added: {roles_added}") + logger.info(f"Completed sync for {target_member.id}: Level {level}, Roles added: {roles_added}") # Send response if level == 0: @@ -139,18 +143,16 @@ class XPSync(commands.Cog): ) except discord.NotFound: - print(f"[SYNC] Interaction or message not found - may have timed out") - # Can't respond if interaction is gone + logger.warning("Interaction or message not found - may have timed out") except Exception as e: - print(f"[SYNC] Unexpected error in sync command: {e}") - traceback.print_exc() + logger.error(f"Unexpected error in sync command: {e}", exc_info=True) try: await interaction.followup.send( - f"Error syncing roles: {str(e)[:100]}", + f"Error syncing roles: {str(e)[:100]}", ephemeral=True ) except Exception as followup_error: - print(f"[SYNC] Could not send error message: {followup_error}") + logger.error(f"Could not send error message: {followup_error}") def cog_unload(self): xp_group.remove_command("sync") diff --git a/xp/utils.py b/xp/utils.py index f804671..b76acf8 100644 --- a/xp/utils.py +++ b/xp/utils.py @@ -1,3 +1,4 @@ +"""XP system utilities: level math, multipliers, config loading.""" import math import random import time