From 121fac424a352e4b56c6dede4410663d6263fb1f Mon Sep 17 00:00:00 2001 From: Slipstream Date: Mon, 19 May 2025 14:45:40 -0600 Subject: [PATCH] feat: Add Neru Bot with global command syncing and environment variable support --- README.md | 27 ++++-- neru_bot.py | 193 +++++++++++++++++++++++++++++++++++++++++ run_additional_bots.py | 16 ++++ run_neru_bot.py | 18 ++++ 4 files changed, 247 insertions(+), 7 deletions(-) create mode 100644 neru_bot.py create mode 100644 run_neru_bot.py diff --git a/README.md b/README.md index 498853a..da5f930 100644 --- a/README.md +++ b/README.md @@ -46,33 +46,34 @@ A versatile, modular Discord bot framework with multiple bot personalities and e ``` 3. **Set up environment variables** - + Create a `.env` file in the root directory with the following variables: ``` # Required DISCORD_TOKEN=your_main_discord_bot_token OWNER_USER_ID=your_discord_user_id - + # For specific bots (optional) DISCORD_TOKEN_GURT=your_gurt_bot_token DISCORD_TOKEN_WHEATLEY=your_wheatley_bot_token - DISCORD_TOKEN_NERU=your_neru_bot_token + NERU_BOT_TOKEN=your_neru_bot_token + DISCORD_TOKEN_NERU=your_neru_bot_token # Alternative to NERU_BOT_TOKEN DISCORD_TOKEN_MIKU=your_miku_bot_token - + # For AI features (if using) AI_API_KEY=your_openrouter_api_key GCP_PROJECT_ID=your_gcp_project_id GCP_LOCATION=us-central1 - + # For web search (optional) TAVILY_API_KEY=your_tavily_api_key - + # For database (if using) POSTGRES_USER=your_postgres_user POSTGRES_PASSWORD=your_postgres_password POSTGRES_HOST=localhost POSTGRES_SETTINGS_DB=discord_bot_settings - + # For Redis (if using) REDIS_HOST=localhost REDIS_PORT=6379 @@ -109,6 +110,11 @@ python run_wheatley_bot.py python run_additional_bots.py ``` +**Neru Bot** +```bash +python run_neru_bot.py +``` + ### API Service ```bash @@ -137,9 +143,16 @@ python api_service/api_server.py - Use `/multibot stop ` to stop a specific bot - Use `/multibot startall` to start all configured bots +### Neru Bot +- Default prefix: `!` (same as main bot) +- Uses global command syncing instead of per-guild +- All commands work in DMs and private channels +- Identical functionality to main bot but with different command registration + ## 🧩 Project Structure - **`main.py`**: Main bot entry point +- **`neru_bot.py`**: Global command syncing bot entry point - **`gurt_bot.py`/`wheatley_bot.py`**: Specialized bot entry points - **`multi_bot.py`**: Multi-bot system for running multiple AI personalities - **`cogs/`**: Directory containing different modules (cogs) diff --git a/neru_bot.py b/neru_bot.py new file mode 100644 index 0000000..0c067ef --- /dev/null +++ b/neru_bot.py @@ -0,0 +1,193 @@ +import discord +from discord.ext import commands +from discord import app_commands +import os +from dotenv import load_dotenv +import asyncio +import logging +import asyncpg +import redis.asyncio as aioredis +from commands import load_all_cogs +from error_handler import handle_error +import settings_manager +from global_bot_accessor import set_bot_instance + +# Load environment variables from .env file +load_dotenv() + +# --- Constants --- +DEFAULT_PREFIX = "!" +CORE_COGS = {'SettingsCog', 'HelpCog'} # Cogs that cannot be disabled + +# --- Dynamic Prefix Function --- +async def get_prefix(bot_instance, message): + """Determines the command prefix based on guild settings or default, but disables mention as prefix.""" + if not message.guild: + # Use default prefix in DMs + return DEFAULT_PREFIX + + # Fetch prefix from settings manager (cache first, then DB) + prefix = await settings_manager.get_guild_prefix(message.guild.id, DEFAULT_PREFIX) + return prefix + +# --- Bot Setup --- +# Set up intents (permissions) +intents = discord.Intents.default() +intents.message_content = True +intents.members = True + +# --- Custom Bot Class with setup_hook for async initialization --- +class NeruBot(commands.Bot): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + owner_id = os.getenv('OWNER_USER_ID') + if owner_id: + self.owner_id = int(owner_id) + self.core_cogs = CORE_COGS # Attach core cogs list to bot instance + self.settings_manager = settings_manager # Attach settings manager instance + self.pg_pool = None # Will be initialized in setup_hook + self.redis = None # Will be initialized in setup_hook + self.ai_cogs_to_skip = [] # For --disable-ai flag + + async def setup_hook(self): + """Async initialization that runs after login but before on_ready.""" + log.info("NeruBot setup_hook called") + + # Initialize database connections + try: + # PostgreSQL connection pool + self.pg_pool = await asyncpg.create_pool( + user=os.getenv('POSTGRES_USER'), + password=os.getenv('POSTGRES_PASSWORD'), + host=os.getenv('POSTGRES_HOST'), + database=os.getenv('POSTGRES_SETTINGS_DB') + ) + log.info("PostgreSQL connection pool initialized") + + # Redis connection + self.redis = await aioredis.from_url( + f"redis://{os.getenv('REDIS_HOST')}:{os.getenv('REDIS_PORT', '6379')}", + password=os.getenv('REDIS_PASSWORD'), + decode_responses=True + ) + log.info("Redis connection initialized") + + # Initialize database schema and run migrations using settings_manager + if self.pg_pool and self.redis: + try: + await settings_manager.initialize_database() + log.info("Database schema initialization called via settings_manager.") + await settings_manager.run_migrations() + log.info("Database migrations called via settings_manager.") + except Exception as e: + log.exception("CRITICAL: Failed during settings_manager database setup (init/migrations).") + else: + log.error("CRITICAL: pg_pool or redis_client not initialized in setup_hook. Cannot proceed with settings_manager setup.") + + # Load all cogs + try: + await load_all_cogs(self) + log.info("All cogs loaded successfully") + except Exception as e: + log.exception(f"Error loading cogs: {e}") + + # Apply global allowed_installs and allowed_contexts to all commands + try: + log.info("Applying global allowed_installs and allowed_contexts to all commands...") + for command in self.tree.get_commands(): + # Apply decorators to each command + app_commands.allowed_installs(guilds=True, users=True)(command) + app_commands.allowed_contexts(guilds=True, dms=True, private_channels=True)(command) + + # Sync commands globally + log.info("Starting global command sync process...") + synced = await self.tree.sync() + log.info(f"Synced {len(synced)} commands globally") + + # List commands after sync + commands_after = [cmd.name for cmd in self.tree.get_commands()] + log.info(f"Commands registered in command tree: {commands_after}") + except Exception as e: + log.exception(f"Failed to sync commands: {e}") + + except Exception as e: + log.exception(f"Error in setup_hook: {e}") + +# Create bot instance using the custom class +bot = NeruBot(command_prefix=get_prefix, intents=intents) + +# --- Logging Setup --- +# Configure logging (adjust level and format as needed) +logging.basicConfig(level=logging.INFO, format='%(asctime)s:%(levelname)s:%(name)s: %(message)s') +log = logging.getLogger(__name__) # Logger for neru_bot.py + +# --- Events --- +@bot.event +async def on_ready(): + if bot.user: + log.info(f'{bot.user.name} has connected to Discord!') + log.info(f'Bot ID: {bot.user.id}') + # Set the bot's status + await bot.change_presence(activity=discord.Activity(type=discord.ActivityType.listening, name="!help")) + log.info("Bot status set to 'Listening to !help'") + +# Error handling +@bot.event +async def on_command_error(ctx, error): + await handle_error(ctx, error) + +@bot.tree.error +async def on_app_command_error(interaction, error): + await handle_error(interaction, error) + +async def main(args): + """Main async function to load cogs and start the bot.""" + TOKEN = os.getenv('NERU_BOT_TOKEN') + if not TOKEN: + raise ValueError("No token found. Make sure to set NERU_BOT_TOKEN in your .env file.") + + # Set the global bot instance for other modules to access + set_bot_instance(bot) + log.info(f"Global bot instance set in global_bot_accessor. Bot ID: {id(bot)}") + + # Configure AI cogs to skip if needed + if args.disable_ai: + log.info("AI functionality disabled via command line flag.") + ai_cogs_to_skip = [ + "cogs.ai_cog", + "cogs.multi_conversation_ai_cog", + # Add any other AI-related cogs from the 'cogs' folder here + ] + # Store the skip list on the bot object for reload commands + bot.ai_cogs_to_skip = ai_cogs_to_skip + else: + bot.ai_cogs_to_skip = [] # Ensure it exists even if empty + + try: + # The bot will call setup_hook internally after login but before on_ready. + await bot.start(TOKEN) + except Exception as e: + log.exception(f"An error occurred during bot.start(): {e}") + finally: + # Close database connections + if bot.pg_pool: + await bot.pg_pool.close() + log.info("PostgreSQL connection pool closed.") + if bot.redis: + await bot.redis.close() + log.info("Redis connection closed.") + +if __name__ == "__main__": + import argparse + + # Parse command line arguments + parser = argparse.ArgumentParser(description="Run the Neru Discord Bot.") + parser.add_argument('--disable-ai', action='store_true', help='Disable AI functionality') + args = parser.parse_args() + + try: + asyncio.run(main(args)) + except KeyboardInterrupt: + log.info("Bot stopped by user.") + except Exception as e: + log.exception(f"An error occurred running the bot: {e}") diff --git a/run_additional_bots.py b/run_additional_bots.py index 0e68426..ca06f6e 100644 --- a/run_additional_bots.py +++ b/run_additional_bots.py @@ -6,6 +6,7 @@ import threading import asyncio import multi_bot import gurt_bot +import neru_bot def run_gurt_bot_in_thread(): """Run the Gurt Bot in a separate thread""" @@ -14,6 +15,15 @@ def run_gurt_bot_in_thread(): thread.start() return thread +def run_neru_bot_in_thread(): + """Run the Neru Bot in a separate thread""" + loop = asyncio.new_event_loop() + # Create args object with disable_ai=False + args = type('Args', (), {'disable_ai': False})() + thread = threading.Thread(target=lambda: loop.run_until_complete(neru_bot.main(args)), daemon=True) + thread.start() + return thread + def main(): """Main function to run all additional bots""" print("Starting additional bots (Neru, Miku, and Gurt)...") @@ -25,6 +35,10 @@ def main(): gurt_thread = run_gurt_bot_in_thread() bot_threads.append(("gurt", gurt_thread)) + # Start Neru Bot + neru_thread = run_neru_bot_in_thread() + bot_threads.append(("neru", neru_thread)) + if not bot_threads: print("No bots were started. Check your configuration in data/multi_bot_config.json") return @@ -41,6 +55,8 @@ def main(): print(f"Thread for bot {bot_id} died, restarting...") if bot_id == "gurt": new_thread = run_gurt_bot_in_thread() + elif bot_id == "neru": + new_thread = run_neru_bot_in_thread() else: new_thread = multi_bot.run_bot_in_thread(bot_id) bot_threads.remove((bot_id, thread)) diff --git a/run_neru_bot.py b/run_neru_bot.py new file mode 100644 index 0000000..cf780fc --- /dev/null +++ b/run_neru_bot.py @@ -0,0 +1,18 @@ +import os +import sys +import asyncio +import argparse +import neru_bot + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Run the Neru Discord Bot.") + parser.add_argument('--disable-ai', action='store_true', help='Disable AI functionality') + args = parser.parse_args() + + try: + # Pass the arguments to the main function + asyncio.run(neru_bot.main(args)) + except KeyboardInterrupt: + print("Neru Bot stopped by user.") + except Exception as e: + print(f"An error occurred running Neru Bot: {e}")