From f7045b9c42047824c9912cfe0a5e10974663b6ff Mon Sep 17 00:00:00 2001 From: youngcoder45 Date: Wed, 20 May 2026 23:22:52 +0530 Subject: [PATCH 1/3] Major count fix --- bot.py | 139 ++-- cogs/counting.py | 36 +- cogs/tickets.py | 1350 ++++++++++++++++++++--------------- cogs/tts.py | 51 +- utils/codebuddy_database.py | 187 ++++- 5 files changed, 1066 insertions(+), 697 deletions(-) diff --git a/bot.py b/bot.py index 4166ac7..dbe42e9 100644 --- a/bot.py +++ b/bot.py @@ -13,6 +13,7 @@ from discord import app_commands from discord.ext import commands from dotenv import load_dotenv + from utils.config import Config # Load environment variables @@ -20,15 +21,13 @@ # Configure logging logging.basicConfig( - level=getattr(logging, os.getenv('LOG_LEVEL', 'INFO')), - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler('bot.log'), - logging.StreamHandler() - ] + level=getattr(logging, os.getenv("LOG_LEVEL", "INFO")), + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.FileHandler("bot.log"), logging.StreamHandler()], ) logger = logging.getLogger(__name__) + class Fun2OoshBot(commands.Bot): """Main bot class for Eigen Bot.""" @@ -41,23 +40,26 @@ def __init__(self, config: Config): # Disable the built-in help_command so a custom help cog can register `?helpmenu` and `/help` super().__init__( - command_prefix='?', + command_prefix="?", intents=intents, help_command=None, # SECURITY: Prevent mass-mention exploits caused by echoing user content. # Even if the bot has Administrator / Mention Everyone, this blocks @everyone/@here and role pings. - allowed_mentions=discord.AllowedMentions(everyone=False, roles=False, users=True, replied_user=False), + allowed_mentions=discord.AllowedMentions( + everyone=False, roles=False, users=True, replied_user=False + ), ) self.start_time = discord.utils.utcnow() self.config = config # Discover available cog modules from the cogs directory from pathlib import Path - cogs_dir = Path(__file__).resolve().parent / 'cogs' + + cogs_dir = Path(__file__).resolve().parent / "cogs" self.available_cogs = [] if cogs_dir.exists() and cogs_dir.is_dir(): for p in sorted(cogs_dir.iterdir()): - if p.suffix == '.py' and p.stem != '__init__': + if p.suffix == ".py" and p.stem != "__init__": self.available_cogs.append(p.stem) logger.info(f"Available cogs discovered: {self.available_cogs}") @@ -66,6 +68,7 @@ async def setup_hook(self) -> None: # Initialize CodeBuddy database try: from utils.codebuddy_database import init_db + await init_db() logger.info("Initialized CodeBuddy database") except Exception as e: @@ -73,47 +76,47 @@ async def setup_hook(self) -> None: # Load core cogs core_cogs = [ - 'cogs.misc', - 'cogs.admin', - 'cogs.tickets', + "cogs.misc", + "cogs.admin", + "cogs.tickets", ] for ext in core_cogs: try: await self.load_extension(ext) - logger.info(f'Loaded {ext}') + logger.info(f"Loaded {ext}") except Exception as e: - logger.error(f'Failed to load {ext}: {e}') + logger.error(f"Failed to load {ext}: {e}") # Load feature cogs (new/renamed) feature_cogs = [ - 'cogs.tags', - 'cogs.fun', - 'cogs.starboard', - 'cogs.help', - 'cogs.community', - 'cogs.utility_extra', - 'cogs.afk', - 'cogs.birthday', - 'cogs.bump_leaderboard', - 'cogs.suggestions', - 'cogs.codebuddy_quiz', - 'cogs.codebuddy_leaderboard', - 'cogs.codebuddy_help', - 'cogs.counting', - 'cogs.tod', - 'cogs.daily_quests', - 'cogs.staff_applications', - 'cogs.tts', - 'cogs.chowkidar' + "cogs.tags", + "cogs.fun", + "cogs.starboard", + "cogs.help", + "cogs.community", + "cogs.utility_extra", + "cogs.afk", + "cogs.birthday", + "cogs.bump_leaderboard", + "cogs.suggestions", + "cogs.codebuddy_quiz", + "cogs.codebuddy_leaderboard", + "cogs.codebuddy_help", + "cogs.counting", + "cogs.tod", + "cogs.daily_quests", + "cogs.staff_applications", + "cogs.tts", + "cogs.chowkidar", ] for ext in feature_cogs: try: await self.load_extension(ext) - logger.info(f'Loaded {ext}') + logger.info(f"Loaded {ext}") except Exception as e: - logger.error(f'Failed to load {ext}: {e}') + logger.error(f"Failed to load {ext}: {e}") # Load modmail cog # try: @@ -132,19 +135,27 @@ async def setup_hook(self) -> None: self.tree.copy_global_to(guild=guild) synced = await self.tree.sync(guild=guild) - logger.info(f"āœ… Synced {len(synced)} slash commands to guild {guild_id}") - logger.info(f"šŸ“Š Guild Command Slots: {len(synced)}/100 used ({100 - len(synced)} remaining)") + logger.info( + f"āœ… Synced {len(synced)} slash commands to guild {guild_id}" + ) + logger.info( + f"šŸ“Š Guild Command Slots: {len(synced)}/100 used ({100 - len(synced)} remaining)" + ) command_names = [cmd.name for cmd in synced] - logger.info(f"šŸ“ Synced commands for {guild_id}: {', '.join(command_names)}") + logger.info( + f"šŸ“ Synced commands for {guild_id}: {', '.join(command_names)}" + ) else: synced = await self.tree.sync() logger.info(f"āœ… Synced {len(synced)} slash commands globally") - logger.info(f"šŸ“Š Global Command Slots: {len(synced)}/100 used ({100 - len(synced)} remaining)") + logger.info( + f"šŸ“Š Global Command Slots: {len(synced)}/100 used ({100 - len(synced)} remaining)" + ) command_names = [cmd.name for cmd in synced] logger.info(f"šŸ“ Synced commands: {', '.join(command_names)}") - + except Exception as e: logger.error(f"āŒ Failed to sync slash commands: {e}") @@ -155,17 +166,19 @@ async def setup_hook(self) -> None: async def on_ready(self): """Called when the bot is ready.""" if self.user: - logger.info(f'Logged in as {self.user} (ID: {self.user.id})') + logger.info(f"Logged in as {self.user} (ID: {self.user.id})") else: - logger.info('Bot logged in but user is None') - logger.info(f'Connected to {len(self.guilds)} guilds') + logger.info("Bot logged in but user is None") + logger.info(f"Connected to {len(self.guilds)} guilds") # Set presence await self.change_presence( activity=discord.Game(name="?helpmenu /help | Made by YC45") ) - async def on_command_error(self, ctx: commands.Context, error: commands.CommandError): + async def on_command_error( + self, ctx: commands.Context, error: commands.CommandError + ): """Handle command errors.""" # Silence unknown/prefix-not-found commands if isinstance(error, commands.CommandNotFound): @@ -189,7 +202,12 @@ async def _safe_ctx_send(message: str) -> None: return await interaction.followup.send(message, ephemeral=True) return - except (discord.NotFound, discord.HTTPException, discord.Forbidden, RuntimeError): + except ( + discord.NotFound, + discord.HTTPException, + discord.Forbidden, + RuntimeError, + ): # Fall back to a normal channel send. pass except Exception: @@ -202,7 +220,9 @@ async def _safe_ctx_send(message: str) -> None: return if isinstance(error, commands.CommandOnCooldown): - await _safe_ctx_send(f"This command is on cooldown. Try again in {error.retry_after:.2f} seconds.") + await _safe_ctx_send( + f"This command is on cooldown. Try again in {error.retry_after:.2f} seconds." + ) elif isinstance(error, commands.MissingPermissions): await _safe_ctx_send("You don't have permission to use this command.") elif isinstance(error, commands.BadArgument): @@ -211,7 +231,9 @@ async def _safe_ctx_send(message: str) -> None: logger.error(f"Command error: {error}") await _safe_ctx_send("An error occurred while processing your command.") - async def on_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError): + async def on_app_command_error( + self, interaction: discord.Interaction, error: app_commands.AppCommandError + ): """Handle slash command errors.""" # Silence unknown slash/app commands. # `app_commands` doesn't expose a stable CommandNotFound across versions, so be conservative. @@ -220,7 +242,7 @@ async def on_app_command_error(self, interaction: discord.Interaction, error: ap if isinstance(error, app_commands.CommandOnCooldown): await interaction.response.send_message( f"This command is on cooldown. Try again in {error.retry_after:.2f} seconds.", - ephemeral=True + ephemeral=True, ) else: # For other app command errors, log and try to respond once @@ -229,12 +251,13 @@ async def on_app_command_error(self, interaction: discord.Interaction, error: ap if not interaction.response.is_done(): await interaction.response.send_message( "An error occurred while processing your command.", - ephemeral=True + ephemeral=True, ) except Exception: # If sending fails, silently ignore to avoid noisy errors for unknown commands return + async def main(): """Main function to run the bot.""" config = Config() @@ -248,11 +271,21 @@ async def main(): try: await bot.start(config.discord_token) except KeyboardInterrupt: - logger.info("Bot shutdown requested.") + logger.info("Bot shutdown requested (KeyboardInterrupt).") + except asyncio.CancelledError: + # Occurs during event loop cancellation / shutdown sequences (e.g. Ctrl-C) + logger.info( + "Bot start cancelled (asyncio.CancelledError). Shutting down gracefully." + ) except Exception as e: logger.error(f"Bot encountered an error: {e}") finally: await bot.close() -if __name__ == '__main__': - asyncio.run(main()) + +if __name__ == "__main__": + try: + asyncio.run(main()) + except (KeyboardInterrupt, asyncio.CancelledError): + # Graceful shutdown requested (Ctrl-C or event loop cancellation). + logger.info("Shutdown requested; exiting.") diff --git a/cogs/counting.py b/cogs/counting.py index b9641c5..a49c6a8 100644 --- a/cogs/counting.py +++ b/cogs/counting.py @@ -12,7 +12,9 @@ from utils.codebuddy_database import ( DB_PATH, + MAX_SAVE_UNITS, add_guild_save_units, + get_guild_daily_award_info, get_guild_save_units, get_user_save_units, increment_guild_daily_count, @@ -45,16 +47,31 @@ async def _reaction_worker(self) -> None: # A small delay between reaction requests keeps us under the common reaction route limits. # Reactions may appear slightly delayed, but they will still be added. while True: - message, emoji = await self._reaction_queue.get() + try: + message, emoji = await self._reaction_queue.get() + except asyncio.CancelledError: + # Task cancelled (bot shutdown); exit gracefully. + break + try: try: await message.add_reaction(emoji) except Exception: pass - await asyncio.sleep(0.35) + try: + await asyncio.sleep(0.35) + except asyncio.CancelledError: + # Task cancelled during sleep; still perform cleanup and exit loop. + self._pending_reactions.discard((message.id, emoji)) + self._reaction_queue.task_done() + break finally: self._pending_reactions.discard((message.id, emoji)) - self._reaction_queue.task_done() + try: + self._reaction_queue.task_done() + except Exception: + # Ignore if task_done called multiple times or queue already closed. + pass def _enqueue_reaction(self, message: discord.Message, emoji: str) -> None: key = (message.id, emoji) @@ -577,9 +594,11 @@ async def on_message(self, message): # Guild daily counting: increment the per-guild daily counter and award # a server save for every 10 valid counts (counts may come from multiple users). try: - awarded, new_daily_count = await increment_guild_daily_count( - message.guild.id - ) + ( + awarded, + new_daily_count, + reason, + ) = await increment_guild_daily_count(message.guild.id) if awarded: # Try to get current server save units for display (best-effort). try: @@ -598,6 +617,11 @@ async def on_message(self, message): await message.channel.send( f"Server reached **{new_daily_count}** counting numbers today — awarded **1.0** server save!" ) + else: + # Silent on non-award situations to avoid spamming the counting channel. + # We maintain internal state and will award again when conditions are met. + pass + except Exception: # Best-effort only; do not break counting if this fails. pass diff --git a/cogs/tickets.py b/cogs/tickets.py index 86fbc53..2809bdf 100644 --- a/cogs/tickets.py +++ b/cogs/tickets.py @@ -1,26 +1,27 @@ -import discord -from discord.ext import commands -from discord import app_commands -import sqlite3 -from datetime import datetime, timezone -from typing import Optional import asyncio import io import logging +import sqlite3 +from datetime import datetime, timezone +from typing import Optional + +import discord +from discord import app_commands +from discord.ext import commands from utils.database import DATABASE_NAME -from utils.helpers import create_error_embed, create_success_embed, create_info_embed +from utils.helpers import create_error_embed, create_info_embed, create_success_embed logger = logging.getLogger("codeverse.tickets") class TicketCategoryView(discord.ui.View): """View for selecting ticket category""" - + def __init__(self, cog): super().__init__(timeout=180) self.cog = cog - + @discord.ui.select( placeholder="Choose a category...", options=[ @@ -30,154 +31,168 @@ def __init__(self, cog): discord.SelectOption(label="Partnership", value="partnership"), discord.SelectOption(label="Reports", value="report"), discord.SelectOption(label="Other Issues", value="other"), - ] + ], ) - async def category_select(self, interaction: discord.Interaction, select: discord.ui.Select): + async def category_select( + self, interaction: discord.Interaction, select: discord.ui.Select + ): category = select.values[0] await self.cog.show_ticket_info(interaction, category) class TicketConfirmationView(discord.ui.View): """View for confirming ticket creation after seeing info""" - + def __init__(self, cog, category): super().__init__(timeout=120) self.cog = cog self.category = category - + @discord.ui.button(label="Create This Ticket", style=discord.ButtonStyle.grey) - async def create_ticket_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def create_ticket_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Create the actual ticket""" await self.cog.create_ticket(interaction, self.category) - + @discord.ui.button(label="Back to Categories", style=discord.ButtonStyle.grey) - async def back_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def back_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Go back to category selection""" view = TicketCategoryView(self.cog) embed = discord.Embed( title="šŸŽ« Create a Ticket", description="Select a category from the dropdown below:\n\n**Available Categories:**\n• General Support\n• Bug Reports\n• Feature Requests\n• Partnership\n• Reports\n• Other Issues", - color=0x2B2D31 + color=0x2B2D31, ) embed.set_footer(text="Choose the category that best fits your needs") - + await interaction.response.edit_message(embed=embed, view=view) class TicketControlView(discord.ui.View): """Persistent view with ticket control buttons""" - + def __init__(self, cog): super().__init__(timeout=None) self.cog = cog - + @discord.ui.button( label="šŸ”’ Close Ticket", style=discord.ButtonStyle.red, - custom_id="ticket_close_button" + custom_id="ticket_close_button", ) - async def close_ticket(self, interaction: discord.Interaction, button: discord.ui.Button): + async def close_ticket( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Close the ticket""" await self.cog.handle_close_ticket(interaction) - + @discord.ui.button( label="šŸ“Œ Claim Ticket", style=discord.ButtonStyle.primary, - custom_id="ticket_claim_button" + custom_id="ticket_claim_button", ) - async def claim_ticket(self, interaction: discord.Interaction, button: discord.ui.Button): + async def claim_ticket( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Claim the ticket""" await self.cog.handle_claim_ticket(interaction) class TicketPanelView(discord.ui.View): """Persistent view for the ticket panel""" - + def __init__(self, cog): super().__init__(timeout=None) # Persistent view self.cog = cog - + @discord.ui.button( label="Create Ticket", style=discord.ButtonStyle.grey, - custom_id="persistent_ticket_create_button" # Static custom_id + custom_id="persistent_ticket_create_button", # Static custom_id ) - async def create_ticket_button(self, interaction: discord.Interaction, button: discord.ui.Button): + async def create_ticket_button( + self, interaction: discord.Interaction, button: discord.ui.Button + ): """Handle ticket creation button""" # Check if user already has an open ticket conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() cursor.execute( 'SELECT ticket_thread_id FROM tickets WHERE user_id = ? AND status = "open"', - (interaction.user.id,) + (interaction.user.id,), ) existing = cursor.fetchone() conn.close() - + if existing: await interaction.response.send_message( embed=create_error_embed( "Ticket Already Open", - f"You already have an open ticket: <#{existing[0]}>" + f"You already have an open ticket: <#{existing[0]}>", ), - ephemeral=True + ephemeral=True, ) return - + # Show ticket category selection view = TicketCategoryView(self.cog) embed = discord.Embed( title="šŸŽ« Create a Ticket", description="Select a category from the dropdown below:\n\n**Available Categories:**\n• General Support\n• Bug Reports\n• Feature Requests\n• Partnership\n• Reports\n• Other Issues", - color=0x2B2D31 + color=0x2B2D31, ) embed.set_footer(text="Choose the category that best fits your needs") - + await interaction.response.send_message(embed=embed, view=view, ephemeral=True) class Tickets(commands.Cog): """Advanced ticket system using threads for support and moderation""" - + def __init__(self, bot): self.bot = bot self._init_database() - + # Configuration - self.ticket_channel_id = None # Set this to the channel where tickets will be created as threads + self.ticket_channel_id = ( + None # Set this to the channel where tickets will be created as threads + ) # Note: Logs will be sent to #ticketlog channel in each server (optional) self.staff_role_id = 1417900662053671073 # Your staff role ID - + # Ticket naming self.ticket_counter = self._get_ticket_counter() - + # Register persistent views on bot startup self.bot.loop.create_task(self._restore_persistent_views()) - + async def _restore_persistent_views(self): """Restore persistent views for all ticket panels on bot startup""" await self.bot.wait_until_ready() - + try: conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - + # Get all ticket panels from database - cursor.execute('SELECT guild_id, channel_id, message_id FROM ticket_panels') + cursor.execute("SELECT guild_id, channel_id, message_id FROM ticket_panels") panels = cursor.fetchall() conn.close() - + # Re-register the view for each panel for guild_id, channel_id, message_id in panels: try: guild = self.bot.get_guild(guild_id) if not guild: continue - + channel = guild.get_channel(channel_id) if not channel or not isinstance(channel, discord.TextChannel): continue - + # Fetch the message to ensure it exists try: await channel.fetch_message(message_id) @@ -185,32 +200,43 @@ async def _restore_persistent_views(self): view = TicketPanelView(self) # The view is automatically registered due to persistent custom_id self.bot.add_view(view, message_id=message_id) - logger.info(f"Restored ticket panel view for message {message_id} in guild {guild_id}") + logger.info( + f"Restored ticket panel view for message {message_id} in guild {guild_id}" + ) except discord.NotFound: # Message was deleted, remove from database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_panels WHERE message_id = ?', (message_id,)) + cursor.execute( + "DELETE FROM ticket_panels WHERE message_id = ?", + (message_id,), + ) conn.commit() conn.close() - logger.warning(f"Ticket panel message {message_id} not found, removed from database") + logger.warning( + f"Ticket panel message {message_id} not found, removed from database" + ) except Exception as e: - logger.error(f"Error fetching ticket panel message {message_id}: {e}") - + logger.error( + f"Error fetching ticket panel message {message_id}: {e}" + ) + except Exception as e: - logger.error(f"Error restoring ticket panel for guild {guild_id}: {e}") - + logger.error( + f"Error restoring ticket panel for guild {guild_id}: {e}" + ) + logger.info(f"Restored {len(panels)} ticket panel views") - + except Exception as e: logger.error(f"Error restoring persistent ticket views: {e}") - + def _init_database(self): """Initialize tickets database table""" conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - - cursor.execute(''' + + cursor.execute(""" CREATE TABLE IF NOT EXISTS tickets ( ticket_id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_thread_id INTEGER NOT NULL, @@ -222,10 +248,10 @@ def _init_database(self): closed_at TIMESTAMP, close_reason TEXT ) - ''') - + """) + # Table for storing persistent ticket panels - cursor.execute(''' + cursor.execute(""" CREATE TABLE IF NOT EXISTS ticket_panels ( panel_id INTEGER PRIMARY KEY AUTOINCREMENT, guild_id INTEGER NOT NULL, @@ -235,61 +261,63 @@ def _init_database(self): created_by INTEGER NOT NULL, UNIQUE(guild_id, channel_id, message_id) ) - ''') - + """) + # Table for storing custom ticket log channel settings - cursor.execute(''' + cursor.execute(""" CREATE TABLE IF NOT EXISTS ticket_log_channels ( guild_id INTEGER PRIMARY KEY, channel_id INTEGER NOT NULL, set_by INTEGER NOT NULL, set_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') - + """) + # Table for storing ticket support team role settings - cursor.execute(''' + cursor.execute(""" CREATE TABLE IF NOT EXISTS ticket_support_roles ( guild_id INTEGER PRIMARY KEY, role_id INTEGER NOT NULL, set_by INTEGER NOT NULL, set_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') - + """) + # Table for storing ticket report team role settings - cursor.execute(''' + cursor.execute(""" CREATE TABLE IF NOT EXISTS ticket_report_roles ( guild_id INTEGER PRIMARY KEY, role_id INTEGER NOT NULL, set_by INTEGER NOT NULL, set_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') - + """) + # Table for storing ticket partner team role settings - cursor.execute(''' + cursor.execute(""" CREATE TABLE IF NOT EXISTS ticket_partner_roles ( guild_id INTEGER PRIMARY KEY, role_id INTEGER NOT NULL, set_by INTEGER NOT NULL, set_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) - ''') - + """) + conn.commit() conn.close() - + def _get_ticket_counter(self) -> int: """Get the next ticket number""" conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('SELECT COUNT(*) FROM tickets') + cursor.execute("SELECT COUNT(*) FROM tickets") count = cursor.fetchone()[0] conn.close() return count + 1 - - def _get_ticket_log_channel(self, guild: discord.Guild) -> Optional[discord.TextChannel]: + + def _get_ticket_log_channel( + self, guild: discord.Guild + ) -> Optional[discord.TextChannel]: """Get the ticketlog channel for the guild if it exists""" # Check for hardcoded ticket log channel TICKET_LOGS_CHANNEL = 1438487366305190018 @@ -301,10 +329,13 @@ def _get_ticket_log_channel(self, guild: discord.Guild) -> Optional[discord.Text try: conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('SELECT channel_id FROM ticket_log_channels WHERE guild_id = ?', (guild.id,)) + cursor.execute( + "SELECT channel_id FROM ticket_log_channels WHERE guild_id = ?", + (guild.id,), + ) result = cursor.fetchone() conn.close() - + if result: channel = guild.get_channel(result[0]) if channel and isinstance(channel, discord.TextChannel): @@ -313,27 +344,38 @@ def _get_ticket_log_channel(self, guild: discord.Guild) -> Optional[discord.Text # Clean up invalid channel reference conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_log_channels WHERE guild_id = ?', (guild.id,)) + cursor.execute( + "DELETE FROM ticket_log_channels WHERE guild_id = ?", + (guild.id,), + ) conn.commit() conn.close() except Exception as e: print(f"[Tickets] Error checking custom log channel: {e}") - + # Fall back to checking channel names for channel in guild.text_channels: - if channel.name.lower() in ['ticketlog', 'ticket-log', 'ticketlogs', 'ticket-logs']: + if channel.name.lower() in [ + "ticketlog", + "ticket-log", + "ticketlogs", + "ticket-logs", + ]: return channel return None - + def _get_support_team_role(self, guild: discord.Guild) -> Optional[discord.Role]: """Get the support team role for the guild if it exists""" try: conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('SELECT role_id FROM ticket_support_roles WHERE guild_id = ?', (guild.id,)) + cursor.execute( + "SELECT role_id FROM ticket_support_roles WHERE guild_id = ?", + (guild.id,), + ) result = cursor.fetchone() conn.close() - + if result: role = guild.get_role(result[0]) if role: @@ -342,27 +384,33 @@ def _get_support_team_role(self, guild: discord.Guild) -> Optional[discord.Role] # Clean up invalid role reference conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_support_roles WHERE guild_id = ?', (guild.id,)) + cursor.execute( + "DELETE FROM ticket_support_roles WHERE guild_id = ?", + (guild.id,), + ) conn.commit() conn.close() except Exception as e: print(f"[Tickets] Error checking custom support role: {e}") - + # Fall back to checking for default staff role for role in guild.roles: if role.id == self.staff_role_id: return role return None - + def _get_report_team_role(self, guild: discord.Guild) -> Optional[discord.Role]: """Get the report team role for the guild if it exists""" try: conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('SELECT role_id FROM ticket_report_roles WHERE guild_id = ?', (guild.id,)) + cursor.execute( + "SELECT role_id FROM ticket_report_roles WHERE guild_id = ?", + (guild.id,), + ) result = cursor.fetchone() conn.close() - + if result: role = guild.get_role(result[0]) if role: @@ -371,24 +419,30 @@ def _get_report_team_role(self, guild: discord.Guild) -> Optional[discord.Role]: # Clean up invalid role reference conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_report_roles WHERE guild_id = ?', (guild.id,)) + cursor.execute( + "DELETE FROM ticket_report_roles WHERE guild_id = ?", + (guild.id,), + ) conn.commit() conn.close() except Exception as e: print(f"[Tickets] Error checking report team role: {e}") - + # Fall back to support team role if no report role set return self._get_support_team_role(guild) - + def _get_partner_team_role(self, guild: discord.Guild) -> Optional[discord.Role]: """Get the partner team role for the guild if it exists""" try: conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('SELECT role_id FROM ticket_partner_roles WHERE guild_id = ?', (guild.id,)) + cursor.execute( + "SELECT role_id FROM ticket_partner_roles WHERE guild_id = ?", + (guild.id,), + ) result = cursor.fetchone() conn.close() - + if result: role = guild.get_role(result[0]) if role: @@ -397,15 +451,18 @@ def _get_partner_team_role(self, guild: discord.Guild) -> Optional[discord.Role] # Clean up invalid role reference conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_partner_roles WHERE guild_id = ?', (guild.id,)) + cursor.execute( + "DELETE FROM ticket_partner_roles WHERE guild_id = ?", + (guild.id,), + ) conn.commit() conn.close() except Exception as e: print(f"[Tickets] Error checking partner team role: {e}") - + # Fall back to support team role if no partner role set return self._get_support_team_role(guild) - + async def show_ticket_info(self, interaction: discord.Interaction, category: str): """Show information about the selected ticket type""" # Category information with detailed descriptions @@ -429,7 +486,7 @@ async def show_ticket_info(self, interaction: discord.Interaction, category: str "**Ready to apply?** Click 'Create This Ticket' to begin the partnership application process." ), "examples": "Discord server partnerships, tech community collaborations, educational alliances", - "color": 0x2B2D31 + "color": 0x2B2D31, }, "support": { "name": "General Support", @@ -442,7 +499,7 @@ async def show_ticket_info(self, interaction: discord.Interaction, category: str "**Be patient** - Our team will help you as soon as possible" ), "examples": "How to use features, account questions, general guidance", - "color": 0x2B2D31 + "color": 0x2B2D31, }, "bug_reports": { "name": "Bug Reports", @@ -455,7 +512,7 @@ async def show_ticket_info(self, interaction: discord.Interaction, category: str "**When it happened** - Date and time (approximate)" ), "examples": "Bot commands not working, features broken, error messages, unexpected behavior", - "color": 0x2B2D31 + "color": 0x2B2D31, }, "report": { "name": "Reports", @@ -468,7 +525,7 @@ async def show_ticket_info(self, interaction: discord.Interaction, category: str "**Your involvement** - Were you directly affected?" ), "examples": "Harassment, spam, rule breaking, inappropriate content", - "color": 0x2B2D31 + "color": 0x2B2D31, }, "feature_requests": { "name": "Feature Requests", @@ -481,7 +538,7 @@ async def show_ticket_info(self, interaction: discord.Interaction, category: str "**Priority** - How important is this to you? (low/medium/high)" ), "examples": "New bot commands, server improvements, quality of life changes, feature enhancements", - "color": 0x2B2D31 + "color": 0x2B2D31, }, "other": { "name": "Other Issues", @@ -494,57 +551,53 @@ async def show_ticket_info(self, interaction: discord.Interaction, category: str "**Additional context** - Any other details that might help" ), "examples": "Feedback, suggestions, questions not covered by other categories", - "color": 0x2B2D31 - } + "color": 0x2B2D31, + }, } - + info = category_info.get(category, category_info["other"]) - + embed = discord.Embed( - title=info["name"], - description=info["description"], - color=info["color"] + title=info["name"], description=info["description"], color=info["color"] ) - + embed.add_field( name="Guidelines for this ticket type:", value=info["guidelines"], - inline=False - ) - - embed.add_field( - name="Examples:", - value=info["examples"], - inline=False + inline=False, ) - + + embed.add_field(name="Examples:", value=info["examples"], inline=False) + embed.add_field( name="What happens next?", value=( "Your ticket will be created as a private thread\n" - "Our support team will be notified automatically\n" + "Our support team will be notified automatically\n" "You'll receive help from qualified staff members\n" "The ticket will remain open until your issue is resolved" ), - inline=False + inline=False, + ) + + embed.set_footer( + text="Click 'Create This Ticket' if you're ready to proceed, or go back to choose a different category." ) - - embed.set_footer(text="Click 'Create This Ticket' if you're ready to proceed, or go back to choose a different category.") - + view = TicketConfirmationView(self, category) await interaction.response.send_message(embed=embed, view=view, ephemeral=True) - + async def create_ticket(self, interaction: discord.Interaction, category: str): """Create a new ticket thread""" # Defer the response to avoid timeout await interaction.response.defer(ephemeral=True) - + if not interaction.guild: return - + guild = interaction.guild user = interaction.user - + # Category emojis and names category_info = { "support": ("ā“", "General Support"), @@ -552,45 +605,49 @@ async def create_ticket(self, interaction: discord.Interaction, category: str): "feature_requests": ("āš–ļø", "Feature Requests"), "partnership": ("šŸ¤", "Partnership"), "report": ("🚨", "Reports"), - "other": ("šŸ“", "Other Issues") + "other": ("šŸ“", "Other Issues"), } - + emoji, category_name = category_info.get(category, ("", "Ticket")) - + # Get ticket channel ticket_channel_id = self.ticket_channel_id or interaction.channel_id if ticket_channel_id is None: await interaction.followup.send( - embed=create_error_embed("Configuration Error", "No ticket channel configured."), - ephemeral=True + embed=create_error_embed( + "Configuration Error", "No ticket channel configured." + ), + ephemeral=True, ) return - + ticket_channel = guild.get_channel(ticket_channel_id) - + if not ticket_channel or not isinstance(ticket_channel, discord.TextChannel): await interaction.followup.send( - embed=create_error_embed("Configuration Error", "Ticket channel not properly configured."), - ephemeral=True + embed=create_error_embed( + "Configuration Error", "Ticket channel not properly configured." + ), + ephemeral=True, ) return - + # Create ticket thread ticket_number = self.ticket_counter self.ticket_counter += 1 - + thread_name = f"{emoji} Ticket-{ticket_number:04d} | {category_name}" - + try: # Create the thread thread = await ticket_channel.create_thread( name=thread_name, - auto_archive_duration=4320 # 3 days + auto_archive_duration=4320, # 3 days ) - + # Add user to thread await thread.add_user(user) - + # Add staff role members based on ticket category staff_role = None if category == "report": @@ -599,31 +656,30 @@ async def create_ticket(self, interaction: discord.Interaction, category: str): staff_role = self._get_partner_team_role(guild) else: staff_role = self._get_support_team_role(guild) - - + except Exception as e: await interaction.followup.send( embed=create_error_embed("Failed to Create Ticket", f"Error: {str(e)}"), - ephemeral=True + ephemeral=True, ) return - + # Save to database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() cursor.execute( - 'INSERT INTO tickets (ticket_thread_id, user_id, category) VALUES (?, ?, ?)', - (thread.id, user.id, category) + "INSERT INTO tickets (ticket_thread_id, user_id, category) VALUES (?, ?, ?)", + (thread.id, user.id, category), ) ticket_id = cursor.lastrowid conn.commit() conn.close() - + # Send welcome message in thread embed = discord.Embed( title=f"{emoji} Ticket #{ticket_number} - {category_name}", description=f"Welcome {user.mention}! Thank you for creating a ticket.\n\nPlease describe your issue in detail, and our staff team will assist you shortly.", - color=0x2ecc71 + color=0x2ECC71, ) embed.add_field( name="šŸ“‹ Ticket Information", @@ -632,7 +688,7 @@ async def create_ticket(self, interaction: discord.Interaction, category: str): f"**Created:** \n" f"**Status:** 🟢 Open" ), - inline=False + inline=False, ) embed.add_field( name="šŸŽ›ļø Ticket Controls", @@ -640,107 +696,113 @@ async def create_ticket(self, interaction: discord.Interaction, category: str): "• **šŸ”’ Close** - Close this ticket\n" "• **šŸ“Œ Claim** - Claim this ticket (Staff)" ), - inline=False + inline=False, ) embed.set_footer(text=f"Ticket ID: {ticket_id} | CodeVerse Support") - + view = TicketControlView(self) staff_mention = staff_role.mention if staff_role else "@Staff" - await thread.send(content=f"{user.mention} | Staff: {staff_mention}", embed=embed, view=view) - + await thread.send( + content=f"{user.mention} | Staff: {staff_mention}", embed=embed, view=view + ) + # Confirm to user await interaction.followup.send( embed=create_success_embed( - "Ticket Created", - f"Your ticket has been created: {thread.mention}" + "Ticket Created", f"Your ticket has been created: {thread.mention}" ), - ephemeral=True + ephemeral=True, ) - + # Log to staff channel if ticket_id is not None: await self._log_ticket_action( - "CREATED", - ticket_id, - thread, - user, - category_name + "CREATED", ticket_id, thread, user, category_name ) - - print(f"[Tickets] Ticket #{ticket_number} created by {user} ({user.id}) - Category: {category_name}") - + + print( + f"[Tickets] Ticket #{ticket_number} created by {user} ({user.id}) - Category: {category_name}" + ) + async def handle_close_ticket(self, interaction: discord.Interaction): """Handle ticket closure""" if not isinstance(interaction.channel, discord.Thread): await interaction.response.send_message( - embed=create_error_embed("Not a Ticket", "This command can only be used in ticket threads."), - ephemeral=True + embed=create_error_embed( + "Not a Ticket", "This command can only be used in ticket threads." + ), + ephemeral=True, ) return - + thread = interaction.channel - + # Get ticket info from database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() cursor.execute( 'SELECT ticket_id, user_id, category FROM tickets WHERE ticket_thread_id = ? AND status = "open"', - (thread.id,) + (thread.id,), ) result = cursor.fetchone() - + if not result: await interaction.response.send_message( - embed=create_error_embed("Not a Ticket", "This is not an open ticket thread."), - ephemeral=True + embed=create_error_embed( + "Not a Ticket", "This is not an open ticket thread." + ), + ephemeral=True, ) conn.close() return - + ticket_id, user_id, category = result - + # Check permissions (ticket owner or staff) has_permission = False if isinstance(interaction.user, discord.Member): has_permission = ( - interaction.user.id == user_id or - any(role.id == self.staff_role_id for role in interaction.user.roles) or - interaction.user.guild_permissions.administrator + interaction.user.id == user_id + or any(role.id == self.staff_role_id for role in interaction.user.roles) + or interaction.user.guild_permissions.administrator ) elif interaction.user.id == user_id: has_permission = True - + if not has_permission: await interaction.response.send_message( - embed=create_error_embed("No Permission", "Only the ticket owner or staff can close this ticket."), - ephemeral=True + embed=create_error_embed( + "No Permission", + "Only the ticket owner or staff can close this ticket.", + ), + ephemeral=True, ) conn.close() return - + # Update database cursor.execute( 'UPDATE tickets SET status = "closed", closed_at = CURRENT_TIMESTAMP, close_reason = ? WHERE ticket_id = ?', - (f"Closed by {interaction.user}", ticket_id) + (f"Closed by {interaction.user}", ticket_id), ) conn.commit() conn.close() - + # Send closure message embed = discord.Embed( title="šŸ”’ Ticket Closed", description=f"This ticket has been closed by {interaction.user.mention}", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="šŸ“‹ Next Steps", value="This thread will be archived and locked in 10 seconds.\nA transcript has been saved.", - inline=False + inline=False, ) embed.timestamp = datetime.now(timezone.utc) - + await interaction.response.send_message(embed=embed) - + # Log closure await self._log_ticket_action( "CLOSED", @@ -748,12 +810,12 @@ async def handle_close_ticket(self, interaction: discord.Interaction): thread, interaction.user, category, - f"Closed by {interaction.user.name}" + f"Closed by {interaction.user.name}", ) - + # Generate transcript before archiving await self._generate_transcript(thread, ticket_id, save_to_log=True) - + # Archive and lock thread after delay await asyncio.sleep(10) try: @@ -761,96 +823,103 @@ async def handle_close_ticket(self, interaction: discord.Interaction): await thread.edit(archived=True, locked=True) except Exception as e: print(f"[Tickets] Failed to archive ticket thread: {e}") - + print(f"[Tickets] Ticket #{ticket_id} closed by {interaction.user}") - + async def handle_claim_ticket(self, interaction: discord.Interaction): """Handle ticket claiming by staff""" if not isinstance(interaction.channel, discord.Thread): await interaction.response.send_message( - embed=create_error_embed("Not a Ticket", "This command can only be used in ticket threads."), - ephemeral=True + embed=create_error_embed( + "Not a Ticket", "This command can only be used in ticket threads." + ), + ephemeral=True, ) return - + thread = interaction.channel - + # Check if user is staff is_staff = False if isinstance(interaction.user, discord.Member): is_staff = ( - any(role.id == self.staff_role_id for role in interaction.user.roles) or - interaction.user.guild_permissions.administrator + any(role.id == self.staff_role_id for role in interaction.user.roles) + or interaction.user.guild_permissions.administrator ) - + if not is_staff: await interaction.response.send_message( - embed=create_error_embed("No Permission", "Only staff members can claim tickets."), - ephemeral=True + embed=create_error_embed( + "No Permission", "Only staff members can claim tickets." + ), + ephemeral=True, ) return - + # Get ticket info conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() cursor.execute( 'SELECT ticket_id, user_id, claimed_by FROM tickets WHERE ticket_thread_id = ? AND status = "open"', - (thread.id,) + (thread.id,), ) result = cursor.fetchone() - + if not result: await interaction.response.send_message( - embed=create_error_embed("Not a Ticket", "This is not an open ticket thread."), - ephemeral=True + embed=create_error_embed( + "Not a Ticket", "This is not an open ticket thread." + ), + ephemeral=True, ) conn.close() return - + ticket_id, user_id, claimed_by = result - + if claimed_by: try: claimer = await self.bot.fetch_user(claimed_by) await interaction.response.send_message( embed=create_info_embed( "Already Claimed", - f"This ticket is already claimed by {claimer.mention}" + f"This ticket is already claimed by {claimer.mention}", ), - ephemeral=True + ephemeral=True, ) except: await interaction.response.send_message( embed=create_info_embed( - "Already Claimed", - "This ticket is already claimed by someone." + "Already Claimed", "This ticket is already claimed by someone." ), - ephemeral=True + ephemeral=True, ) conn.close() return - + # Claim ticket cursor.execute( - 'UPDATE tickets SET claimed_by = ? WHERE ticket_id = ?', - (interaction.user.id, ticket_id) + "UPDATE tickets SET claimed_by = ? WHERE ticket_id = ?", + (interaction.user.id, ticket_id), ) conn.commit() conn.close() - + # Send claim message embed = discord.Embed( title="šŸ“Œ Ticket Claimed", description=f"{interaction.user.mention} is now handling this ticket.", - color=0x3498db + color=0x3498DB, ) embed.timestamp = datetime.now(timezone.utc) - + await interaction.response.send_message(embed=embed) - + print(f"[Tickets] Ticket #{ticket_id} claimed by {interaction.user}") - - async def _generate_transcript(self, thread: discord.Thread, ticket_id: int, save_to_log: bool = False) -> Optional[str]: + + async def _generate_transcript( + self, thread: discord.Thread, ticket_id: int, save_to_log: bool = False + ) -> Optional[str]: """Generate a text transcript of the ticket""" try: messages = [] @@ -858,178 +927,199 @@ async def _generate_transcript(self, thread: discord.Thread, ticket_id: int, sav timestamp = message.created_at.strftime("%Y-%m-%d %H:%M:%S UTC") author = f"{message.author.display_name}" content = message.content or "[No text content]" - + # Include attachments if message.attachments: for attachment in message.attachments: content += f"\n[Attachment: {attachment.url}]" - + messages.append(f"[{timestamp}] {author}: {content}") - + transcript = f"Ticket #{ticket_id} Transcript\n" transcript += f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}\n" transcript += "=" * 80 + "\n\n" transcript += "\n".join(messages) - + # Save to log channel if requested if save_to_log and thread.guild: log_channel = self._get_ticket_log_channel(thread.guild) if log_channel: file = discord.File( - io.BytesIO(transcript.encode('utf-8')), - filename=f"ticket-{ticket_id}-transcript.txt" + io.BytesIO(transcript.encode("utf-8")), + filename=f"ticket-{ticket_id}-transcript.txt", ) - + embed = discord.Embed( title=f"šŸ“„ Ticket #{ticket_id} Transcript", description="Transcript saved for closed ticket.", - color=0x95a5a6 + color=0x95A5A6, ) embed.timestamp = datetime.now(timezone.utc) - + await log_channel.send(embed=embed, file=file) - + return transcript except Exception as e: print(f"[Tickets] Failed to generate transcript: {e}") return None - - async def _log_ticket_action(self, action: str, ticket_id: int, thread: discord.Thread, - user: discord.User | discord.Member, category: Optional[str] = None, - reason: Optional[str] = None): + + async def _log_ticket_action( + self, + action: str, + ticket_id: int, + thread: discord.Thread, + user: discord.User | discord.Member, + category: Optional[str] = None, + reason: Optional[str] = None, + ): """Log ticket actions to the ticket log channel in the server (if it exists)""" if not thread.guild: return - + log_channel = self._get_ticket_log_channel(thread.guild) if not log_channel: - print(f"[Tickets] No #ticketlog channel found in {thread.guild.name} - skipping log") + print( + f"[Tickets] No #ticketlog channel found in {thread.guild.name} - skipping log" + ) return - - colors = { - "CREATED": 0x2ecc71, - "CLOSED": 0xe74c3c, - "CLAIMED": 0x3498db - } - + + colors = {"CREATED": 0x2ECC71, "CLOSED": 0xE74C3C, "CLAIMED": 0x3498DB} + titles = { "CREATED": "✨ Ticket Created", "CLOSED": "šŸ”’ Ticket Closed", - "CLAIMED": "šŸ“Œ Ticket Claimed" + "CLAIMED": "šŸ“Œ Ticket Claimed", } - + embed = discord.Embed( title=titles.get(action, f"šŸŽ« Ticket {action}"), - color=colors.get(action, 0x95a5a6) + color=colors.get(action, 0x95A5A6), ) - + embed.add_field(name="Ticket ID", value=f"#{ticket_id}", inline=True) embed.add_field(name="User", value=f"{user.mention} ({user.id})", inline=True) embed.add_field(name="Thread", value=thread.mention, inline=True) - + if category: embed.add_field(name="Category", value=category, inline=True) - + if reason: embed.add_field(name="šŸ“ Reason", value=reason, inline=False) - + embed.timestamp = datetime.now(timezone.utc) embed.set_footer(text="Ticket System") - + try: await log_channel.send(embed=embed) except Exception as e: print(f"[Tickets] Failed to send log: {e}") - + @commands.hybrid_command(name="ticketpanel") @commands.has_permissions(administrator=True) @app_commands.describe( channel="Channel to send the ticket panel to", support_role="Role to ping when new tickets are created (optional)", report_role="Role to ping for report tickets (optional)", - partner_role="Role to ping for partnership tickets (optional)" + partner_role="Role to ping for partnership tickets (optional)", ) - async def ticket_panel(self, ctx, - channel: Optional[discord.TextChannel] = None, - support_role: Optional[discord.Role] = None, - report_role: Optional[discord.Role] = None, - partner_role: Optional[discord.Role] = None): + async def ticket_panel( + self, + ctx, + channel: Optional[discord.TextChannel] = None, + support_role: Optional[discord.Role] = None, + report_role: Optional[discord.Role] = None, + partner_role: Optional[discord.Role] = None, + ): """Create a ticket panel with a button to open tickets""" target_channel = channel or ctx.channel - + if not isinstance(target_channel, discord.TextChannel): await ctx.send( - embed=create_error_embed("Invalid Channel", "Please provide a valid text channel."), - ephemeral=True + embed=create_error_embed( + "Invalid Channel", "Please provide a valid text channel." + ), + ephemeral=True, ) return - + embed = discord.Embed( title="šŸŽ« Support Tickets", description="Need help? Click **Create Ticket** below to get started!\n\n**Available Categories:**\n• General Support\n• Bug Reports\n• Feature Requests\n• Partnership\n• Reports\n• Other Issues", - color=0x2B2D31 + color=0x2B2D31, ) - + embed.set_footer(text="Private • Fast • Organized") if ctx.guild and ctx.guild.icon: embed.set_thumbnail(url=ctx.guild.icon.url) - + view = TicketPanelView(self) panel_message = await target_channel.send(embed=embed, view=view) - + # Save panel to database for persistence if ctx.guild: try: conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - - cursor.execute(''' + + cursor.execute( + """ INSERT OR IGNORE INTO ticket_panels (guild_id, channel_id, message_id, created_by) VALUES (?, ?, ?, ?) - ''', (ctx.guild.id, target_channel.id, panel_message.id, ctx.author.id)) - + """, + (ctx.guild.id, target_channel.id, panel_message.id, ctx.author.id), + ) + conn.commit() conn.close() except Exception as e: logger.error(f"Error saving ticket panel to database: {e}") - + # Set ticket channel to this channel self.ticket_channel_id = target_channel.id - + # Save roles if provided roles_saved = [] if ctx.guild: try: conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - + # Save support role if support_role: - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO ticket_support_roles (guild_id, role_id, set_by, set_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ''', (ctx.guild.id, support_role.id, ctx.author.id)) + """, + (ctx.guild.id, support_role.id, ctx.author.id), + ) roles_saved.append(f"**Support:** {support_role.mention}") - + # Save report role if report_role: - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO ticket_report_roles (guild_id, role_id, set_by, set_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ''', (ctx.guild.id, report_role.id, ctx.author.id)) + """, + (ctx.guild.id, report_role.id, ctx.author.id), + ) roles_saved.append(f"**Report:** {report_role.mention}") - + # Save partner role if partner_role: - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO ticket_partner_roles (guild_id, role_id, set_by, set_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ''', (ctx.guild.id, partner_role.id, ctx.author.id)) + """, + (ctx.guild.id, partner_role.id, ctx.author.id), + ) roles_saved.append(f"**Partner:** {partner_role.mention}") - + conn.commit() conn.close() - + if roles_saved: role_info = "\n".join(roles_saved) success_message = f"Ticket panel created in {target_channel.mention}\nTickets will be created as threads in that channel.\n\n{role_info}" @@ -1038,7 +1128,7 @@ async def ticket_panel(self, ctx, current_support = self._get_support_team_role(ctx.guild) current_report = self._get_report_team_role(ctx.guild) current_partner = self._get_partner_team_role(ctx.guild) - + current_roles = [] if current_support: current_roles.append(f"**Support:** {current_support.mention}") @@ -1046,38 +1136,41 @@ async def ticket_panel(self, ctx, current_roles.append(f"**Report:** {current_report.mention}") if current_partner and current_partner != current_support: current_roles.append(f"**Partner:** {current_partner.mention}") - + if current_roles: role_info = "\n".join(current_roles) success_message = f"Ticket panel created in {target_channel.mention}\nTickets will be created as threads in that channel.\n\n**Current Role Settings:**\n{role_info}" else: success_message = f"Ticket panel created in {target_channel.mention}\nTickets will be created as threads in that channel.\n\nšŸ’” Use `/ticketpanel #channel @support @report @partner` to set specialized roles." - + except Exception as e: print(f"[Tickets] Failed to save roles: {e}") success_message = f"Ticket panel created in {target_channel.mention}\nTickets will be created as threads in that channel.\n\nāš ļø Failed to save role settings." else: success_message = f"Ticket panel created in {target_channel.mention}\nTickets will be created as threads in that channel." - + await ctx.send( - embed=create_success_embed( - "Panel Created", - success_message - ), - ephemeral=True + embed=create_success_embed("Panel Created", success_message), ephemeral=True ) - + @commands.hybrid_command(name="ticketlog") @commands.has_permissions(administrator=True) @app_commands.describe( channel="The channel to use for ticket logs (leave empty to view current setting)" ) - async def ticket_log_setup(self, ctx, channel: Optional[discord.TextChannel] = None): - """Set up, view, or disable the ticket log channel for this server""" + async def ticket_log_setup( + self, ctx, channel: Optional[discord.TextChannel] = None + ): + """Set up, view, or disable the ticket log channel for this server""" if not ctx.guild: - await ctx.send(embed=create_error_embed("Error", "This command can only be used in servers."), ephemeral=True) + await ctx.send( + embed=create_error_embed( + "Error", "This command can only be used in servers." + ), + ephemeral=True, + ) return - + if channel is None: # View current setting current_log_channel = self._get_ticket_log_channel(ctx.guild) @@ -1085,164 +1178,172 @@ async def ticket_log_setup(self, ctx, channel: Optional[discord.TextChannel] = N embed = discord.Embed( title="šŸ“‹ Ticket Log Channel", description=f"Current ticket log channel: {current_log_channel.mention}", - color=0x5865F2 + color=0x5865F2, ) embed.add_field( - name="ā„¹ļø Information", + name="ā„¹ļø Information", value="Ticket actions (create, close, claim) will be logged to this channel.\n\n" - "• **Change it:** `/ticketlog #new-channel`\n" - "• **Disable custom:** `/ticketlog-disable`\n" - "• **Quick disable:** Rename this channel", - inline=False + "• **Change it:** `/ticketlog #new-channel`\n" + "• **Disable custom:** `/ticketlog-disable`\n" + "• **Quick disable:** Rename this channel", + inline=False, ) else: embed = discord.Embed( title="šŸ“‹ Ticket Log Channel", description="āŒ No ticket log channel is currently set up.", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="šŸ”§ Setup Instructions", value="To enable ticket logging:\n" - "1. Create a channel named `ticketlog`\n" - "2. Or use `/ticketlog #channel` to set a specific channel\n" - "3. Make sure the bot has permission to send messages there", - inline=False + "1. Create a channel named `ticketlog`\n" + "2. Or use `/ticketlog #channel` to set a specific channel\n" + "3. Make sure the bot has permission to send messages there", + inline=False, ) - + await ctx.send(embed=embed, ephemeral=True) return - + # Set new log channel try: # Test if bot can send messages to the channel test_embed = discord.Embed( title="🧪 Test Message", description="Testing ticket log setup...", - color=0x95a5a6 + color=0x95A5A6, ) test_message = await channel.send(embed=test_embed) - + # Delete test message await test_message.delete() - + # Save the channel to database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO ticket_log_channels (guild_id, channel_id, set_by, set_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ''', (ctx.guild.id, channel.id, ctx.author.id)) + """, + (ctx.guild.id, channel.id, ctx.author.id), + ) conn.commit() conn.close() - + # Update the helper function to recognize this specific channel # We'll store it in a simple way by checking if it's the designated channel success_embed = discord.Embed( title="āœ… Ticket Log Channel Set", description=f"Ticket logs will now be sent to {channel.mention}", - color=0x2ecc71 + color=0x2ECC71, ) success_embed.add_field( name="šŸ“ What gets logged?", value="• New ticket creation\n• Ticket closing\n• Ticket claiming\n• Ticket transcripts", - inline=False + inline=False, ) success_embed.add_field( name="šŸ’” Note", value="The bot automatically detects channels named `ticketlog`, `ticket-log`, etc.\n" - f"You can also rename {channel.mention} to any of these names.", - inline=False + f"You can also rename {channel.mention} to any of these names.", + inline=False, ) - + await ctx.send(embed=success_embed, ephemeral=True) - + # Send a confirmation to the log channel log_embed = discord.Embed( title="šŸŽ« Ticket Logging Enabled", description=f"This channel has been set up for ticket logging by {ctx.author.mention}", - color=0x5865F2 + color=0x5865F2, ) log_embed.add_field( name="šŸ“Š What you'll see here:", value="• Ticket creation notifications\n• Ticket closure logs\n• Staff claim notifications\n• Ticket transcripts", - inline=False + inline=False, ) log_embed.timestamp = datetime.now(timezone.utc) log_embed.set_footer(text="CodeVerse Ticket System") - + await channel.send(embed=log_embed) - + except discord.Forbidden: await ctx.send( embed=create_error_embed( "Permission Error", f"I don't have permission to send messages in {channel.mention}.\n" - "Please make sure I have `Send Messages` permission in that channel." + "Please make sure I have `Send Messages` permission in that channel.", ), - ephemeral=True + ephemeral=True, ) except Exception as e: await ctx.send( embed=create_error_embed( - "Setup Error", - f"Failed to set up ticket logging: {str(e)}" + "Setup Error", f"Failed to set up ticket logging: {str(e)}" ), - ephemeral=True + ephemeral=True, ) - + @commands.hybrid_command(name="ticketlog-disable") @commands.has_permissions(administrator=True) async def ticket_log_disable(self, ctx): """Disable ticket logging for this server""" if not ctx.guild: - await ctx.send(embed=create_error_embed("Error", "This command can only be used in servers."), ephemeral=True) + await ctx.send( + embed=create_error_embed( + "Error", "This command can only be used in servers." + ), + ephemeral=True, + ) return - + try: # Remove custom log channel setting from database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_log_channels WHERE guild_id = ?', (ctx.guild.id,)) + cursor.execute( + "DELETE FROM ticket_log_channels WHERE guild_id = ?", (ctx.guild.id,) + ) deleted = cursor.rowcount > 0 conn.commit() conn.close() - + if deleted: embed = discord.Embed( title="🚫 Ticket Logging Disabled", description="Custom ticket log channel has been removed.", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="ā„¹ļø Note", value="The bot will still log to channels named `ticketlog`, `ticket-log`, etc. if they exist.\n" - "To completely disable logging, rename or delete those channels.", - inline=False + "To completely disable logging, rename or delete those channels.", + inline=False, ) else: embed = discord.Embed( title="ā„¹ļø No Custom Channel Set", description="There was no custom ticket log channel to disable.", - color=0x95a5a6 + color=0x95A5A6, ) embed.add_field( name="Current Status", value="The bot is using automatic detection for channels named `ticketlog`, `ticket-log`, etc.", - inline=False + inline=False, ) - + await ctx.send(embed=embed, ephemeral=True) - + except Exception as e: await ctx.send( embed=create_error_embed( - "Error", - f"Failed to disable ticket logging: {str(e)}" + "Error", f"Failed to disable ticket logging: {str(e)}" ), - ephemeral=True + ephemeral=True, ) - + @commands.hybrid_command(name="ticketsupport") @commands.has_permissions(administrator=True) @app_commands.describe( @@ -1251,9 +1352,14 @@ async def ticket_log_disable(self, ctx): async def ticket_support_role(self, ctx, role: Optional[discord.Role] = None): """Set up or view the support team role for ticket notifications""" if not ctx.guild: - await ctx.send(embed=create_error_embed("Error", "This command can only be used in servers."), ephemeral=True) + await ctx.send( + embed=create_error_embed( + "Error", "This command can only be used in servers." + ), + ephemeral=True, + ) return - + if role is None: # View current setting current_role = self._get_support_team_role(ctx.guild) @@ -1261,183 +1367,198 @@ async def ticket_support_role(self, ctx, role: Optional[discord.Role] = None): embed = discord.Embed( title="šŸ‘„ Ticket Support Role", description=f"Current support role: {current_role.mention}", - color=0x5865F2 + color=0x5865F2, ) embed.add_field( - name="ā„¹ļø Information", - value="This role will be pinged when new tickets are created.\n\n" - "• **Change it:** `/ticketsupport @new-role`\n" - "• **Remove it:** `/ticketsupport-disable`\n" - "• **Set via panel:** `/ticketpanel #channel @role`", - inline=False + name="ā„¹ļø Information", + value="This role will be pinged when new tickets are created.\n\n" + "• **Change it:** `/ticketsupport @new-role`\n" + "• **Remove it:** `/ticketsupport-disable`\n" + "• **Set via panel:** `/ticketpanel #channel @role`", + inline=False, ) else: embed = discord.Embed( title="šŸ‘„ Ticket Support Role", description="āŒ No support role is currently set up.", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="šŸ”§ Setup Instructions", value="To set a support role:\n" - "1. Use `/ticketsupport @role` to set a role\n" - "2. Or use `/ticketpanel #channel @role` when creating panels\n" - "3. The role will be pinged when new tickets are created", - inline=False + "1. Use `/ticketsupport @role` to set a role\n" + "2. Or use `/ticketpanel #channel @role` when creating panels\n" + "3. The role will be pinged when new tickets are created", + inline=False, ) - + await ctx.send(embed=embed, ephemeral=True) return - + # Set new support role try: # Save the role to database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO ticket_support_roles (guild_id, role_id, set_by, set_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ''', (ctx.guild.id, role.id, ctx.author.id)) + """, + (ctx.guild.id, role.id, ctx.author.id), + ) conn.commit() conn.close() - + success_embed = discord.Embed( title="āœ… Support Role Set", description=f"Support role set to {role.mention}", - color=0x2ecc71 + color=0x2ECC71, ) success_embed.add_field( name="šŸ“ What happens now?", value=f"• {role.mention} will be pinged when new tickets are created\n" - f"• Role members will be added to ticket threads automatically\n" - f"• This setting applies to all new tickets in this server", - inline=False + f"• Role members will be added to ticket threads automatically\n" + f"• This setting applies to all new tickets in this server", + inline=False, ) success_embed.add_field( name="šŸ’” Tips", value="• Make sure the role is mentionable\n" - "• You can change this anytime with `/ticketsupport @new-role`\n" - "• Use `/ticketsupport-disable` to remove the setting", - inline=False + "• You can change this anytime with `/ticketsupport @new-role`\n" + "• Use `/ticketsupport-disable` to remove the setting", + inline=False, ) - + await ctx.send(embed=success_embed, ephemeral=True) - + except Exception as e: await ctx.send( embed=create_error_embed( - "Setup Error", - f"Failed to set support role: {str(e)}" + "Setup Error", f"Failed to set support role: {str(e)}" ), - ephemeral=True + ephemeral=True, ) - + @commands.hybrid_command(name="ticketsupport-disable") @commands.has_permissions(administrator=True) async def ticket_support_role_disable(self, ctx): """Disable the custom support role for tickets""" if not ctx.guild: - await ctx.send(embed=create_error_embed("Error", "This command can only be used in servers."), ephemeral=True) + await ctx.send( + embed=create_error_embed( + "Error", "This command can only be used in servers." + ), + ephemeral=True, + ) return - + try: # Remove support role setting from database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_support_roles WHERE guild_id = ?', (ctx.guild.id,)) + cursor.execute( + "DELETE FROM ticket_support_roles WHERE guild_id = ?", (ctx.guild.id,) + ) deleted = cursor.rowcount > 0 conn.commit() conn.close() - + if deleted: embed = discord.Embed( title="🚫 Support Role Disabled", description="Custom support role has been removed.", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="ā„¹ļø Note", value=f"New tickets will fall back to using the default staff role (ID: {self.staff_role_id}) if it exists.\n" - "Use `/ticketsupport @role` to set a new support role.", - inline=False + "Use `/ticketsupport @role` to set a new support role.", + inline=False, ) else: embed = discord.Embed( title="ā„¹ļø No Custom Role Set", description="There was no custom support role to disable.", - color=0x95a5a6 + color=0x95A5A6, ) embed.add_field( name="Current Status", value=f"Using default staff role (ID: {self.staff_role_id}) if it exists.", - inline=False + inline=False, ) - + await ctx.send(embed=embed, ephemeral=True) - + except Exception as e: await ctx.send( embed=create_error_embed( - "Error", - f"Failed to disable support role: {str(e)}" + "Error", f"Failed to disable support role: {str(e)}" ), - ephemeral=True + ephemeral=True, ) - + @commands.hybrid_command(name="tickets") @commands.has_permissions(manage_messages=True) @app_commands.describe( status="Filter tickets by status (open, closed, all)", - user="Filter tickets by user" + user="Filter tickets by user", ) - async def tickets_list(self, ctx, status: str = "open", user: Optional[discord.User] = None): + async def tickets_list( + self, ctx, status: str = "open", user: Optional[discord.User] = None + ): """View all tickets or filter by status/user""" conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - - query = 'SELECT ticket_id, ticket_thread_id, user_id, category, status, claimed_by, created_at FROM tickets' + + query = "SELECT ticket_id, ticket_thread_id, user_id, category, status, claimed_by, created_at FROM tickets" params = [] - + if status != "all": - query += ' WHERE status = ?' + query += " WHERE status = ?" params.append(status) - + if user: if params: - query += ' AND user_id = ?' + query += " AND user_id = ?" else: - query += ' WHERE user_id = ?' + query += " WHERE user_id = ?" params.append(user.id) - - query += ' ORDER BY created_at DESC LIMIT 20' - + + query += " ORDER BY created_at DESC LIMIT 20" + cursor.execute(query, params) tickets = cursor.fetchall() conn.close() - + if not tickets: await ctx.send( embed=create_info_embed("No Tickets", f"No {status} tickets found."), - ephemeral=True + ephemeral=True, ) return - - embed = discord.Embed( - title=f" {status.title()} Tickets", - color=0x5865F2 - ) - + + embed = discord.Embed(title=f" {status.title()} Tickets", color=0x5865F2) + for ticket in tickets[:10]: # Show max 10 - ticket_id, thread_id, user_id, category, ticket_status, claimed_by, created_at = ticket - + ( + ticket_id, + thread_id, + user_id, + category, + ticket_status, + claimed_by, + created_at, + ) = ticket + try: ticket_user = await self.bot.fetch_user(user_id) user_name = f"{ticket_user.name}" except: user_name = f"Unknown ({user_id})" - + status_emoji = "🟢" if ticket_status == "open" else "" - + claimer_text = "" if claimed_by: try: @@ -1445,7 +1566,7 @@ async def tickets_list(self, ctx, status: str = "open", user: Optional[discord.U claimer_text = f"\n**Claimed by:** {claimer.name}" except: claimer_text = "\n**Claimed by:** Unknown" - + embed.add_field( name=f"{status_emoji} Ticket #{ticket_id}", value=( @@ -1455,40 +1576,39 @@ async def tickets_list(self, ctx, status: str = "open", user: Optional[discord.U f"**Created:** " f"{claimer_text}" ), - inline=True + inline=True, ) - + embed.set_footer(text=f"Showing {len(tickets[:10])} of {len(tickets)} tickets") - + await ctx.send(embed=embed) - + @commands.hybrid_command(name="ticketstats") @commands.has_permissions(manage_messages=True) async def ticket_stats(self, ctx): """View ticket statistics""" conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - + # Get various stats - cursor.execute('SELECT COUNT(*) FROM tickets') + cursor.execute("SELECT COUNT(*) FROM tickets") total_tickets = cursor.fetchone()[0] - + cursor.execute('SELECT COUNT(*) FROM tickets WHERE status = "open"') open_tickets = cursor.fetchone()[0] - + cursor.execute('SELECT COUNT(*) FROM tickets WHERE status = "closed"') closed_tickets = cursor.fetchone()[0] - - cursor.execute('SELECT category, COUNT(*) FROM tickets GROUP BY category ORDER BY COUNT(*) DESC') + + cursor.execute( + "SELECT category, COUNT(*) FROM tickets GROUP BY category ORDER BY COUNT(*) DESC" + ) categories = cursor.fetchall() - + conn.close() - - embed = discord.Embed( - title="šŸ“Š Ticket Statistics", - color=0x5865F2 - ) - + + embed = discord.Embed(title="šŸ“Š Ticket Statistics", color=0x5865F2) + embed.add_field( name="šŸ“‹ Overview", value=( @@ -1496,52 +1616,51 @@ async def ticket_stats(self, ctx): f"**🟢 Open:** {open_tickets}\n" f"**šŸ”“ Closed:** {closed_tickets}" ), - inline=True + inline=True, ) - + if categories: - category_text = "\n".join([f"**{cat.title()}:** {count}" for cat, count in categories[:5]]) - embed.add_field( - name="šŸ“‚ By Category", - value=category_text, - inline=True + category_text = "\n".join( + [f"**{cat.title()}:** {count}" for cat, count in categories[:5]] ) - + embed.add_field(name="šŸ“‚ By Category", value=category_text, inline=True) + embed.timestamp = datetime.now(timezone.utc) embed.set_footer(text="CodeVerse Ticket System") - + await ctx.send(embed=embed) - + @commands.hybrid_command(name="forceclose") @commands.has_permissions(manage_messages=True) @app_commands.describe( ticket_id="The ID of the ticket to force close", - reason="Reason for force closing the ticket" + reason="Reason for force closing the ticket", ) - async def force_close_ticket(self, ctx, ticket_id: int, *, reason: str = "Force closed by staff"): + async def force_close_ticket( + self, ctx, ticket_id: int, *, reason: str = "Force closed by staff" + ): """Force close a ticket by its ID (Staff only)""" # Get ticket info from database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() cursor.execute( 'SELECT ticket_thread_id, user_id, category FROM tickets WHERE ticket_id = ? AND status = "open"', - (ticket_id,) + (ticket_id,), ) result = cursor.fetchone() - + if not result: await ctx.send( embed=create_error_embed( - "Ticket Not Found", - f"No open ticket found with ID #{ticket_id}" + "Ticket Not Found", f"No open ticket found with ID #{ticket_id}" ), - ephemeral=True + ephemeral=True, ) conn.close() return - + thread_id, user_id, category = result - + # Get the thread if ctx.guild: thread = ctx.guild.get_thread(thread_id) @@ -1553,67 +1672,80 @@ async def force_close_ticket(self, ctx, ticket_id: int, *, reason: str = "Force thread = None else: thread = None - + # Update database to mark as closed cursor.execute( 'UPDATE tickets SET status = "closed", closed_at = CURRENT_TIMESTAMP, close_reason = ? WHERE ticket_id = ?', - (f"Force closed by {ctx.author}: {reason}", ticket_id) + (f"Force closed by {ctx.author}: {reason}", ticket_id), ) conn.commit() conn.close() - + # Send confirmation to command channel embed = discord.Embed( title="šŸ”’ Ticket Force Closed", description=f"Ticket **#{ticket_id}** has been force closed.", - color=0xe74c3c + color=0xE74C3C, + ) + embed.add_field( + name="šŸ‘¤ Ticket Owner", value=f"<@{user_id}> ({user_id})", inline=True ) - embed.add_field(name="šŸ‘¤ Ticket Owner", value=f"<@{user_id}> ({user_id})", inline=True) embed.add_field(name="šŸ‘® Closed By", value=ctx.author.mention, inline=True) embed.add_field(name="šŸ“ Category", value=category.title(), inline=True) embed.add_field(name="šŸ“ Reason", value=reason, inline=False) - + if thread: embed.add_field(name="šŸ“ŗ Thread", value=thread.mention, inline=True) - + embed.timestamp = datetime.now(timezone.utc) embed.set_footer(text="Force Close Command") - + await ctx.send(embed=embed) - + # Send message to thread if it exists and is accessible if thread and isinstance(thread, discord.Thread): try: closure_embed = discord.Embed( title="šŸ”’ Ticket Force Closed", description=f"This ticket has been force closed by {ctx.author.mention}", - color=0xe74c3c + color=0xE74C3C, ) closure_embed.add_field(name="šŸ“ Reason", value=reason, inline=False) closure_embed.add_field( name="ā„¹ļø Next Steps", value="This thread will be archived and locked in 10 seconds.\nA transcript has been saved.", - inline=False + inline=False, ) closure_embed.timestamp = datetime.now(timezone.utc) - + await thread.send(embed=closure_embed) - + # Generate transcript before archiving await self._generate_transcript(thread, ticket_id, save_to_log=True) - + # Archive and lock thread after delay - await asyncio.sleep(10) try: - await thread.edit(archived=True, locked=True) - print(f"[Tickets] šŸ”’ Thread archived for force closed ticket #{ticket_id}") - except Exception as e: - print(f"[Tickets] āŒ Failed to archive force closed ticket thread: {e}") - + await asyncio.sleep(10) + except asyncio.CancelledError: + # Shutdown in progress; abort archiving + print( + f"[Tickets] Archiving for ticket #{ticket_id} cancelled due to shutdown" + ) + else: + try: + await thread.edit(archived=True, locked=True) + print( + f"[Tickets] šŸ”’ Thread archived for force closed ticket #{ticket_id}" + ) + except Exception as e: + print( + f"[Tickets] āŒ Failed to archive force closed ticket thread: {e}" + ) + except Exception as e: print(f"[Tickets] āŒ Failed to send force close message to thread: {e}") # Still continue with logging even if thread message fails - + # Log to staff channel if thread: await self._log_ticket_action( @@ -1622,9 +1754,9 @@ async def force_close_ticket(self, ctx, ticket_id: int, *, reason: str = "Force thread, ctx.author, category, - f"Force closed by {ctx.author.name}: {reason}" + f"Force closed by {ctx.author.name}: {reason}", ) - + # Try to DM the ticket owner about the force closure try: user = await self.bot.fetch_user(user_id) @@ -1632,20 +1764,26 @@ async def force_close_ticket(self, ctx, ticket_id: int, *, reason: str = "Force dm_embed = discord.Embed( title="šŸ”’ Your Ticket Has Been Closed", description=f"Your ticket **#{ticket_id}** in **{ctx.guild.name}** has been closed by staff.", - color=0xe74c3c + color=0xE74C3C, + ) + dm_embed.add_field( + name="šŸ“ Category", value=category.title(), inline=True + ) + dm_embed.add_field( + name="šŸ‘® Closed By", value=str(ctx.author), inline=True ) - dm_embed.add_field(name="šŸ“ Category", value=category.title(), inline=True) - dm_embed.add_field(name="šŸ‘® Closed By", value=str(ctx.author), inline=True) dm_embed.add_field(name="šŸ“ Reason", value=reason, inline=False) dm_embed.set_footer(text=f"{ctx.guild.name} • Ticket System") - + await user.send(embed=dm_embed) print(f"[Tickets] šŸ“§ Sent force closure notification to {user}") except Exception as e: print(f"[Tickets] āŒ Failed to DM user about force closure: {e}") - - print(f"[Tickets] šŸ”’ Ticket #{ticket_id} force closed by {ctx.author} - Reason: {reason}") - + + print( + f"[Tickets] šŸ”’ Ticket #{ticket_id} force closed by {ctx.author} - Reason: {reason}" + ) + @commands.hybrid_command(name="ticketreport") @commands.has_permissions(administrator=True) @app_commands.describe( @@ -1654,137 +1792,154 @@ async def force_close_ticket(self, ctx, ticket_id: int, *, reason: str = "Force async def ticket_report_role(self, ctx, role: Optional[discord.Role] = None): """Set up or view the report team role for report ticket notifications""" if not ctx.guild: - await ctx.send(embed=create_error_embed("Error", "This command can only be used in servers."), ephemeral=True) + await ctx.send( + embed=create_error_embed( + "Error", "This command can only be used in servers." + ), + ephemeral=True, + ) return - + if role is None: # View current setting current_role = self._get_report_team_role(ctx.guild) support_role = self._get_support_team_role(ctx.guild) - + if current_role and current_role != support_role: embed = discord.Embed( title="šŸ“‹ Ticket Report Role", description=f"Current report role: {current_role.mention}", - color=0xe67e22 + color=0xE67E22, ) embed.add_field( - name="ā„¹ļø Information", - value="This role will be pinged when report tickets are created.\n\n" - "• **Change it:** `/ticketreport @new-role`\n" - "• **Remove it:** `/ticketreport-disable`\n" - "• **Set via panel:** `/ticketpanel report_role:@role`", - inline=False + name="ā„¹ļø Information", + value="This role will be pinged when report tickets are created.\n\n" + "• **Change it:** `/ticketreport @new-role`\n" + "• **Remove it:** `/ticketreport-disable`\n" + "• **Set via panel:** `/ticketpanel report_role:@role`", + inline=False, ) else: - fallback_msg = f"\n**Fallback:** Using support role: {support_role.mention}" if support_role else "" + fallback_msg = ( + f"\n**Fallback:** Using support role: {support_role.mention}" + if support_role + else "" + ) embed = discord.Embed( title="šŸ“‹ Ticket Report Role", description=f"āŒ No specialized report role is currently set up.{fallback_msg}", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="šŸ”§ Setup Instructions", value="To set a report role:\n" - "1. Use `/ticketreport @role` to set a role\n" - "2. Or use `/ticketpanel report_role:@role` when creating panels\n" - "3. The role will be pinged when report tickets are created", - inline=False + "1. Use `/ticketreport @role` to set a role\n" + "2. Or use `/ticketpanel report_role:@role` when creating panels\n" + "3. The role will be pinged when report tickets are created", + inline=False, ) - + await ctx.send(embed=embed, ephemeral=True) return - + # Set new report role try: # Save the role to database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO ticket_report_roles (guild_id, role_id, set_by, set_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ''', (ctx.guild.id, role.id, ctx.author.id)) + """, + (ctx.guild.id, role.id, ctx.author.id), + ) conn.commit() conn.close() - + success_embed = discord.Embed( title="āœ… Report Role Set", description=f"Report role set to {role.mention}", - color=0x2ecc71 + color=0x2ECC71, ) success_embed.add_field( name="šŸ“ What happens now?", value=f"• {role.mention} will be pinged when report tickets are created\n" - f"• Role members will be added to report ticket threads automatically\n" - f"• Other ticket types will use the general support role", - inline=False + f"• Role members will be added to report ticket threads automatically\n" + f"• Other ticket types will use the general support role", + inline=False, ) success_embed.add_field( name="šŸ’” Tips", value="• Make sure the role is mentionable\n" - "• You can change this anytime with `/ticketreport @new-role`\n" - "• Use `/ticketreport-disable` to remove the setting", - inline=False + "• You can change this anytime with `/ticketreport @new-role`\n" + "• Use `/ticketreport-disable` to remove the setting", + inline=False, ) - + await ctx.send(embed=success_embed, ephemeral=True) - + except Exception as e: await ctx.send( embed=create_error_embed( - "Setup Error", - f"Failed to set report role: {str(e)}" + "Setup Error", f"Failed to set report role: {str(e)}" ), - ephemeral=True + ephemeral=True, ) - + @commands.hybrid_command(name="ticketreport-disable") @commands.has_permissions(administrator=True) async def ticket_report_role_disable(self, ctx): """Disable the custom report role for tickets""" if not ctx.guild: - await ctx.send(embed=create_error_embed("Error", "This command can only be used in servers."), ephemeral=True) + await ctx.send( + embed=create_error_embed( + "Error", "This command can only be used in servers." + ), + ephemeral=True, + ) return - + try: # Remove report role setting from database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_report_roles WHERE guild_id = ?', (ctx.guild.id,)) + cursor.execute( + "DELETE FROM ticket_report_roles WHERE guild_id = ?", (ctx.guild.id,) + ) deleted = cursor.rowcount > 0 conn.commit() conn.close() - + if deleted: embed = discord.Embed( title="🚫 Report Role Disabled", description="Custom report role has been removed.", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="ā„¹ļø Note", value="Report tickets will now fall back to using the general support role.\n" - "Use `/ticketreport @role` to set a new report role.", - inline=False + "Use `/ticketreport @role` to set a new report role.", + inline=False, ) else: embed = discord.Embed( title="ā„¹ļø No Custom Role Set", description="There was no custom report role to disable.", - color=0x95a5a6 + color=0x95A5A6, ) - + await ctx.send(embed=embed, ephemeral=True) - + except Exception as e: await ctx.send( embed=create_error_embed( - "Database Error", - f"Failed to disable report role: {str(e)}" + "Database Error", f"Failed to disable report role: {str(e)}" ), - ephemeral=True + ephemeral=True, ) - + @commands.hybrid_command(name="ticketpartner") @commands.has_permissions(administrator=True) @app_commands.describe( @@ -1793,135 +1948,152 @@ async def ticket_report_role_disable(self, ctx): async def ticket_partner_role(self, ctx, role: Optional[discord.Role] = None): """Set up or view the partner team role for partnership ticket notifications""" if not ctx.guild: - await ctx.send(embed=create_error_embed("Error", "This command can only be used in servers."), ephemeral=True) + await ctx.send( + embed=create_error_embed( + "Error", "This command can only be used in servers." + ), + ephemeral=True, + ) return - + if role is None: # View current setting current_role = self._get_partner_team_role(ctx.guild) support_role = self._get_support_team_role(ctx.guild) - + if current_role and current_role != support_role: embed = discord.Embed( title="šŸ¤ Ticket Partner Role", description=f"Current partner role: {current_role.mention}", - color=0x9b59b6 + color=0x9B59B6, ) embed.add_field( - name="ā„¹ļø Information", - value="This role will be pinged when partnership tickets are created.\n\n" - "• **Change it:** `/ticketpartner @new-role`\n" - "• **Remove it:** `/ticketpartner-disable`\n" - "• **Set via panel:** `/ticketpanel partner_role:@role`", - inline=False + name="ā„¹ļø Information", + value="This role will be pinged when partnership tickets are created.\n\n" + "• **Change it:** `/ticketpartner @new-role`\n" + "• **Remove it:** `/ticketpartner-disable`\n" + "• **Set via panel:** `/ticketpanel partner_role:@role`", + inline=False, ) else: - fallback_msg = f"\n**Fallback:** Using support role: {support_role.mention}" if support_role else "" + fallback_msg = ( + f"\n**Fallback:** Using support role: {support_role.mention}" + if support_role + else "" + ) embed = discord.Embed( title="šŸ¤ Ticket Partner Role", description=f"āŒ No specialized partner role is currently set up.{fallback_msg}", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="šŸ”§ Setup Instructions", value="To set a partner role:\n" - "1. Use `/ticketpartner @role` to set a role\n" - "2. Or use `/ticketpanel partner_role:@role` when creating panels\n" - "3. The role will be pinged when partnership tickets are created", - inline=False + "1. Use `/ticketpartner @role` to set a role\n" + "2. Or use `/ticketpanel partner_role:@role` when creating panels\n" + "3. The role will be pinged when partnership tickets are created", + inline=False, ) - + await ctx.send(embed=embed, ephemeral=True) return - + # Set new partner role try: # Save the role to database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute(''' + cursor.execute( + """ INSERT OR REPLACE INTO ticket_partner_roles (guild_id, role_id, set_by, set_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) - ''', (ctx.guild.id, role.id, ctx.author.id)) + """, + (ctx.guild.id, role.id, ctx.author.id), + ) conn.commit() conn.close() - + success_embed = discord.Embed( title="āœ… Partner Role Set", description=f"Partner role set to {role.mention}", - color=0x2ecc71 + color=0x2ECC71, ) success_embed.add_field( name="šŸ“ What happens now?", value=f"• {role.mention} will be pinged when partnership tickets are created\n" - f"• Role members will be added to partnership ticket threads automatically\n" - f"• Other ticket types will use the general support role", - inline=False + f"• Role members will be added to partnership ticket threads automatically\n" + f"• Other ticket types will use the general support role", + inline=False, ) success_embed.add_field( name="šŸ’” Tips", value="• Make sure the role is mentionable\n" - "• You can change this anytime with `/ticketpartner @new-role`\n" - "• Use `/ticketpartner-disable` to remove the setting", - inline=False + "• You can change this anytime with `/ticketpartner @new-role`\n" + "• Use `/ticketpartner-disable` to remove the setting", + inline=False, ) - + await ctx.send(embed=success_embed, ephemeral=True) - + except Exception as e: await ctx.send( embed=create_error_embed( - "Setup Error", - f"Failed to set partner role: {str(e)}" + "Setup Error", f"Failed to set partner role: {str(e)}" ), - ephemeral=True + ephemeral=True, ) - + @commands.hybrid_command(name="ticketpartner-disable") @commands.has_permissions(administrator=True) async def ticket_partner_role_disable(self, ctx): """Disable the custom partner role for tickets""" if not ctx.guild: - await ctx.send(embed=create_error_embed("Error", "This command can only be used in servers."), ephemeral=True) + await ctx.send( + embed=create_error_embed( + "Error", "This command can only be used in servers." + ), + ephemeral=True, + ) return - + try: # Remove partner role setting from database conn = sqlite3.connect(DATABASE_NAME) cursor = conn.cursor() - cursor.execute('DELETE FROM ticket_partner_roles WHERE guild_id = ?', (ctx.guild.id,)) + cursor.execute( + "DELETE FROM ticket_partner_roles WHERE guild_id = ?", (ctx.guild.id,) + ) deleted = cursor.rowcount > 0 conn.commit() conn.close() - + if deleted: embed = discord.Embed( title="🚫 Partner Role Disabled", description="Custom partner role has been removed.", - color=0xe74c3c + color=0xE74C3C, ) embed.add_field( name="ā„¹ļø Note", value="Partnership tickets will now fall back to using the general support role.\n" - "Use `/ticketpartner @role` to set a new partner role.", - inline=False + "Use `/ticketpartner @role` to set a new partner role.", + inline=False, ) else: embed = discord.Embed( title="ā„¹ļø No Custom Role Set", description="There was no custom partner role to disable.", - color=0x95a5a6 + color=0x95A5A6, ) - + await ctx.send(embed=embed, ephemeral=True) - + except Exception as e: await ctx.send( embed=create_error_embed( - "Database Error", - f"Failed to disable partner role: {str(e)}" + "Database Error", f"Failed to disable partner role: {str(e)}" ), - ephemeral=True + ephemeral=True, ) diff --git a/cogs/tts.py b/cogs/tts.py index 4090501..eaf8485 100644 --- a/cogs/tts.py +++ b/cogs/tts.py @@ -1,13 +1,13 @@ import asyncio import os import sqlite3 -from queue import Queue from io import BytesIO +from queue import Queue import discord +import edge_tts from discord.ext import commands from discord.ext.commands import Context -import edge_tts class Say(commands.Cog): @@ -21,8 +21,7 @@ def __init__(self, bot: commands.Bot) -> None: # Persistent SQLite storage self.db: sqlite3.Connection = sqlite3.connect( - "botdata.db", - check_same_thread=False + "botdata.db", check_same_thread=False ) self.db.execute( @@ -46,11 +45,16 @@ async def schedule_leave(self, vc: discord.VoiceClient) -> None: async def leave_later(): try: timeout = float(os.getenv("TTS_VC_LEAVE_TIMEOUT", "240")) - await asyncio.sleep(timeout) + try: + await asyncio.sleep(timeout) + except asyncio.CancelledError: + # Task was cancelled (shutdown or reschedule). No action needed. + return if vc.is_connected() and not vc.is_playing(): await vc.disconnect() except asyncio.CancelledError: + # Outer CancelledError catch for safety; ignore pass self.leave_task = asyncio.create_task(leave_later()) @@ -60,7 +64,9 @@ async def leave_later(): # ---------------------------- async def edge_to_bytes(self, text: str) -> BytesIO: - voice = os.getenv("TTS_VOICE", "en-US-AriaNeural") # Default voice if env missing + voice = os.getenv( + "TTS_VOICE", "en-US-AriaNeural" + ) # Default voice if env missing comm = edge_tts.Communicate(text=text, voice=voice) fp = BytesIO() @@ -100,7 +106,11 @@ def after_playing(error): vc.play(source, after=after_playing) while vc.is_playing(): - await asyncio.sleep(0.5) + try: + await asyncio.sleep(0.5) + except asyncio.CancelledError: + # If the bot is shutting down, abort loop early + break if not vc.is_connected(): break @@ -112,7 +122,7 @@ def after_playing(error): self.playing = False await self.schedule_leave(vc) - # ---------------------------- + # ---------------------------- await self.schedule_leave(vc) # ---------------------------- @@ -126,14 +136,11 @@ async def logintts(self, ctx: Context, name: str): self.db.execute( "INSERT OR REPLACE INTO tts_logins (user_id, name) VALUES (?, ?)", - (ctx.author.id, name.strip()) + (ctx.author.id, name.strip()), ) self.db.commit() - await ctx.send( - f'TTS name set to: {name}\n' - "You can now use ?tts " - ) + await ctx.send(f"TTS name set to: {name}\nYou can now use ?tts ") # ---------------------------- # FORCE LEAVE VC @@ -168,15 +175,13 @@ async def tts(self, ctx: Context, *, text: str): # Fetch login from DB cursor = self.db.execute( - "SELECT name FROM tts_logins WHERE user_id = ?", - (ctx.author.id,) + "SELECT name FROM tts_logins WHERE user_id = ?", (ctx.author.id,) ) row = cursor.fetchone() if row is None: return await ctx.send( - "You must set your TTS name first.\n" - "Use: ?logintts " + "You must set your TTS name first.\nUse: ?logintts " ) tts_name: str = row[0] @@ -197,17 +202,11 @@ async def tts(self, ctx: Context, *, text: str): if ctx.message: for member in ctx.message.mentions: - content = content.replace( - f"<@{member.id}>", f"@{member.display_name}" - ) - content = content.replace( - f"<@!{member.id}>", f"@{member.display_name}" - ) + content = content.replace(f"<@{member.id}>", f"@{member.display_name}") + content = content.replace(f"<@!{member.id}>", f"@{member.display_name}") for channel_ in ctx.message.channel_mentions: - content = content.replace( - f"<#{channel_.id}>", f"#{channel_.name}" - ) + content = content.replace(f"<#{channel_.id}>", f"#{channel_.name}") self.queue.put(f"{tts_name} said {content}") diff --git a/utils/codebuddy_database.py b/utils/codebuddy_database.py index c595635..1f549e1 100644 --- a/utils/codebuddy_database.py +++ b/utils/codebuddy_database.py @@ -175,8 +175,18 @@ async def init_db(): count INTEGER NOT NULL DEFAULT 0 ) """) + # Ensure awarded_units column exists for tracking whether a daily award has been + # given and the save pool size at the time of award. This allows reset if the + # server spends the save (i.e., pool drops below the recorded value). + cursor = await db.execute("PRAGMA table_info(counting_guild_daily)") + cg_columns = [row[1] async for row in cursor] + if "awarded_units" not in cg_columns: + await db.execute( + "ALTER TABLE counting_guild_daily ADD COLUMN awarded_units INTEGER NOT NULL DEFAULT 0" + ) # Truth or Dare table + await db.execute(""" CREATE TABLE IF NOT EXISTS tod_questions ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -856,21 +866,47 @@ async def get_guild_save_units(guild_id: int) -> int: async def add_guild_save_units(guild_id: int, units: int) -> int: + """Add units to the guild save pool but never exceed MAX_SAVE_UNITS. + + This implementation only increases the stored units up to MAX_SAVE_UNITS and + ensures that further additions when already at the cap do not push the value + beyond the limit. Returns the resulting save_units after the operation. + """ async with aiosqlite.connect(DB_PATH) as db: - await db.execute( - """ - INSERT INTO counting_guild_saves (guild_id, save_units) - VALUES (?, ?) - ON CONFLICT(guild_id) DO UPDATE SET save_units = save_units + excluded.save_units - """, - (guild_id, int(units)), + # Read current value (0 if missing). + cursor = await db.execute( + "SELECT save_units FROM counting_guild_saves WHERE guild_id = ?", + (guild_id,), ) - await db.commit() + row = await cursor.fetchone() + prev_units = int(row[0] or 0) if row else 0 + + # Compute how many units we can actually add without exceeding the max. + to_add = max(0, min(int(units), MAX_SAVE_UNITS - prev_units)) + if to_add > 0: + if not row: + await db.execute( + "INSERT INTO counting_guild_saves (guild_id, save_units) VALUES (?, ?)", + (guild_id, to_add), + ) + else: + await db.execute( + "UPDATE counting_guild_saves SET save_units = save_units + ? WHERE guild_id = ?", + (to_add, guild_id), + ) + await db.commit() + + # Return the (possibly unchanged) current units. return await get_guild_save_units(guild_id) async def try_use_guild_save(guild_id: int) -> bool: - """Consume 1.0 guild save if available.""" + """Consume 1.0 guild save if available. + + Additionally, if consuming the save reduces the guild's save pool below the + `awarded_units` recorded in `counting_guild_daily`, clear that awarded flag so + the daily award may be granted again later the same day. + """ async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( "SELECT save_units FROM counting_guild_saves WHERE guild_id = ?", @@ -880,11 +916,33 @@ async def try_use_guild_save(guild_id: int) -> bool: units = int(row[0] or 0) if row else 0 if units < USE_ITEM_UNITS: return False + # Perform the consumption await db.execute( "UPDATE counting_guild_saves SET save_units = save_units - ? WHERE guild_id = ?", (USE_ITEM_UNITS, guild_id), ) await db.commit() + + # Compute new stored units after consumption + new_units = max(0, units - USE_ITEM_UNITS) + + # If the daily tracker recorded an awarded_units value greater than the + # current pool, clear that marker so the guild can receive another daily + # award later when it reaches the threshold again. + cursor = await db.execute( + "SELECT awarded_units FROM counting_guild_daily WHERE guild_id = ?", + (guild_id,), + ) + row2 = await cursor.fetchone() + if row2: + awarded_units = int(row2[0] or 0) + if awarded_units > 0 and new_units < awarded_units: + await db.execute( + "UPDATE counting_guild_daily SET awarded_units = 0 WHERE guild_id = ?", + (guild_id,), + ) + await db.commit() + return True @@ -908,44 +966,75 @@ async def get_guild_daily_count(guild_id: int) -> int: return row_count +async def get_guild_daily_award_info(guild_id: int): + """Return a tuple (awarded_units:int, count:int, date:datetime.date) for the guild's daily tracker. + + - `awarded_units` is the stored awarded_units (0 if no award has been given today). + - `count` is the daily count value (0 if none). + - `date` is the date recorded for the row (or today's date if missing). + """ + async with aiosqlite.connect(DB_PATH) as db: + cursor = await db.execute( + "SELECT awarded_units, count, date FROM counting_guild_daily WHERE guild_id = ?", + (guild_id,), + ) + row = await cursor.fetchone() + if not row: + return 0, 0, datetime.date.today() + awarded_units = int(row[0] or 0) + count = int(row[1] or 0) + row_date = _coerce_date(row[2]) + # If stored date is older than today, treat as no award for today + if row_date < datetime.date.today(): + return 0, 0, row_date + return awarded_units, count, row_date + + async def increment_guild_daily_count(guild_id: int): """ Increment the daily count for the given guild by 1. Returns a tuple (awarded_save: bool, new_count: int): - - awarded_save is True if this increment caused the guild to reach a multiple of 10 counts - for the day and a 1.0 save (10 units) was awarded to the guild. + - awarded_save is True if this increment caused the guild to reach the daily threshold + (first time reaching 10 counts in the current day) and a 1.0 save (10 units) was awarded. - new_count is the updated count for today after this increment. Notes: - This uses the counting_guild_daily table to track per-guild daily counts and will reset the counter automatically when the stored date is older than today. - - When a multiple of 10 is reached (10, 20, 30, ...), the guild is awarded 10 units - (i.e., 1.0 save) via add_guild_save_units. + - A guild can only be awarded one save per day regardless of how many subsequent tens are reached. """ today = datetime.date.today() async with aiosqlite.connect(DB_PATH) as db: cursor = await db.execute( - "SELECT count, date FROM counting_guild_daily WHERE guild_id = ?", + "SELECT count, date, awarded_units FROM counting_guild_daily WHERE guild_id = ?", (guild_id,), ) row = await cursor.fetchone() + + # Track previous count for today's window (0 if no row or row is older than today) + prev_for_today = 0 + row_awarded_units = 0 if not row: new_count = 1 await db.execute( - "INSERT INTO counting_guild_daily (guild_id, date, count) VALUES (?, ?, ?)", - (guild_id, today, new_count), + "INSERT INTO counting_guild_daily (guild_id, date, count, awarded_units) VALUES (?, ?, ?, ?)", + (guild_id, today, new_count, 0), ) else: row_count = int(row[0] or 0) row_date = _coerce_date(row[1]) + row_awarded_units = int(row[2] or 0) if row_date < today: + # previous data is stale -> reset for today (clear awarded flag) new_count = 1 await db.execute( - "UPDATE counting_guild_daily SET date = ?, count = ? WHERE guild_id = ?", - (today, new_count, guild_id), + "UPDATE counting_guild_daily SET date = ?, count = ?, awarded_units = ? WHERE guild_id = ?", + (today, new_count, 0, guild_id), ) + row_awarded_units = 0 else: + prev_for_today = row_count new_count = row_count + 1 await db.execute( "UPDATE counting_guild_daily SET count = ? WHERE guild_id = ?", @@ -955,9 +1044,61 @@ async def increment_guild_daily_count(guild_id: int): await db.commit() awarded = False - if new_count % 10 == 0: - # Award 1.0 guild save (10 units). - await add_guild_save_units(guild_id, 10) - awarded = True + reason = None + + # If the guild previously received a daily award but the server's save pool dropped + # below the stored awarded_units (e.g. the save was spent or pool emptied), clear the + # awarded flag so a new award can be granted later in the same day. + try: + current_units = await get_guild_save_units(guild_id) + except Exception: + current_units = 0 + + if row_awarded_units > 0 and current_units < row_awarded_units: + # Clear the awarded flag in the daily table. + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE counting_guild_daily SET awarded_units = 0 WHERE guild_id = ?", + (guild_id,), + ) + await db.commit() + # Also clear the local marker so the award path can run below. + row_awarded_units = 0 + + # Attempt awarding when we hit a multiple of 10 and there is no active awarded_units. + # This allows re-award if the previously-awarded save was consumed (awarded_units cleared). + if row_awarded_units == 0 and (new_count % 10) == 0: + # Try to give 10 units atomically (add_guild_save_units enforces MAX_SAVE_UNITS). + prev_units = current_units + new_units = await add_guild_save_units(guild_id, 10) + if new_units > prev_units: + awarded = True + reason = "awarded" + # Persist the awarded_units (store the pool size after award) + async with aiosqlite.connect(DB_PATH) as db: + await db.execute( + "UPDATE counting_guild_daily SET awarded_units = ? WHERE guild_id = ?", + (new_units, guild_id), + ) + await db.commit() + else: + if prev_units >= MAX_SAVE_UNITS: + reason = "at_cap" + else: + reason = "transient_failure" + else: + # No award attempted now. Explain why in caller-facing reason codes. + if row_awarded_units > 0: + reason = "already_awarded" + elif prev_for_today >= 10: + # The guild reached the threshold earlier today. If the guild currently has no + # saves, we should not block further awarding — so don't set the threshold reason. + if current_units == 0: + reason = None + else: + reason = "threshold_already_reached" + else: + # Not enough counts yet to reach the first 10 for today. + reason = None - return awarded, new_count + return awarded, new_count, reason From 782069646c6a0da2970bc4adfb4709f347da5fd5 Mon Sep 17 00:00:00 2001 From: youngcoder45 Date: Wed, 20 May 2026 23:27:12 +0530 Subject: [PATCH 2/3] Topic COG --- cogs/fun.py | 136 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/cogs/fun.py b/cogs/fun.py index 141f5f8..612381b 100644 --- a/cogs/fun.py +++ b/cogs/fun.py @@ -295,6 +295,142 @@ async def flip(self, ctx: commands.Context): embed.set_footer(text="CodeVerse Bot | Random Utilities") await ctx.reply(embed=embed, mention_author=False) + @commands.command(name="topic", help="Get a random chat topic (prefix-only).") + async def topic(self, ctx: commands.Context): + """Send a random discussion topic from a curated list (prefix-only).""" + topics = [ + "What programming language should everyone try at least once?", + "Tabs or spaces?", + "What was your first coding project?", + "Which tech trend is overrated right now?", + "Linux or Windows for development?", + "What game got you addicted instantly?", + "What is your dream setup?", + "Which framework do you hate working with?", + "Frontend or backend?", + "What coding mistake taught you the most?", + "What app do you use every single day?", + "What technology will dominate in 10 years?", + "Which programming meme is the most accurate?", + "What is the hardest bug you ever fixed?", + "What was your first PC or laptop?", + "Dark mode or light mode?", + "Which company has the best developer tools?", + "What coding project are you proud of?", + "Which OS looks the cleanest?", + "If you could master one skill instantly what would it be?", + "What keyboard switch sounds the best?", + "Which tech YouTuber do you watch most?", + "What is your favorite open source project?", + "What coding language feels the most satisfying?", + "What is your dream job in tech?", + "What is the worst programming language syntax?", + "What motivates you to keep learning?", + "Which anime has the best story?", + "What game has the best soundtrack?", + "Which editor or IDE do you use daily?", + "What was your biggest coding fail?", + "What technology scared you at first?", + "What is your favorite Linux distro?", + "Which browser do you trust most?", + "AI replacing developers soon or not?", + "Which social media app is declining fastest?", + "What coding project idea should beginners make first?", + "What is your favorite keyboard shortcut?", + "Which programming language should schools teach first?", + "What tech purchase was 100 percent worth it?", + "Which programming language has the best community?", + "What is your favorite command line tool?", + "Which startup idea would you build if money was unlimited?", + "What movie has the best visual effects?", + "Which coding habit improved your skills most?", + "What is your favorite API?", + "Which game has the best graphics?", + "What website design looks the cleanest?", + "What skill will be most valuable in future tech?", + "Which programming language is hardest to learn?", + "What is your favorite terminal theme?", + "Which app has the worst UI?", + "What was your most useful coding resource?", + "Which fictional technology do you want real?", + "What coding language would survive longest?", + "Which company makes the best hardware?", + "What is your favorite productivity trick?", + "What project are you currently building?", + "Which coding project took longest to finish?", + "What is your favorite game genre?", + "Which tech myth annoys you most?", + "What browser extension can you not live without?", + "Which programming language deserves more attention?", + "What was your first experience with Linux?", + "Which coding project idea sounds fun right now?", + "What feature should Discord add next?", + "Which device do you use most daily?", + "What was your first coding language?", + "Which operating system is most underrated?", + "What tech opinion would start an argument instantly?", + "Which programming language has best documentation?", + "What game world would you live in?", + "What coding skill is hardest to master?", + "Which old technology do you still use?", + "What motivates you during difficult projects?", + "Which app wastes most of your time?", + "What is your favorite coding font?", + "Which console generation was best?", + "What is your dream programming project?", + "Which tech company fell off hardest?", + "What was your biggest learning breakthrough?", + "Which Linux command feels most powerful?", + "What coding advice do beginners ignore too much?", + "Which movie predicted technology best?", + "What is your favorite coding snack?", + "Which programming language has coolest logo?", + "What tech skill should everyone learn?", + "Which software has the cleanest UI?", + "What coding project would you restart differently?", + "Which mobile app deserves a desktop version?", + "What futuristic gadget do you want most?", + "Which tech career seems most exciting?", + "What coding workflow works best for you?", + "Which app update ruined the app?", + "What programming concept took longest to understand?", + "Which game deserves a remake?", + "What is your favorite open source alternative?", + "Which website do you visit most often?", + "What coding language has best naming style?", + "Which piece of tech do you regret buying?", + "What project idea sounds impossible but cool?", + "Which software should become open source?", + "What was your favorite school subject?", + "Which programming language feels fastest?", + "What tech trend are you most excited for?", + "Which coding project improved your skills most?", + "What device would you upgrade right now?", + "Which app icon looks best?", + "What is the cleanest programming syntax ever?", + "Which developer tool saved you most time?", + "What technology from movies became real?", + "Which coding project idea should become a startup?", + "What is your favorite Discord server type?", + "Which website has the worst ads?", + "What coding topic should more people learn?", + "Which gadget feels most futuristic?", + "What is your favorite tech wallpaper style?", + "Which programming meme is painfully true?", + "What was your most satisfying coding moment?", + "Which game had the best multiplayer experience?", + ] + topic = random.choice(topics) + + embed = discord.Embed( + title="Discussion Topic", + description=topic, + color=0x3498DB, + timestamp=datetime.now(timezone.utc), + ) + embed.set_footer(text="Use ?topic to get another idea") + await ctx.reply(embed=embed, mention_author=False) + @commands.command( name="singledice", help="Roll a single die (basic). For multi-dice use ?roll" ) From 8dfe1d31400fd297748ca99331320629d94054a5 Mon Sep 17 00:00:00 2001 From: youngcoder45 Date: Wed, 20 May 2026 23:30:43 +0530 Subject: [PATCH 3/3] gif cog --- cogs/fun.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/cogs/fun.py b/cogs/fun.py index 612381b..f33215c 100644 --- a/cogs/fun.py +++ b/cogs/fun.py @@ -431,6 +431,43 @@ async def topic(self, ctx: commands.Context): embed.set_footer(text="Use ?topic to get another idea") await ctx.reply(embed=embed, mention_author=False) + @commands.command(name="gif", help="Send a gif matching a query (prefix-only).") + async def gif(self, ctx: commands.Context, *, query: Optional[str] = None): + """Return a gif URL from a fixed list matching the query or random if none.""" + gifs = [ + "https://tenor.com/view/patrick-spongebob-spongebob-meme-patrick-meme-dumb-patrick-gif-9974665538168463324", + "https://images-ext-2.discordapp.net/external/CXNcMebjeujg_gGvrp1Ymgjg9ei_fTQRybF8rymUh2s/https/cdn.weeb.sh/images/SyFkekYwW.gif", + "https://tenor.com/view/i-ain%E2%80%99t-reading-all-that-happy-for-u-tho-happy-for-you-tho-sorry-that-happened-too-long-didn%E2%80%99t-read-gif-9353839682789985827", + "https://tenor.com/view/tuff-tuff-minion-tuff-minoin-hoverboard-gif-17512699728490497347", + "https://cdn.discordapp.com/attachments/1203665076997849139/1506339325464285264/ragebait.gif", + "https://klipy.com/gifs/breaking-bad-126--k01KRGZC3DEFA1MMB9RF30TE2XX", + ] + + # Simple keyword matching to pick a gif; lower-case query. + if query: + q = query.lower() + # mapping of keywords to gif URLs (first match wins) + mapping = { + "patrick": gifs[0], + "spongebob": gifs[0], + "weeb": gifs[1], + "i ain't reading": gifs[2], + "reading": gifs[2], + "minion": gifs[3], + "tuff": gifs[3], + "rage": gifs[4], + "breaking": gifs[5], + "bad": gifs[5], + } + + for k, url in mapping.items(): + if k in q: + await ctx.send(url) + return + + # No query or no keyword matched: random gif + await ctx.send(random.choice(gifs)) + @commands.command( name="singledice", help="Roll a single die (basic). For multi-dice use ?roll" )