298 lines
11 KiB
Python

import os
import discord
from discord.ext import commands
from dotenv import load_dotenv
import asyncio
import traceback
import sys
import functools
from discord import app_commands
import sys
import io
# Redirect stdout and stderr to a log file and the console
class DualStream:
def __init__(self, original_stream, log_file):
self.original_stream = original_stream
self.log_file = log_file
def write(self, data):
self.original_stream.write(data)
self.log_file.write(data)
self.log_file.flush() # Ensure data is written to file immediately
def flush(self):
self.original_stream.flush()
self.log_file.flush()
log_file = open("bot.log", "a")
sys.stdout = DualStream(sys.stdout, log_file)
sys.stderr = DualStream(sys.stderr, log_file)
print("Logging started.")
# Load environment variables
load_dotenv("keys.env")
discord_token = os.getenv("DISCORD_TOKEN")
# Ensure token is set
if not discord_token:
raise ValueError("Missing DISCORD_TOKEN environment variable.")
# Configure bot with intents
intents = discord.Intents.default()
intents.message_content = True
intents.presences = True # Enable presence intent for status/device/activity detection
intents.members = True # Enable member intent for full member info
# Technically no reason to have a prefix set because the bot only uses slash commands.
bot = commands.Bot(command_prefix="/", intents=intents)
# User ID to send error notifications to
ERROR_NOTIFICATION_USER_ID = 1141746562922459136
# Decorator to catch and report exceptions in any function
def catch_exceptions(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
try:
return await func(*args, **kwargs)
except Exception as e:
# Get traceback
tb_string = "".join(traceback.format_exception(type(e), e, e.__traceback__))
# Log to console
print(f"Uncaught exception in {func.__name__}:")
print(tb_string)
# Context information
context = f"Function: {func.__name__}, Module: {func.__module__}"
if args and hasattr(args[0], '__class__'):
context += f", Class: {args[0].__class__.__name__}"
# Get the bot instance to send the error
# This assumes the function is a method of a cog with a bot attribute
# or the first argument is the bot itself
bot_instance = None
if args and hasattr(args[0], 'bot'):
bot_instance = args[0].bot
elif args and isinstance(args[0], commands.Bot):
bot_instance = args[0]
if bot_instance:
# Use the global bot instance's send_error_dm function
user = await bot_instance.fetch_user(ERROR_NOTIFICATION_USER_ID)
if user:
error_content = f"**Error Type:** {type(e).__name__}\n"
error_content += f"**Error Message:** {str(e)}\n"
error_content += f"**Context:** {context}\n"
if tb_string:
if len(tb_string) > 1500:
tb_string = tb_string[:1500] + "...(truncated)"
error_content += f"**Traceback:**\n```\n{tb_string}\n```"
await user.send(error_content)
# Re-raise the exception to maintain the original behavior
raise
return wrapper
# Load cog files dynamically
async def load_cogs():
for filename in os.listdir("cogs"):
if filename.endswith(".py"):
try:
await bot.load_extension(f"cogs.{filename[:-3]}")
print(f"Loaded cog: {filename}")
except Exception as e:
print(f"Failed to load cog {filename}: {e}")
# Send DM notification for cog loading error
tb_string = "".join(traceback.format_exception(type(e), e, e.__traceback__))
try:
await send_error_dm(
error_type=type(e).__name__,
error_message=str(e),
error_traceback=tb_string,
context_info=f"Error loading cog: {filename}"
)
except Exception as dm_error:
print(f"Failed to send error DM for cog loading error: {dm_error}")
async def send_error_dm(error_type, error_message, error_traceback=None, context_info=None):
"""Send error details to the specified user via DM."""
try:
# Get the user to send the error to
user = await bot.fetch_user(ERROR_NOTIFICATION_USER_ID)
if not user:
print(f"Could not find user with ID {ERROR_NOTIFICATION_USER_ID} to send error notification")
return
# Format the error message
error_content = f"**Error Type:** {error_type}\n"
error_content += f"**Error Message:** {error_message}\n"
if context_info:
error_content += f"**Context:** {context_info}\n"
# Add traceback if available (might need to be split into multiple messages if too long)
if error_traceback:
# Limit traceback length to avoid hitting Discord's message limit
if len(error_traceback) > 1500:
error_traceback = error_traceback[:1500] + "...(truncated)"
error_content += f"**Traceback:**\n```\n{error_traceback}\n```"
# Send the DM
await user.send(error_content)
except Exception as e:
# If we can't send the DM, at least log it to console
print(f"Failed to send error DM: {e}")
@bot.event
async def on_error(event, *args, **kwargs):
"""Global error handler for all events."""
# Get the exception info
error_type, error_value, error_traceback = sys.exc_info()
tb_string = "".join(traceback.format_exception(error_type, error_value, error_traceback))
# Log to console
print(f"Error in event {event}:")
print(tb_string)
# Context information
context = f"Event: {event}"
if args:
context += f", Args: {args}"
if kwargs:
context += f", Kwargs: {kwargs}"
# Send DM notification
await send_error_dm(
error_type=error_type.__name__,
error_message=str(error_value),
error_traceback=tb_string,
context_info=context
)
@bot.event
async def on_command_error(ctx, error):
"""Error handler for prefix commands."""
# Get the original error if it's wrapped in CommandInvokeError
error = getattr(error, 'original', error)
# Get traceback
tb_string = "".join(traceback.format_exception(type(error), error, error.__traceback__))
# Log to console
print(f"Command error in {ctx.command}:")
print(tb_string)
# Context information
context = f"Command: {ctx.command}, Author: {ctx.author} ({ctx.author.id}), Guild: {ctx.guild.name if ctx.guild else 'DM'} ({ctx.guild.id if ctx.guild else 'N/A'}), Channel: {ctx.channel}"
# Send DM notification
await send_error_dm(
error_type=type(error).__name__,
error_message=str(error),
error_traceback=tb_string,
context_info=context
)
# You can still respond to the user if appropriate
try:
await ctx.send(f"An error occurred while executing the command. The bot owner has been notified.")
except:
pass
@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
"""Error handler for application (slash) commands."""
# Get the original error if it's wrapped
error = getattr(error, 'original', error)
# Get traceback
tb_string = "".join(traceback.format_exception(type(error), error, error.__traceback__))
# Log to console
command_name = interaction.command.name if interaction.command else "Unknown"
print(f"App command error in {command_name}:")
print(tb_string)
# Context information
context = f"Command: {command_name}, Author: {interaction.user} ({interaction.user.id}), Guild: {interaction.guild.name if interaction.guild else 'DM'} ({interaction.guild.id if interaction.guild else 'N/A'}), Channel: {interaction.channel}"
# Send DM notification
await send_error_dm(
error_type=type(error).__name__,
error_message=str(error),
error_traceback=tb_string,
context_info=context
)
# Respond to the interaction if it hasn't been responded to yet
try:
if not interaction.response.is_done():
await interaction.response.send_message("An error occurred while executing the command. The bot owner has been notified.", ephemeral=True)
else:
await interaction.followup.send("An error occurred while executing the command. The bot owner has been notified.", ephemeral=True)
except:
pass
@bot.event
async def on_ready():
try:
await bot.tree.sync() # Sync commands globally or specify a guild if needed
print("Commands synced successfully!")
except Exception as e:
print(f"Failed to sync commands: {e}")
# Send DM notification for sync error
tb_string = "".join(traceback.format_exception(type(e), e, e.__traceback__))
await send_error_dm(
error_type=type(e).__name__,
error_message=str(e),
error_traceback=tb_string,
context_info="Error occurred during command sync in on_ready event"
)
print(f"Logged in as {bot.user}")
print(f"Global error handling is active - errors will be sent to user ID: {ERROR_NOTIFICATION_USER_ID}")
# Test commands to verify error handling
@bot.command(name="testerror")
async def test_error(ctx):
"""Test command to verify error handling by intentionally raising an exception."""
# Use ctx to avoid the unused variable warning
await ctx.send(f"Testing error handling in {ctx.command}...")
raise ValueError("This is a test error to verify error handling")
@bot.tree.command(name="testerror", description="Test slash command to verify error handling")
async def test_error_slash(interaction: discord.Interaction):
"""Test slash command to verify error handling by intentionally raising an exception."""
await interaction.response.send_message("Testing error handling in slash command...")
raise ValueError("This is a test error to verify slash command error handling")
async def main():
async with bot:
await load_cogs()
await bot.start(discord_token)
if __name__ == "__main__":
try:
asyncio.run(main())
except Exception as e:
# Handle startup errors
print(f"Critical error during bot startup: {e}")
tb_string = "".join(traceback.format_exception(type(e), e, e.__traceback__))
print(tb_string)
# We can't use the async function here since the bot isn't running
print(f"Could not send error notification to user ID {ERROR_NOTIFICATION_USER_ID} - bot not running")
print(f"Error Type: {type(e).__name__}")
print(f"Error Message: {str(e)}")
print(f"Traceback: {tb_string}")
finally:
if 'log_file' in locals() and not log_file.closed:
log_file.close()