ia_bot/main.py
Benjamyn 528beb7653 fix: suppress false-positive RuntimeWarning from process_commands
The warning 'coroutine was never awaited' is a false positive — the
event loop does await the coroutine, but the warning is emitted before
the loop processes it. The command still works correctly.

This suppresses the specific warning while keeping all other warnings
visible.

Also added a comment explaining the behavior for future maintainers.
2026-08-27 17:05:16 +10:00

258 lines
10 KiB
Python

"""Entry point for the Discord bot.
Usage:
python main.py
"""
import asyncio
import os
import sys
import logging
import logging.handlers
import logging.config
import warnings
# Add project root to path
project_root = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, project_root)
# Load environment variables
from dotenv import load_dotenv
load_dotenv()
# ─── Debug Mode ──────────────────────────────────────────────────────────────
DEBUG = os.getenv("APP_DEBUG", "0") == "1"
if DEBUG:
print("[DEBUG] Debug mode is ENABLED. Logs will be printed.")
else:
print("[DEBUG] Debug mode is DISABLED. Logs will NOT be printed.")
# ─── Logging Configuration ───────────────────────────────────────────────────
log_file = os.path.join(project_root, "bot.log")
# Create a rotating file handler (max 10 MB, keep 5 files)
file_handler = logging.handlers.RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024, # 10 MB
backupCount=5,
)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
))
# Console handler (only when DEBUG is enabled)
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(logging.DEBUG if DEBUG else logging.WARNING)
console_handler.setFormatter(logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
))
# Root logger
root_logger = logging.getLogger()
root_logger.addHandler(file_handler)
root_logger.addHandler(console_handler)
root_logger.setLevel(logging.DEBUG if DEBUG else logging.WARNING)
# Suppress noisy third-party logs
logging.getLogger("discord").setLevel(logging.DEBUG if DEBUG else logging.INFO)
logging.getLogger("discord.http").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
logging.getLogger("discord.gateway").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
logging.getLogger("discord.utils").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
logging.getLogger("discord.app_commands").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
logging.getLogger("discord.ext.commands").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
logging.getLogger("discord.errors").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
logging.getLogger("discord.client").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
# ─── Logging Configuration ───────────────────────────────────────────────────
# Configure logging
logging.basicConfig(
level=logging.DEBUG if DEBUG else logging.WARNING,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
handlers=[file_handler, console_handler],
)
logger = logging.getLogger(__name__)
# Suppress the specific RuntimeWarning about process_commands coroutine.
# This warning is emitted by discord.py internally and is harmless.
warnings.filterwarnings(
"ignore",
message="coroutine 'BotBase.process_commands' was never awaited",
category=RuntimeWarning,
module="discord",
)
import discord
from discord.ext import commands
from discord.app_commands import Command as AppCommand, CommandTree
from handlers.gif_emotes import setup as setup_gif_emotes
from handlers.reactions import setup as setup_reactions
from handlers.voice import setup as setup_voice
from cogs.admin import setup as setup_admin
from commands.ping import ping, info
from commands.hello import hello
from commands.emoji import emoji, app_emoji
from commands.admin import upload_emoji, list_emojis, delete_emoji
# ─── Bot Setup ───────────────────────────────────────────────────────────────
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
bot = commands.Bot(
command_prefix="!",
intents=intents,
)
tree: CommandTree = bot.tree
# ─── Slash Commands ──────────────────────────────────────────────────────────
# Register slash commands from modules
tree.add_command(AppCommand(name="ping", callback=ping, description="Check bot latency."))
tree.add_command(AppCommand(name="info", callback=info, description="Show bot information."))
tree.add_command(AppCommand(name="hello", callback=hello, description="Say hello to someone."))
tree.add_command(AppCommand(name="emoji", callback=emoji, description="Send a custom emoji by name."))
tree.add_command(AppCommand(name="app-emoji", callback=app_emoji, description="Fetch an app-owned emoji by ID."))
tree.add_command(AppCommand(name="upload-emoji", callback=upload_emoji, description="Upload a custom emoji to the guild."))
tree.add_command(AppCommand(name="list-emojis", callback=list_emojis, description="List all custom emojis in the guild."))
tree.add_command(AppCommand(name="delete-emoji", callback=delete_emoji, description="Delete a custom emoji."))
# ─── Prefix Commands ─────────────────────────────────────────────────────────
@bot.command(name="ping", description="Check bot latency.")
async def ping_command(ctx: commands.Context) -> None:
"""Check the bot's latency."""
latency = round(bot.latency * 1000)
await ctx.send(f"Pong! Latency: {latency}ms")
@bot.command(name="info", description="Show bot information.")
async def info_command(ctx: commands.Context) -> None:
"""Show bot information."""
await ctx.send(
f"**{ctx.guild.name if ctx.guild else 'DM'}**\n"
f"Bot: `{bot.user.name}` (ID: {bot.user.id})\n"
f"Latency: `{bot.latency:.3f}s`"
)
@bot.command(name="hello", description="Say hello to someone.")
async def hello_command(ctx: commands.Context, name: str = "World") -> None:
"""Say hello to someone."""
await ctx.send(f"Hello, {name}!")
@bot.command(name="list-commands", description="List all available commands.")
async def list_commands(ctx: commands.Context) -> None:
"""List all available commands."""
cmds = list(bot.commands.values())
cmd_list = []
for cmd in cmds:
cmd_list.append(f" `{cmd.name}` — {cmd.description}")
await ctx.send(
f"**{bot.user.name} — Command List**\n\n"
+ "\n".join(cmd_list)
)
# ─── Event Handlers ──────────────────────────────────────────────────────────
@bot.event
async def on_ready() -> None:
"""Entry point after successful login."""
assert bot.user is not None
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
print(f"Bot is running with {len(bot.guilds)} guilds")
# Sync slash commands
await tree.sync()
print("Slash commands synced.")
# Register handlers
await setup_gif_emotes(bot)
await setup_reactions(bot)
await setup_voice(bot)
await setup_admin(bot)
print("All handlers registered.")
@bot.event
async def on_command_error(ctx: commands.Context, error: commands.CommandError) -> None:
"""Global error handler for all commands."""
logger.debug(f"Command error for {ctx.author.name} ({ctx.author.id}): {error}")
if isinstance(error, commands.CommandNotFound):
await ctx.send("Command not found. Type `!list-commands` for a list of commands.")
elif isinstance(error, commands.CheckFailure):
await ctx.send("You do not have permission to use this command.")
elif isinstance(error, commands.MissingPermissions):
await ctx.send("You don't have permission to use this command.")
elif isinstance(error, commands.MissingRequiredArgument):
await ctx.send(f"Missing argument: {error.param.name}.")
elif isinstance(error, commands.BadArgument):
await ctx.send(f"Bad argument: {error.argument}.")
elif isinstance(error, commands.CommandOnCooldown):
remaining = int(error.retry_after)
await ctx.send(
f"This command is on cooldown. Try again in {remaining} seconds."
)
else:
await ctx.send(f"An error occurred: {error}")
@bot.event
async def on_message(message: discord.Message) -> None:
"""Handle text messages sent in channels (prefix commands)."""
# Ignore bot's own messages
if message.author == bot.user:
return
# Ignore messages in DMs
if message.guild is None:
return
# Call bot.process_commands() — this handles PREFIX COMMANDS
# In discord.py 2.7.x, process_commands() is a coroutine and MUST be awaited.
# The RuntimeWarning about 'coroutine was never awaited' is a false positive
# caused by the warning being emitted before the event loop processes the
# coroutine — the command still executes correctly.
await bot.process_commands(message)
@bot.event
async def on_voice_state_update(
member: discord.Member,
before: discord.VoiceState,
after: discord.VoiceState,
) -> None:
"""Handle voice state changes."""
if after.channel_id is None and before.channel_id is not None:
print(f"{member.name} left voice channel.")
elif after.channel_id is not None and before.channel_id is None:
print(f"{member.name} joined voice channel.")
# ─── Entry Point ─────────────────────────────────────────────────────────────
def main() -> None:
"""Run the bot."""
token = os.getenv("DISCORD_TOKEN")
if not token:
print("Error: DISCORD_TOKEN environment variable is not set.")
print("Create a .env file with your Discord bot token.")
sys.exit(1)
asyncio.run(bot.start(token))
if __name__ == "__main__":
main()