From 996f65fcab2c7ffa59c56a9e570368233ffea3de Mon Sep 17 00:00:00 2001 From: BaileyCodes Date: Wed, 17 Jun 2026 14:29:02 -0500 Subject: [PATCH] fixed, fuully bug free --- bot.py | 145 +++++++++++++++++---- cogs/help.py | 156 ++++++++++------------ database.py | 356 ++++++++++++++++++++++++++++----------------------- 3 files changed, 390 insertions(+), 267 deletions(-) diff --git a/bot.py b/bot.py index 3d6d3db..2f78427 100644 --- a/bot.py +++ b/bot.py @@ -4,6 +4,8 @@ import os from dotenv import load_dotenv from database import Database import logging +import traceback +import sys # OC Bot - Coded by Ball Studios 🎨 # A Discord bot for managing and roleplaying as original characters @@ -11,10 +13,34 @@ import logging # Load environment variables load_dotenv() -# Setup logging -logging.basicConfig(level=logging.INFO) +# Setup logging with more verbosity +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) logger = logging.getLogger(__name__) +# Log startup info +logger.info('=' * 60) +logger.info('Starting OC Bot...') +logger.info(f'Python: {sys.version}') +logger.info(f'discord.py: {discord.__version__}') + +# Verify required environment variables +required_env_vars = ['DISCORD_TOKEN', 'DB_HOST', 'DB_USER', 'DB_PASSWORD', 'DB_NAME'] +missing_vars = [var for var in required_env_vars if not os.getenv(var)] +if missing_vars: + logger.error(f'❌ Missing required environment variables: {", ".join(missing_vars)}') + for var in missing_vars: + logger.error(f' - {var}') + sys.exit(1) + +logger.info('✓ All required environment variables found') +logger.info(f'Database Host: {os.getenv("DB_HOST")}') +logger.info(f'Database Name: {os.getenv("DB_NAME")}') +logger.info(f'Database User: {os.getenv("DB_USER")}') +logger.info('=' * 60) + # Bot setup intents = discord.Intents.default() intents.message_content = True @@ -25,26 +51,53 @@ bot = commands.Bot(command_prefix=',', intents=intents) # Database instance db = None +@bot.command(name='divide') +async def divide_command(ctx): + """Sends a Discord GIF link when you type ,divide""" + gif_url = "https://cdn.discordapp.com/attachments/1478216070660755569/1516884782188069057/togif.gif?ex=6a34448a&is=6a32f30a&hm=5f505a453c242673c212999ed8f2b666bf455678b3762312fa3eda31b761fc73&" + await ctx.send(gif_url) + @bot.event async def on_ready(): global db - logger.info(f'{bot.user} has connected to Discord!') - print('started') + logger.info(f'✓ Discord connection established: {bot.user}') + if db is None: + logger.info('Initializing database connection...') db = Database() - await db.connect() + try: + await db.connect() + bot.db = db # Make database accessible to cogs + logger.info('✓✓✓ DATABASE CONNECTION SUCCESSFUL ✓✓✓') + except Exception as e: + logger.critical(f'❌❌❌ FAILED TO CONNECT TO DATABASE ❌❌❌') + logger.critical(f'Error: {type(e).__name__}: {str(e)}') + logger.critical(f'Full traceback:\n{traceback.format_exc()}') + logger.critical('Bot cannot function without database. Exiting...') + await bot.close() + return + await load_cogs() async def load_cogs(): """Load all command cogs""" cogs_dir = 'cogs' - for filename in os.listdir(cogs_dir): - if filename.endswith('.py') and not filename.startswith('_'): - try: - await bot.load_extension(f'cogs.{filename[:-3]}') - logger.info(f'Loaded cog: {filename}') - except Exception as e: - logger.error(f'Failed to load cog {filename}: {e}') + if not os.path.exists(cogs_dir): + logger.warning(f'Cogs directory not found: {cogs_dir}') + return + + cog_files = [f for f in os.listdir(cogs_dir) if f.endswith('.py') and not f.startswith('_')] + logger.info(f'Found {len(cog_files)} cog(s) to load') + + for filename in cog_files: + try: + cog_name = filename[:-3] + await bot.load_extension(f'cogs.{cog_name}') + logger.info(f'✓ Loaded cog: {cog_name}') + except Exception as e: + logger.error(f'❌ Failed to load cog {filename}:') + logger.error(f' {type(e).__name__}: {str(e)}') + logger.error(f' Traceback:\n{traceback.format_exc()}') @bot.event async def on_message(message): @@ -61,23 +114,34 @@ async def on_message(message): msg_content = parts[1] if len(parts) > 1 else '' # Check if this is a registered command - if trigger in ['oc', 'help', 'ping', 'stats', 'invite', 'support', 'about', 'reload', 'debug', 'admin', 'config']: + # ----- ADDED 'divide' TO THE LIST ----- + if trigger in ['oc', 'help', 'ping', 'stats', 'invite', 'support', 'about', 'reload', 'debug', 'admin', 'config', 'divide']: await bot.process_commands(message) return + # ------------------------------------- # Try to find a character with this trigger - if db: + if db is None: + logger.warning('Database not connected - cannot process character trigger') + return + + try: character = await db.get_character_by_trigger(message.author.id, trigger) if character: + logger.info(f'Triggered character: {character.get("name", "Unknown")} (trigger: {trigger}, user: {message.author.id})') # Delete original message try: await message.delete() - except: - pass + except Exception as e: + logger.warning(f'Could not delete message: {e}') # Create webhook and send message await send_character_message(message.channel, character, msg_content) return + except Exception as e: + logger.error(f'Error processing character trigger "{trigger}":') + logger.error(f' {type(e).__name__}: {str(e)}') + logger.error(f' Traceback:\n{traceback.format_exc()}') # Process other commands await bot.process_commands(message) @@ -85,6 +149,8 @@ async def on_message(message): async def send_character_message(channel, character, content): """Send a message through webhook as a character""" try: + logger.info(f'Sending message as {character.get("name")} to {channel}') + # Get existing webhooks webhooks = await channel.webhooks() webhook = None @@ -97,25 +163,58 @@ async def send_character_message(channel, character, content): # Create webhook if doesn't exist if not webhook: + logger.info(f'Creating webhook for channel {channel}') webhook = await channel.create_webhook(name='OC-Bot') - # Send message through webhook - await webhook.send( + # Get avatar URL safely (fallback to None if not present) + avatar_url = character.get('avatar_url') # can be None + + # Send message through webhook, wait=True ensures we get a Message object + msg = await webhook.send( content=content, username=character['name'], - avatar_url=character['avatar_url'] + avatar_url=avatar_url, + wait=True ) + + # Defensive check: webhook.send() might return None in some edge cases + if msg is None: + logger.error(f'❌ webhook.send() returned None for character {character["name"]}') + logger.error(f' content: {content[:50]}, avatar_url: {avatar_url}') + return + + logger.info(f'✓ Message sent successfully: {msg.id}') + except discord.HTTPException as e: + # Specific HTTP error from Discord API + logger.error(f'❌ HTTP error sending webhook message: {e.status} {e.text}') except Exception as e: - logger.error(f'Failed to send character message: {e}') + logger.error(f'❌ Failed to send character message:') + logger.error(f' Character: {character.get("name", "Unknown")}') + logger.error(f' Channel: {channel}') + logger.error(f' {type(e).__name__}: {str(e)}') + logger.error(f' Traceback:\n{traceback.format_exc()}') @bot.event async def on_command_error(ctx, error): """Error handling for commands""" - logger.error(f'Command error: {error}') + logger.error(f'❌ Command error in {ctx.command}:') + logger.error(f' {type(error).__name__}: {str(error)}') + logger.error(f' Traceback:\n{traceback.format_exc()}') # Run bot if __name__ == '__main__': TOKEN = os.getenv('DISCORD_TOKEN') if not TOKEN: - raise ValueError('DISCORD_TOKEN not found in .env file') - bot.run(TOKEN) + logger.critical('❌ DISCORD_TOKEN not found in .env file') + sys.exit(1) + + logger.info('Starting bot with Discord token...') + try: + bot.run(TOKEN) + except KeyboardInterrupt: + logger.info('Bot interrupted by user') + except Exception as e: + logger.critical(f'❌ Fatal error running bot:') + logger.critical(f' {type(e).__name__}: {str(e)}') + logger.critical(f' Traceback:\n{traceback.format_exc()}') + sys.exit(1) \ No newline at end of file diff --git a/cogs/help.py b/cogs/help.py index 94bb309..1685b6c 100644 --- a/cogs/help.py +++ b/cogs/help.py @@ -1,3 +1,4 @@ +# -*- coding: utf-8 -*- import discord from discord.ext import commands @@ -35,25 +36,25 @@ class Help(commands.Cog): async def show_main_help(self, ctx): """Show main help menu""" embed = discord.Embed( - title='🎭 Original Character Bot', + title='OC Bot - Character Management', description='Manage and roleplay as your original characters!', color=discord.Color.blue() ) embed.add_field( - name='📚 What are OCs?', + name='What are OCs?', value='OCs (Original Characters) are fictional characters you create. This bot lets you manage them and send messages as them!', inline=False ) embed.add_field( - name='🚀 Getting Started', + name='Getting Started', value='`,oc create` - Start creating your first character!\n`,oc list` - View all your characters', inline=False ) embed.add_field( - name='📖 Commands', + name='Commands', value='**Character Management:**\n' '`,oc create` - Create a new character\n' '`,oc list` - List your characters\n' @@ -68,7 +69,7 @@ class Help(commands.Cog): ) embed.add_field( - name='💡 Quick Example', + name='Quick Example', value='1. `,oc create` and follow the wizard\n' '2. Use `,ba Hello everyone` to send as your character\n' '3. `,oc list` to see all your characters', @@ -76,7 +77,7 @@ class Help(commands.Cog): ) embed.add_field( - name='📝 Help Topics', + name='Help Topics', value='`,help create` - Character creation guide\n' '`,help triggers` - How triggers work\n' '`,help oc` - OC command overview\n' @@ -85,7 +86,7 @@ class Help(commands.Cog): inline=False ) - embed.set_footer(text='Coded by Ball Studios 🎨') + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def help_create(self, ctx): @@ -110,9 +111,9 @@ class Help(commands.Cog): embed.add_field( name='Step-by-step', value='1. Run `,oc create`\n' - '2. Enter your character\'s name\n' + '2. Enter your character name\n' '3. Provide an avatar URL (image link)\n' - '4. Choose a unique trigger (e.g., `ba`, `sa`)\n' + '4. Choose a unique trigger (e.g., ba, sa)\n' '5. Add an optional description\n' '6. Confirm!', inline=False @@ -120,31 +121,31 @@ class Help(commands.Cog): embed.add_field( name='Avatar URL', - value='This should be a direct link to an image:\n' - '✅ `https://example.com/avatar.png`\n' - '❌ Discord user profile link\n' - '❌ Local file path', + value='This should be a direct link to an image.\n' + 'OK: https://example.com/avatar.png\n' + 'NOT OK: Discord user profile link\n' + 'NOT OK: Local file path', inline=False ) embed.add_field( name='Trigger', value='A short code (2-5 chars) that activates your character.\n' - 'You\'ll use it like: `,ba Hello!`\n' + 'You will use it like: `,ba Hello!`\n' 'Must be unique to you (others can have `,ba` too)', inline=False ) embed.add_field( name='Example', - value='* Name: **Barny**\n' - '* Avatar: `https://example.com/barny.png`\n' - '* Trigger: `ba`\n' - '* Final command: `,ba Hello everyone!`', + value='Name: Barny\n' + 'Avatar: https://example.com/barny.png\n' + 'Trigger: ba\n' + 'Final command: `,ba Hello everyone!`', inline=False ) - embed.set_footer(text='Coded by Ball Studios 🎨') + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def help_triggers(self, ctx): @@ -162,19 +163,19 @@ class Help(commands.Cog): embed.add_field( name='How to use triggers', - value='If your character has trigger `ba`:\n' - '`,ba Hello!` → Message sent as your character\n' - '`,ba I\'m excited!` → Another message as them', + value='If your character has trigger ba:\n' + '`,ba Hello!` - Message sent as your character\n' + '`,ba I am excited!` - Another message as them', inline=False ) embed.add_field( name='Trigger Rules', - value='✅ 1-10 characters\n' - '✅ Lowercase letters (a-z)\n' - '✅ Numbers (0-9)\n' - '❌ Cannot be reserved words (help, oc, ping, etc.)\n' - '❌ Must be unique to YOU (no duplicates per user)', + value='OK: 1-10 characters\n' + 'OK: Lowercase letters (a-z)\n' + 'OK: Numbers (0-9)\n' + 'NOT OK: Reserved words (help, oc, ping, etc.)\n' + 'NOT OK: Must be unique to YOU (no duplicates per user)', inline=False ) @@ -195,6 +196,7 @@ class Help(commands.Cog): inline=False ) + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def help_oc(self, ctx): @@ -236,10 +238,11 @@ class Help(commands.Cog): embed.add_field( name='`,oc preview `', - value='See how a character\'s message will look', + value='See how a character message will look', inline=False ) + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def help_edit(self, ctx): @@ -257,10 +260,10 @@ class Help(commands.Cog): embed.add_field( name='What can you edit?', - value='• **Name** - Your character\'s name\n' - '• **Avatar** - Their profile picture URL\n' - '• **Trigger** - Their command code\n' - '• **Description** - Background info', + value='- Name: Your character name\n' + '- Avatar: Their profile picture URL\n' + '- Trigger: Their command code\n' + '- Description: Background info', inline=False ) @@ -276,13 +279,13 @@ class Help(commands.Cog): embed.add_field( name='Trigger Change Tips', value='When changing a trigger:\n' - '• Old trigger becomes unusable\n' - '• New trigger must be unique\n' - '• Use new trigger immediately\n' - '• E.g., if `,ba` → `,bn`, use `,bn` now', + '- Old trigger becomes unusable\n' + '- New trigger must be unique\n' + '- You will need to use new command immediately', inline=False ) + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def help_delete(self, ctx): @@ -299,37 +302,28 @@ class Help(commands.Cog): ) embed.add_field( - name='What happens?', - value='1. Bot asks for confirmation\n' - '2. Type `YES` within 30 seconds\n' - '3. Character is permanently deleted\n' - '4. Trigger becomes available again', + name='Warning', + value='This action cannot be undone!', inline=False ) embed.add_field( - name='⚠️ Warning', - value='Deletion is **permanent**! You cannot undo this.\n' - 'Create a backup/description if needed.', - inline=False - ) - - embed.add_field( - name='Example', - value='User: `,oc delete Barny`\n' - 'Bot: Are you sure? Type YES to confirm.\n' - 'User: `YES`\n' - 'Bot: Character Barny deleted.', + name='How to delete', + value='1. `,oc delete Barny`\n' + '2. Bot asks for confirmation\n' + '3. Type YES (within 30 seconds)\n' + '4. Character is permanently deleted', inline=False ) + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def help_list(self, ctx): - """Help for listing characters""" + """Help for list command""" embed = discord.Embed( - title='Listing Your Characters', - color=discord.Color.blue() + title='List Your Characters', + color=discord.Color.blurple() ) embed.add_field( @@ -339,30 +333,27 @@ class Help(commands.Cog): ) embed.add_field( - name='What does it show?', - value='A list of all your characters with their triggers', + name='What it shows', + value='All your characters with their triggers', inline=False ) embed.add_field( - name='Example Output', - value='**Your Characters**\n' - 'Barny\n' - 'Trigger: `,ba`\n\n' - 'Sarah\n' - 'Trigger: `,sa`\n\n' - 'Knight\n' - 'Trigger: `,kn`', + name='Example output', + value='Character 1: Barny (trigger: ba)\n' + 'Character 2: Dragon (trigger: dr)\n' + 'Character 3: Shy (trigger: sh)', inline=False ) + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def help_info(self, ctx): """Help for info command""" embed = discord.Embed( - title='Character Information', - color=discord.Color.blurple() + title='Character Info', + color=discord.Color.dark_blue() ) embed.add_field( @@ -372,30 +363,26 @@ class Help(commands.Cog): ) embed.add_field( - name='What does it show?', - value='Detailed information about a character:\n' - '• Name\n' - '• Trigger code\n' - '• Avatar image\n' - '• Description\n' - '• Creation date\n' - '• Last time used', + name='What it shows', + value='Name, trigger, avatar URL, and description', inline=False ) embed.add_field( name='Example', - value='`,oc info Barny`', + value='`,oc info Barny`\n\n' + 'Shows all details about your Barny character', inline=False ) + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def help_preview(self, ctx): """Help for preview command""" embed = discord.Embed( - title='Character Preview', - color=discord.Color.green() + title='Preview Character', + color=discord.Color.teal() ) embed.add_field( @@ -405,22 +392,19 @@ class Help(commands.Cog): ) embed.add_field( - name='What does it do?', - value='Shows you exactly how your character will appear when sending messages.\n' - 'Useful to check:\n' - '• Avatar displays correctly\n' - '• Name looks good\n' - '• Everything works as expected', + name='What it does', + value='Shows how a character message will appear', inline=False ) embed.add_field( name='Example', value='`,oc preview Barny`\n\n' - 'Bot sends a sample message with Barny\'s avatar and name', + 'Bot sends a sample message with Barny avatar and name', inline=False ) + embed.set_footer(text='Coded by Ball Studios') await ctx.send(embed=embed) async def setup(bot): diff --git a/database.py b/database.py index 2325e68..09a66a9 100644 --- a/database.py +++ b/database.py @@ -1,9 +1,9 @@ -import mysql.connector -from mysql.connector import Error +import aiomysql import os from dotenv import load_dotenv import logging from datetime import datetime +import traceback load_dotenv() logger = logging.getLogger(__name__) @@ -11,63 +11,77 @@ logger = logging.getLogger(__name__) class Database: def __init__(self): self.connection = None - self.cursor = None self.trigger_cache = {} # Cache for (user_id, trigger) -> character_id lookups async def connect(self): """Connect to MariaDB""" try: - self.connection = mysql.connector.connect( - host=os.getenv('DB_HOST', 'localhost'), - user=os.getenv('DB_USER', 'root'), - password=os.getenv('DB_PASSWORD', ''), - database=os.getenv('DB_NAME', 'rp_bot') + db_host = os.getenv('DB_HOST', 'localhost') + db_user = os.getenv('DB_USER', 'root') + db_password = os.getenv('DB_PASSWORD', '') + db_name = os.getenv('DB_NAME', 'rp_bot') + + logger.info(f'Attempting to connect to MariaDB at {db_host}:{db_name} as {db_user}') + + self.connection = await aiomysql.create_pool( + host=db_host, + user=db_user, + password=db_password, + db=db_name, + minsize=1, + maxsize=10, + autocommit=True ) - self.cursor = self.connection.cursor(dictionary=True) - logger.info('Connected to MariaDB') + logger.info('✓ Successfully connected to MariaDB') await self.initialize_tables() - except Error as e: - logger.error(f'Database connection error: {e}') + except Exception as e: + error_msg = f'Database connection failed: {type(e).__name__}: {str(e)}' + logger.error(error_msg) + logger.error(f'Full traceback:\n{traceback.format_exc()}') + logger.error(f'Connection details - Host: {os.getenv("DB_HOST")}, User: {os.getenv("DB_USER")}, DB: {os.getenv("DB_NAME")}') raise async def initialize_tables(self): """Create necessary tables if they don't exist""" try: - # Users table - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS users ( - user_id BIGINT PRIMARY KEY, - username VARCHAR(255), - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP - ) - ''') - - # Characters table - self.cursor.execute(''' - CREATE TABLE IF NOT EXISTS `characters` ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id BIGINT NOT NULL, - name VARCHAR(255) NOT NULL, - `trigger` VARCHAR(100) NOT NULL, - avatar_url TEXT, - description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - last_used TIMESTAMP NULL, - FOREIGN KEY (user_id) REFERENCES users(user_id), - UNIQUE KEY unique_user_trigger (user_id, `trigger`) - ) - ''') - - # Index for fast trigger lookups - self.cursor.execute(''' - CREATE INDEX IF NOT EXISTS idx_user_trigger - ON `characters`(user_id, `trigger`) - ''') - - self.connection.commit() - logger.info('Database tables initialized') - except Error as e: - logger.error(f'Table initialization error: {e}') + async with self.connection.acquire() as conn: + async with conn.cursor() as cursor: + # Users table + await cursor.execute(''' + CREATE TABLE IF NOT EXISTS users ( + user_id BIGINT PRIMARY KEY, + username VARCHAR(255), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + + # Characters table + await cursor.execute(''' + CREATE TABLE IF NOT EXISTS `characters` ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, + name VARCHAR(255) NOT NULL, + `trigger` VARCHAR(100) NOT NULL, + avatar_url TEXT, + description TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_used TIMESTAMP NULL, + FOREIGN KEY (user_id) REFERENCES users(user_id), + UNIQUE KEY unique_user_trigger (user_id, `trigger`) + ) + ''') + + # Index for fast trigger lookups + await cursor.execute(''' + CREATE INDEX IF NOT EXISTS idx_user_trigger + ON `characters`(user_id, `trigger`) + ''') + + logger.info('✓ Database tables initialized successfully') + except Exception as e: + logger.error(f'Table initialization error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') + raise async def get_character_by_trigger(self, user_id, trigger): """Get character by user_id and trigger (O(1) lookup with caching)""" @@ -79,68 +93,80 @@ class Database: return await self.get_character_by_id(char_id) try: - self.cursor.execute( - 'SELECT * FROM `characters` WHERE user_id = %s AND `trigger` = %s', - (user_id, trigger) - ) - result = self.cursor.fetchone() - - if result: - # Cache the result - self.trigger_cache[cache_key] = result['id'] - - return result - except Error as e: - logger.error(f'Get character error: {e}') + async with self.connection.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + 'SELECT * FROM `characters` WHERE user_id = %s AND `trigger` = %s', + (user_id, trigger) + ) + result = await cursor.fetchone() + + if result: + # Cache the result + self.trigger_cache[cache_key] = result['id'] + + return result + except Exception as e: + logger.error(f'Get character error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') return None async def get_character_by_id(self, char_id): """Get character by ID""" try: - self.cursor.execute('SELECT * FROM `characters` WHERE id = %s', (char_id,)) - return self.cursor.fetchone() - except Error as e: - logger.error(f'Get character by ID error: {e}') + async with self.connection.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute('SELECT * FROM `characters` WHERE id = %s', (char_id,)) + return await cursor.fetchone() + except Exception as e: + logger.error(f'Get character by ID error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') return None async def create_character(self, user_id, name, trigger, avatar_url, description=''): """Create a new character""" try: - # Ensure user exists - self.cursor.execute( - 'INSERT IGNORE INTO users (user_id) VALUES (%s)', - (user_id,) - ) - - # Insert character - self.cursor.execute( - '''INSERT INTO `characters` - (user_id, name, `trigger`, avatar_url, description) - VALUES (%s, %s, %s, %s, %s)''', - (user_id, name, trigger, avatar_url, description) - ) - self.connection.commit() - - # Clear cache for this user's trigger - cache_key = (user_id, trigger) - if cache_key in self.trigger_cache: - del self.trigger_cache[cache_key] - - return self.cursor.lastrowid - except Error as e: - logger.error(f'Create character error: {e}') + async with self.connection.acquire() as conn: + async with conn.cursor() as cursor: + # Ensure user exists + await cursor.execute( + 'INSERT IGNORE INTO users (user_id) VALUES (%s)', + (user_id,) + ) + + # Insert character + await cursor.execute( + '''INSERT INTO `characters` + (user_id, name, `trigger`, avatar_url, description) + VALUES (%s, %s, %s, %s, %s)''', + (user_id, name, trigger, avatar_url, description) + ) + + # Clear cache for this user's trigger + cache_key = (user_id, trigger) + if cache_key in self.trigger_cache: + del self.trigger_cache[cache_key] + + logger.info(f'Created character "{name}" with trigger "{trigger}" for user {user_id}') + return cursor.lastrowid + except Exception as e: + logger.error(f'Create character error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') return None async def get_user_characters(self, user_id): """Get all characters for a user""" try: - self.cursor.execute( - 'SELECT * FROM `characters` WHERE user_id = %s ORDER BY created_at DESC', - (user_id,) - ) - return self.cursor.fetchall() - except Error as e: - logger.error(f'Get user characters error: {e}') + async with self.connection.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + 'SELECT * FROM `characters` WHERE user_id = %s ORDER BY created_at DESC', + (user_id,) + ) + return await cursor.fetchall() + except Exception as e: + logger.error(f'Get user characters error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') return [] async def update_character(self, char_id, **kwargs): @@ -152,86 +178,100 @@ class Database: if not updates: return False - # Get old trigger for cache clearing - self.cursor.execute('SELECT user_id, `trigger` FROM `characters` WHERE id = %s', (char_id,)) - old_char = self.cursor.fetchone() - - set_clause = ', '.join([f'`{k}` = %s' if k == 'trigger' else f'{k} = %s' for k in updates.keys()]) - values = list(updates.values()) + [char_id] - - self.cursor.execute( - f'UPDATE `characters` SET {set_clause} WHERE id = %s', - values - ) - self.connection.commit() - - # Clear cache - if old_char: - old_cache_key = (old_char['user_id'], old_char['trigger']) - if old_cache_key in self.trigger_cache: - del self.trigger_cache[old_cache_key] - - # If trigger changed, clear new trigger cache too - if 'trigger' in updates: - new_cache_key = (old_char['user_id'], updates['trigger']) - if new_cache_key in self.trigger_cache: - del self.trigger_cache[new_cache_key] - - return True - except Error as e: - logger.error(f'Update character error: {e}') + async with self.connection.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + # Get old trigger for cache clearing + await cursor.execute('SELECT user_id, `trigger` FROM `characters` WHERE id = %s', (char_id,)) + old_char = await cursor.fetchone() + + set_clause = ', '.join([f'`{k}` = %s' if k == 'trigger' else f'{k} = %s' for k in updates.keys()]) + values = list(updates.values()) + [char_id] + + await cursor.execute( + f'UPDATE `characters` SET {set_clause} WHERE id = %s', + values + ) + + # Clear cache + if old_char: + old_cache_key = (old_char['user_id'], old_char['trigger']) + if old_cache_key in self.trigger_cache: + del self.trigger_cache[old_cache_key] + + # If trigger changed, clear new trigger cache too + if 'trigger' in updates: + new_cache_key = (old_char['user_id'], updates['trigger']) + if new_cache_key in self.trigger_cache: + del self.trigger_cache[new_cache_key] + + logger.info(f'Updated character {char_id}: {updates}') + return True + except Exception as e: + logger.error(f'Update character error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') return False async def delete_character(self, char_id): """Delete a character""" try: - # Get character info for cache clearing - self.cursor.execute('SELECT user_id, `trigger` FROM `characters` WHERE id = %s', (char_id,)) - char = self.cursor.fetchone() - - self.cursor.execute('DELETE FROM `characters` WHERE id = %s', (char_id,)) - self.connection.commit() - - # Clear cache - if char: - cache_key = (char['user_id'], char['trigger']) - if cache_key in self.trigger_cache: - del self.trigger_cache[cache_key] - - return True - except Error as e: - logger.error(f'Delete character error: {e}') + async with self.connection.acquire() as conn: + async with conn.cursor(aiomysql.DictCursor) as cursor: + # Get character info for cache clearing + await cursor.execute('SELECT user_id, `trigger` FROM `characters` WHERE id = %s', (char_id,)) + char = await cursor.fetchone() + + await cursor.execute('DELETE FROM `characters` WHERE id = %s', (char_id,)) + + # Clear cache + if char: + cache_key = (char['user_id'], char['trigger']) + if cache_key in self.trigger_cache: + del self.trigger_cache[cache_key] + + logger.info(f'Deleted character {char_id}') + return True + except Exception as e: + logger.error(f'Delete character error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') return False async def update_last_used(self, char_id): """Update last_used timestamp for a character""" try: - self.cursor.execute( - 'UPDATE `characters` SET last_used = NOW() WHERE id = %s', - (char_id,) - ) - self.connection.commit() + async with self.connection.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + 'UPDATE `characters` SET last_used = NOW() WHERE id = %s', + (char_id,) + ) return True - except Error as e: - logger.error(f'Update last used error: {e}') + except Exception as e: + logger.error(f'Update last used error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') return False async def is_trigger_available(self, user_id, trigger): """Check if trigger is available for a user""" try: - self.cursor.execute( - 'SELECT id FROM `characters` WHERE user_id = %s AND `trigger` = %s', - (user_id, trigger) - ) - return self.cursor.fetchone() is None - except Error as e: - logger.error(f'Check trigger availability error: {e}') + async with self.connection.acquire() as conn: + async with conn.cursor() as cursor: + await cursor.execute( + 'SELECT id FROM `characters` WHERE user_id = %s AND `trigger` = %s', + (user_id, trigger) + ) + return await cursor.fetchone() is None + except Exception as e: + logger.error(f'Check trigger availability error: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}') return False - def close(self): + async def close(self): """Close database connection""" - if self.cursor: - self.cursor.close() - if self.connection: - self.connection.close() - logger.info('Database connection closed') + try: + if self.connection: + self.connection.close() + await self.connection.wait_closed() + logger.info('✓ Database connection closed') + except Exception as e: + logger.error(f'Error closing database: {type(e).__name__}: {str(e)}') + logger.error(f'Traceback:\n{traceback.format_exc()}')