fixed, fuully bug free

This commit is contained in:
2026-06-17 14:29:02 -05:00
parent c84acc4564
commit 996f65fcab
3 changed files with 390 additions and 267 deletions

137
bot.py
View File

@@ -4,6 +4,8 @@ import os
from dotenv import load_dotenv from dotenv import load_dotenv
from database import Database from database import Database
import logging import logging
import traceback
import sys
# OC Bot - Coded by Ball Studios 🎨 # OC Bot - Coded by Ball Studios 🎨
# A Discord bot for managing and roleplaying as original characters # A Discord bot for managing and roleplaying as original characters
@@ -11,10 +13,34 @@ import logging
# Load environment variables # Load environment variables
load_dotenv() load_dotenv()
# Setup logging # Setup logging with more verbosity
logging.basicConfig(level=logging.INFO) logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__) 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 # Bot setup
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True intents.message_content = True
@@ -25,26 +51,53 @@ bot = commands.Bot(command_prefix=',', intents=intents)
# Database instance # Database instance
db = None 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 @bot.event
async def on_ready(): async def on_ready():
global db global db
logger.info(f'{bot.user} has connected to Discord!') logger.info(f'✓ Discord connection established: {bot.user}')
print('started')
if db is None: if db is None:
logger.info('Initializing database connection...')
db = Database() db = Database()
try:
await db.connect() 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() await load_cogs()
async def load_cogs(): async def load_cogs():
"""Load all command cogs""" """Load all command cogs"""
cogs_dir = 'cogs' cogs_dir = 'cogs'
for filename in os.listdir(cogs_dir): if not os.path.exists(cogs_dir):
if filename.endswith('.py') and not filename.startswith('_'): 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: try:
await bot.load_extension(f'cogs.{filename[:-3]}') cog_name = filename[:-3]
logger.info(f'Loaded cog: {filename}') await bot.load_extension(f'cogs.{cog_name}')
logger.info(f'✓ Loaded cog: {cog_name}')
except Exception as e: except Exception as e:
logger.error(f'Failed to load cog {filename}: {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 @bot.event
async def on_message(message): async def on_message(message):
@@ -61,23 +114,34 @@ async def on_message(message):
msg_content = parts[1] if len(parts) > 1 else '' msg_content = parts[1] if len(parts) > 1 else ''
# Check if this is a registered command # 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) await bot.process_commands(message)
return return
# -------------------------------------
# Try to find a character with this trigger # 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) character = await db.get_character_by_trigger(message.author.id, trigger)
if character: if character:
logger.info(f'Triggered character: {character.get("name", "Unknown")} (trigger: {trigger}, user: {message.author.id})')
# Delete original message # Delete original message
try: try:
await message.delete() await message.delete()
except: except Exception as e:
pass logger.warning(f'Could not delete message: {e}')
# Create webhook and send message # Create webhook and send message
await send_character_message(message.channel, character, msg_content) await send_character_message(message.channel, character, msg_content)
return 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 # Process other commands
await bot.process_commands(message) await bot.process_commands(message)
@@ -85,6 +149,8 @@ async def on_message(message):
async def send_character_message(channel, character, content): async def send_character_message(channel, character, content):
"""Send a message through webhook as a character""" """Send a message through webhook as a character"""
try: try:
logger.info(f'Sending message as {character.get("name")} to {channel}')
# Get existing webhooks # Get existing webhooks
webhooks = await channel.webhooks() webhooks = await channel.webhooks()
webhook = None webhook = None
@@ -97,25 +163,58 @@ async def send_character_message(channel, character, content):
# Create webhook if doesn't exist # Create webhook if doesn't exist
if not webhook: if not webhook:
logger.info(f'Creating webhook for channel {channel}')
webhook = await channel.create_webhook(name='OC-Bot') webhook = await channel.create_webhook(name='OC-Bot')
# Send message through webhook # Get avatar URL safely (fallback to None if not present)
await webhook.send( 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, content=content,
username=character['name'], 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: 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 @bot.event
async def on_command_error(ctx, error): async def on_command_error(ctx, error):
"""Error handling for commands""" """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 # Run bot
if __name__ == '__main__': if __name__ == '__main__':
TOKEN = os.getenv('DISCORD_TOKEN') TOKEN = os.getenv('DISCORD_TOKEN')
if not TOKEN: if not TOKEN:
raise ValueError('DISCORD_TOKEN not found in .env file') logger.critical('DISCORD_TOKEN not found in .env file')
sys.exit(1)
logger.info('Starting bot with Discord token...')
try:
bot.run(TOKEN) 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)

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
import discord import discord
from discord.ext import commands from discord.ext import commands
@@ -35,25 +36,25 @@ class Help(commands.Cog):
async def show_main_help(self, ctx): async def show_main_help(self, ctx):
"""Show main help menu""" """Show main help menu"""
embed = discord.Embed( embed = discord.Embed(
title='🎭 Original Character Bot', title='OC Bot - Character Management',
description='Manage and roleplay as your original characters!', description='Manage and roleplay as your original characters!',
color=discord.Color.blue() color=discord.Color.blue()
) )
embed.add_field( 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!', value='OCs (Original Characters) are fictional characters you create. This bot lets you manage them and send messages as them!',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='🚀 Getting Started', name='Getting Started',
value='`,oc create` - Start creating your first character!\n`,oc list` - View all your characters', value='`,oc create` - Start creating your first character!\n`,oc list` - View all your characters',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='📖 Commands', name='Commands',
value='**Character Management:**\n' value='**Character Management:**\n'
'`,oc create` - Create a new character\n' '`,oc create` - Create a new character\n'
'`,oc list` - List your characters\n' '`,oc list` - List your characters\n'
@@ -68,7 +69,7 @@ class Help(commands.Cog):
) )
embed.add_field( embed.add_field(
name='💡 Quick Example', name='Quick Example',
value='1. `,oc create` and follow the wizard\n' value='1. `,oc create` and follow the wizard\n'
'2. Use `,ba Hello everyone` to send as your character\n' '2. Use `,ba Hello everyone` to send as your character\n'
'3. `,oc list` to see all your characters', '3. `,oc list` to see all your characters',
@@ -76,7 +77,7 @@ class Help(commands.Cog):
) )
embed.add_field( embed.add_field(
name='📝 Help Topics', name='Help Topics',
value='`,help create` - Character creation guide\n' value='`,help create` - Character creation guide\n'
'`,help triggers` - How triggers work\n' '`,help triggers` - How triggers work\n'
'`,help oc` - OC command overview\n' '`,help oc` - OC command overview\n'
@@ -85,7 +86,7 @@ class Help(commands.Cog):
inline=False inline=False
) )
embed.set_footer(text='Coded by Ball Studios 🎨') embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def help_create(self, ctx): async def help_create(self, ctx):
@@ -110,9 +111,9 @@ class Help(commands.Cog):
embed.add_field( embed.add_field(
name='Step-by-step', name='Step-by-step',
value='1. Run `,oc create`\n' 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' '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' '5. Add an optional description\n'
'6. Confirm!', '6. Confirm!',
inline=False inline=False
@@ -120,31 +121,31 @@ class Help(commands.Cog):
embed.add_field( embed.add_field(
name='Avatar URL', name='Avatar URL',
value='This should be a direct link to an image:\n' value='This should be a direct link to an image.\n'
'✅ `https://example.com/avatar.png`\n' 'OK: https://example.com/avatar.png\n'
' Discord user profile link\n' 'NOT OK: Discord user profile link\n'
' Local file path', 'NOT OK: Local file path',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='Trigger', name='Trigger',
value='A short code (2-5 chars) that activates your character.\n' 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)', 'Must be unique to you (others can have `,ba` too)',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='Example', name='Example',
value='* Name: **Barny**\n' value='Name: Barny\n'
'* Avatar: `https://example.com/barny.png`\n' 'Avatar: https://example.com/barny.png\n'
'* Trigger: `ba`\n' 'Trigger: ba\n'
'* Final command: `,ba Hello everyone!`', 'Final command: `,ba Hello everyone!`',
inline=False inline=False
) )
embed.set_footer(text='Coded by Ball Studios 🎨') embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def help_triggers(self, ctx): async def help_triggers(self, ctx):
@@ -162,19 +163,19 @@ class Help(commands.Cog):
embed.add_field( embed.add_field(
name='How to use triggers', name='How to use triggers',
value='If your character has trigger `ba`:\n' value='If your character has trigger ba:\n'
'`,ba Hello!` Message sent as your character\n' '`,ba Hello!` - Message sent as your character\n'
'`,ba I\'m excited!` Another message as them', '`,ba I am excited!` - Another message as them',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='Trigger Rules', name='Trigger Rules',
value=' 1-10 characters\n' value='OK: 1-10 characters\n'
' Lowercase letters (a-z)\n' 'OK: Lowercase letters (a-z)\n'
' Numbers (0-9)\n' 'OK: Numbers (0-9)\n'
'❌ Cannot be reserved words (help, oc, ping, etc.)\n' 'NOT OK: Reserved words (help, oc, ping, etc.)\n'
' Must be unique to YOU (no duplicates per user)', 'NOT OK: Must be unique to YOU (no duplicates per user)',
inline=False inline=False
) )
@@ -195,6 +196,7 @@ class Help(commands.Cog):
inline=False inline=False
) )
embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def help_oc(self, ctx): async def help_oc(self, ctx):
@@ -236,10 +238,11 @@ class Help(commands.Cog):
embed.add_field( embed.add_field(
name='`,oc preview <name>`', name='`,oc preview <name>`',
value='See how a character\'s message will look', value='See how a character message will look',
inline=False inline=False
) )
embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def help_edit(self, ctx): async def help_edit(self, ctx):
@@ -257,10 +260,10 @@ class Help(commands.Cog):
embed.add_field( embed.add_field(
name='What can you edit?', name='What can you edit?',
value='• **Name** - Your character\'s name\n' value='- Name: Your character name\n'
'• **Avatar** - Their profile picture URL\n' '- Avatar: Their profile picture URL\n'
'• **Trigger** - Their command code\n' '- Trigger: Their command code\n'
'• **Description** - Background info', '- Description: Background info',
inline=False inline=False
) )
@@ -276,13 +279,13 @@ class Help(commands.Cog):
embed.add_field( embed.add_field(
name='Trigger Change Tips', name='Trigger Change Tips',
value='When changing a trigger:\n' value='When changing a trigger:\n'
' Old trigger becomes unusable\n' '- Old trigger becomes unusable\n'
' New trigger must be unique\n' '- New trigger must be unique\n'
'• Use new trigger immediately\n' '- You will need to use new command immediately',
'• E.g., if `,ba` → `,bn`, use `,bn` now',
inline=False inline=False
) )
embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def help_delete(self, ctx): async def help_delete(self, ctx):
@@ -299,37 +302,28 @@ class Help(commands.Cog):
) )
embed.add_field( embed.add_field(
name='What happens?', name='Warning',
value='1. Bot asks for confirmation\n' value='This action cannot be undone!',
'2. Type `YES` within 30 seconds\n'
'3. Character is permanently deleted\n'
'4. Trigger becomes available again',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='⚠️ Warning', name='How to delete',
value='Deletion is **permanent**! You cannot undo this.\n' value='1. `,oc delete Barny`\n'
'Create a backup/description if needed.', '2. Bot asks for confirmation\n'
inline=False '3. Type YES (within 30 seconds)\n'
) '4. Character is permanently deleted',
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.',
inline=False inline=False
) )
embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def help_list(self, ctx): async def help_list(self, ctx):
"""Help for listing characters""" """Help for list command"""
embed = discord.Embed( embed = discord.Embed(
title='Listing Your Characters', title='List Your Characters',
color=discord.Color.blue() color=discord.Color.blurple()
) )
embed.add_field( embed.add_field(
@@ -339,30 +333,27 @@ class Help(commands.Cog):
) )
embed.add_field( embed.add_field(
name='What does it show?', name='What it shows',
value='A list of all your characters with their triggers', value='All your characters with their triggers',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='Example Output', name='Example output',
value='**Your Characters**\n' value='Character 1: Barny (trigger: ba)\n'
'Barny\n' 'Character 2: Dragon (trigger: dr)\n'
'Trigger: `,ba`\n\n' 'Character 3: Shy (trigger: sh)',
'Sarah\n'
'Trigger: `,sa`\n\n'
'Knight\n'
'Trigger: `,kn`',
inline=False inline=False
) )
embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def help_info(self, ctx): async def help_info(self, ctx):
"""Help for info command""" """Help for info command"""
embed = discord.Embed( embed = discord.Embed(
title='Character Information', title='Character Info',
color=discord.Color.blurple() color=discord.Color.dark_blue()
) )
embed.add_field( embed.add_field(
@@ -372,30 +363,26 @@ class Help(commands.Cog):
) )
embed.add_field( embed.add_field(
name='What does it show?', name='What it shows',
value='Detailed information about a character:\n' value='Name, trigger, avatar URL, and description',
'• Name\n'
'• Trigger code\n'
'• Avatar image\n'
'• Description\n'
'• Creation date\n'
'• Last time used',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='Example', name='Example',
value='`,oc info Barny`', value='`,oc info Barny`\n\n'
'Shows all details about your Barny character',
inline=False inline=False
) )
embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def help_preview(self, ctx): async def help_preview(self, ctx):
"""Help for preview command""" """Help for preview command"""
embed = discord.Embed( embed = discord.Embed(
title='Character Preview', title='Preview Character',
color=discord.Color.green() color=discord.Color.teal()
) )
embed.add_field( embed.add_field(
@@ -405,22 +392,19 @@ class Help(commands.Cog):
) )
embed.add_field( embed.add_field(
name='What does it do?', name='What it does',
value='Shows you exactly how your character will appear when sending messages.\n' value='Shows how a character message will appear',
'Useful to check:\n'
'• Avatar displays correctly\n'
'• Name looks good\n'
'• Everything works as expected',
inline=False inline=False
) )
embed.add_field( embed.add_field(
name='Example', name='Example',
value='`,oc preview Barny`\n\n' 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 inline=False
) )
embed.set_footer(text='Coded by Ball Studios')
await ctx.send(embed=embed) await ctx.send(embed=embed)
async def setup(bot): async def setup(bot):

View File

@@ -1,9 +1,9 @@
import mysql.connector import aiomysql
from mysql.connector import Error
import os import os
from dotenv import load_dotenv from dotenv import load_dotenv
import logging import logging
from datetime import datetime from datetime import datetime
import traceback
load_dotenv() load_dotenv()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -11,30 +11,43 @@ logger = logging.getLogger(__name__)
class Database: class Database:
def __init__(self): def __init__(self):
self.connection = None self.connection = None
self.cursor = None
self.trigger_cache = {} # Cache for (user_id, trigger) -> character_id lookups self.trigger_cache = {} # Cache for (user_id, trigger) -> character_id lookups
async def connect(self): async def connect(self):
"""Connect to MariaDB""" """Connect to MariaDB"""
try: try:
self.connection = mysql.connector.connect( db_host = os.getenv('DB_HOST', 'localhost')
host=os.getenv('DB_HOST', 'localhost'), db_user = os.getenv('DB_USER', 'root')
user=os.getenv('DB_USER', 'root'), db_password = os.getenv('DB_PASSWORD', '')
password=os.getenv('DB_PASSWORD', ''), db_name = os.getenv('DB_NAME', 'rp_bot')
database=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('✓ Successfully connected to MariaDB')
logger.info('Connected to MariaDB')
await self.initialize_tables() await self.initialize_tables()
except Error as e: except Exception as e:
logger.error(f'Database connection error: {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 raise
async def initialize_tables(self): async def initialize_tables(self):
"""Create necessary tables if they don't exist""" """Create necessary tables if they don't exist"""
try: try:
async with self.connection.acquire() as conn:
async with conn.cursor() as cursor:
# Users table # Users table
self.cursor.execute(''' await cursor.execute('''
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
user_id BIGINT PRIMARY KEY, user_id BIGINT PRIMARY KEY,
username VARCHAR(255), username VARCHAR(255),
@@ -43,7 +56,7 @@ class Database:
''') ''')
# Characters table # Characters table
self.cursor.execute(''' await cursor.execute('''
CREATE TABLE IF NOT EXISTS `characters` ( CREATE TABLE IF NOT EXISTS `characters` (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
@@ -59,15 +72,16 @@ class Database:
''') ''')
# Index for fast trigger lookups # Index for fast trigger lookups
self.cursor.execute(''' await cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_user_trigger CREATE INDEX IF NOT EXISTS idx_user_trigger
ON `characters`(user_id, `trigger`) ON `characters`(user_id, `trigger`)
''') ''')
self.connection.commit() logger.info('✓ Database tables initialized successfully')
logger.info('Database tables initialized') except Exception as e:
except Error as e: logger.error(f'Table initialization error: {type(e).__name__}: {str(e)}')
logger.error(f'Table initialization error: {e}') logger.error(f'Traceback:\n{traceback.format_exc()}')
raise
async def get_character_by_trigger(self, user_id, trigger): async def get_character_by_trigger(self, user_id, trigger):
"""Get character by user_id and trigger (O(1) lookup with caching)""" """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) return await self.get_character_by_id(char_id)
try: try:
self.cursor.execute( 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', 'SELECT * FROM `characters` WHERE user_id = %s AND `trigger` = %s',
(user_id, trigger) (user_id, trigger)
) )
result = self.cursor.fetchone() result = await cursor.fetchone()
if result: if result:
# Cache the result # Cache the result
self.trigger_cache[cache_key] = result['id'] self.trigger_cache[cache_key] = result['id']
return result return result
except Error as e: except Exception as e:
logger.error(f'Get character error: {e}') logger.error(f'Get character error: {type(e).__name__}: {str(e)}')
logger.error(f'Traceback:\n{traceback.format_exc()}')
return None return None
async def get_character_by_id(self, char_id): async def get_character_by_id(self, char_id):
"""Get character by ID""" """Get character by ID"""
try: try:
self.cursor.execute('SELECT * FROM `characters` WHERE id = %s', (char_id,)) async with self.connection.acquire() as conn:
return self.cursor.fetchone() async with conn.cursor(aiomysql.DictCursor) as cursor:
except Error as e: await cursor.execute('SELECT * FROM `characters` WHERE id = %s', (char_id,))
logger.error(f'Get character by ID error: {e}') 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 return None
async def create_character(self, user_id, name, trigger, avatar_url, description=''): async def create_character(self, user_id, name, trigger, avatar_url, description=''):
"""Create a new character""" """Create a new character"""
try: try:
async with self.connection.acquire() as conn:
async with conn.cursor() as cursor:
# Ensure user exists # Ensure user exists
self.cursor.execute( await cursor.execute(
'INSERT IGNORE INTO users (user_id) VALUES (%s)', 'INSERT IGNORE INTO users (user_id) VALUES (%s)',
(user_id,) (user_id,)
) )
# Insert character # Insert character
self.cursor.execute( await cursor.execute(
'''INSERT INTO `characters` '''INSERT INTO `characters`
(user_id, name, `trigger`, avatar_url, description) (user_id, name, `trigger`, avatar_url, description)
VALUES (%s, %s, %s, %s, %s)''', VALUES (%s, %s, %s, %s, %s)''',
(user_id, name, trigger, avatar_url, description) (user_id, name, trigger, avatar_url, description)
) )
self.connection.commit()
# Clear cache for this user's trigger # Clear cache for this user's trigger
cache_key = (user_id, trigger) cache_key = (user_id, trigger)
if cache_key in self.trigger_cache: if cache_key in self.trigger_cache:
del self.trigger_cache[cache_key] del self.trigger_cache[cache_key]
return self.cursor.lastrowid logger.info(f'Created character "{name}" with trigger "{trigger}" for user {user_id}')
except Error as e: return cursor.lastrowid
logger.error(f'Create character error: {e}') 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 return None
async def get_user_characters(self, user_id): async def get_user_characters(self, user_id):
"""Get all characters for a user""" """Get all characters for a user"""
try: try:
self.cursor.execute( 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', 'SELECT * FROM `characters` WHERE user_id = %s ORDER BY created_at DESC',
(user_id,) (user_id,)
) )
return self.cursor.fetchall() return await cursor.fetchall()
except Error as e: except Exception as e:
logger.error(f'Get user characters error: {e}') logger.error(f'Get user characters error: {type(e).__name__}: {str(e)}')
logger.error(f'Traceback:\n{traceback.format_exc()}')
return [] return []
async def update_character(self, char_id, **kwargs): async def update_character(self, char_id, **kwargs):
@@ -152,18 +178,19 @@ class Database:
if not updates: if not updates:
return False return False
async with self.connection.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# Get old trigger for cache clearing # Get old trigger for cache clearing
self.cursor.execute('SELECT user_id, `trigger` FROM `characters` WHERE id = %s', (char_id,)) await cursor.execute('SELECT user_id, `trigger` FROM `characters` WHERE id = %s', (char_id,))
old_char = self.cursor.fetchone() old_char = await cursor.fetchone()
set_clause = ', '.join([f'`{k}` = %s' if k == 'trigger' else f'{k} = %s' for k in updates.keys()]) set_clause = ', '.join([f'`{k}` = %s' if k == 'trigger' else f'{k} = %s' for k in updates.keys()])
values = list(updates.values()) + [char_id] values = list(updates.values()) + [char_id]
self.cursor.execute( await cursor.execute(
f'UPDATE `characters` SET {set_clause} WHERE id = %s', f'UPDATE `characters` SET {set_clause} WHERE id = %s',
values values
) )
self.connection.commit()
# Clear cache # Clear cache
if old_char: if old_char:
@@ -177,20 +204,23 @@ class Database:
if new_cache_key in self.trigger_cache: if new_cache_key in self.trigger_cache:
del self.trigger_cache[new_cache_key] del self.trigger_cache[new_cache_key]
logger.info(f'Updated character {char_id}: {updates}')
return True return True
except Error as e: except Exception as e:
logger.error(f'Update character error: {e}') logger.error(f'Update character error: {type(e).__name__}: {str(e)}')
logger.error(f'Traceback:\n{traceback.format_exc()}')
return False return False
async def delete_character(self, char_id): async def delete_character(self, char_id):
"""Delete a character""" """Delete a character"""
try: try:
async with self.connection.acquire() as conn:
async with conn.cursor(aiomysql.DictCursor) as cursor:
# Get character info for cache clearing # Get character info for cache clearing
self.cursor.execute('SELECT user_id, `trigger` FROM `characters` WHERE id = %s', (char_id,)) await cursor.execute('SELECT user_id, `trigger` FROM `characters` WHERE id = %s', (char_id,))
char = self.cursor.fetchone() char = await cursor.fetchone()
self.cursor.execute('DELETE FROM `characters` WHERE id = %s', (char_id,)) await cursor.execute('DELETE FROM `characters` WHERE id = %s', (char_id,))
self.connection.commit()
# Clear cache # Clear cache
if char: if char:
@@ -198,40 +228,50 @@ class Database:
if cache_key in self.trigger_cache: if cache_key in self.trigger_cache:
del self.trigger_cache[cache_key] del self.trigger_cache[cache_key]
logger.info(f'Deleted character {char_id}')
return True return True
except Error as e: except Exception as e:
logger.error(f'Delete character error: {e}') logger.error(f'Delete character error: {type(e).__name__}: {str(e)}')
logger.error(f'Traceback:\n{traceback.format_exc()}')
return False return False
async def update_last_used(self, char_id): async def update_last_used(self, char_id):
"""Update last_used timestamp for a character""" """Update last_used timestamp for a character"""
try: try:
self.cursor.execute( 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', 'UPDATE `characters` SET last_used = NOW() WHERE id = %s',
(char_id,) (char_id,)
) )
self.connection.commit()
return True return True
except Error as e: except Exception as e:
logger.error(f'Update last used error: {e}') logger.error(f'Update last used error: {type(e).__name__}: {str(e)}')
logger.error(f'Traceback:\n{traceback.format_exc()}')
return False return False
async def is_trigger_available(self, user_id, trigger): async def is_trigger_available(self, user_id, trigger):
"""Check if trigger is available for a user""" """Check if trigger is available for a user"""
try: try:
self.cursor.execute( 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', 'SELECT id FROM `characters` WHERE user_id = %s AND `trigger` = %s',
(user_id, trigger) (user_id, trigger)
) )
return self.cursor.fetchone() is None return await cursor.fetchone() is None
except Error as e: except Exception as e:
logger.error(f'Check trigger availability error: {e}') logger.error(f'Check trigger availability error: {type(e).__name__}: {str(e)}')
logger.error(f'Traceback:\n{traceback.format_exc()}')
return False return False
def close(self): async def close(self):
"""Close database connection""" """Close database connection"""
if self.cursor: try:
self.cursor.close()
if self.connection: if self.connection:
self.connection.close() self.connection.close()
logger.info('Database connection closed') 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()}')