diff --git a/discord_reminder_bot/main.py b/discord_reminder_bot/main.py index 7c1b9e8..46994a2 100644 --- a/discord_reminder_bot/main.py +++ b/discord_reminder_bot/main.py @@ -1,13 +1,15 @@ from __future__ import annotations +import asyncio import datetime import json import logging import tempfile from pathlib import Path -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any import discord +import sentry_sdk from apscheduler.job import Job from discord.abc import PrivateChannel from discord_webhook import DiscordWebhook @@ -18,6 +20,8 @@ from discord_reminder_bot.settings import get_bot_token, get_scheduler, get_webh from discord_reminder_bot.ui import JobManagementView, create_job_embed if TYPE_CHECKING: + from collections.abc import Sequence + from apscheduler.job import Job from discord.guild import GuildChannel from discord.interactions import InteractionChannel @@ -28,6 +32,11 @@ logger: logging.Logger = logging.getLogger(__name__) logger.setLevel(logging.DEBUG) logging.getLogger("discord.client").setLevel(logging.INFO) +sentry_sdk.init( + dsn="https://c4c61a52838be9b5042144420fba5aaa@o4505228040339456.ingest.us.sentry.io/4508707268984832", + traces_sample_rate=1.0, +) + GUILD_ID = discord.Object(id=341001473661992962) scheduler: settings.AsyncIOScheduler = get_scheduler() @@ -46,6 +55,49 @@ class RemindBotClient(discord.Client): super().__init__(intents=intents) self.tree = discord.app_commands.CommandTree(self) + async def on_error(self, event_method: str, *args: list[Any], **kwargs: dict[str, Any]) -> None: + """Log errors that occur in the bot.""" + # Log the error + logger.exception("An error occurred in %s", event_method) + + # Add context to Sentry + with sentry_sdk.push_scope() as scope: + # Add event details + scope.set_tag("event_method", event_method) + scope.set_extra("args", args) + scope.set_extra("kwargs", kwargs) + + # Add bot state + scope.set_tag("bot_user_id", self.user.id if self.user else "Unknown") + scope.set_tag("bot_user_name", str(self.user) if self.user else "Unknown") + scope.set_tag("bot_latency", self.latency) + + # If specific arguments are available, extract and add details + if args: + interaction = next((arg for arg in args if isinstance(arg, discord.Interaction)), None) + if interaction: + scope.set_extra("interaction_id", interaction.id) + scope.set_extra("interaction_user", interaction.user.id) + scope.set_extra("interaction_user_tag", str(interaction.user)) + scope.set_extra("interaction_command", interaction.command.name if interaction.command else None) + scope.set_extra("interaction_channel", str(interaction.channel)) + scope.set_extra("interaction_guild", str(interaction.guild) if interaction.guild else None) + + # Add Sentry tags for interaction details + scope.set_tag("interaction_id", interaction.id) + scope.set_tag("interaction_user_id", interaction.user.id) + scope.set_tag("interaction_user_tag", str(interaction.user)) + scope.set_tag("interaction_command", interaction.command.name if interaction.command else "None") + scope.set_tag("interaction_channel_id", interaction.channel.id if interaction.channel else "None") + scope.set_tag("interaction_channel_name", str(interaction.channel)) + scope.set_tag("interaction_guild_id", interaction.guild.id if interaction.guild else "None") + scope.set_tag("interaction_guild_name", str(interaction.guild) if interaction.guild else "None") + + # Add APScheduler context + scope.set_extra("scheduler_jobs", [job.id for job in scheduler.get_jobs()]) + + sentry_sdk.capture_exception() + async def on_ready(self) -> None: """Log when the bot is ready.""" logger.info("Logged in as %s (%s)", self.user, self.user.id if self.user else "N/A ID") @@ -64,6 +116,8 @@ class RemindBotClient(discord.Client): await msg.delete() except discord.HTTPException as e: logger.error("Failed to remove view: %s", e) # noqa: TRY400 + except asyncio.exceptions.CancelledError: + logger.error("Failed to remove view: Task was cancelled.") # noqa: TRY400 return await super().close() @@ -314,7 +368,6 @@ class RemindGroup(discord.app_commands.Group): view = JobManagementView(job=jobs_in_guild[0], scheduler=scheduler, guild=guild, message=message) msg_to_cleanup.append(message) - logger.debug("Views to cleanup: %s", msg_to_cleanup) await interaction.followup.send(embed=embed, view=view) @@ -810,16 +863,37 @@ async def send_to_user(user_id: int, guild_id: int, message: str) -> None: guild_id: The guild ID to get the user from. message: The message to send. """ - # TODO(TheLovinator): Add try/except for all of these await calls # noqa: TD003 - guild: discord.Guild | None = bot.get_guild(guild_id) - if guild is None: - guild = await bot.fetch_guild(guild_id) + logger.info("Sending message to user %s in guild %s:\n%s", user_id, guild_id, message) + try: + guild: discord.Guild | None = bot.get_guild(guild_id) + if guild is None: + guild = await bot.fetch_guild(guild_id) + except discord.NotFound: + current_guilds: Sequence[discord.Guild] = bot.guilds + logger.exception("Guild not found. Current guilds: %s", current_guilds) + return + except discord.HTTPException: + logger.exception("Failed to fetch guild") + return - member: discord.Member | None = guild.get_member(user_id) - if member is None: - member = await guild.fetch_member(user_id) + try: + member: discord.Member | None = guild.get_member(user_id) + if member is None: + member = await guild.fetch_member(user_id) + except discord.Forbidden: + logger.exception("We do not have access to the guild. Guild: %s, User: %s", guild_id, user_id) + return + except discord.NotFound: + logger.exception("Member not found. Guild: %s, User: %s", guild_id, user_id) + return + except discord.HTTPException: + logger.exception("Fetching the member failed. Guild: %s, User: %s", guild_id, user_id) + return - await member.send(message) + try: + await member.send(message) + except discord.HTTPException: + logger.exception("Failed to send message (%s) to user (%s) in guild (%s)", message, user_id, guild_id) if __name__ == "__main__": diff --git a/discord_reminder_bot/ui.py b/discord_reminder_bot/ui.py index ba2ae8f..4e687e6 100644 --- a/discord_reminder_bot/ui.py +++ b/discord_reminder_bot/ui.py @@ -4,6 +4,7 @@ import logging from typing import TYPE_CHECKING import discord +import sentry_sdk from apscheduler.job import Job from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.cron import CronTrigger @@ -262,6 +263,46 @@ class JobManagementView(discord.ui.View): logger.debug("No message to edit for job: %s", self.job.id) self.stop() + async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item) -> None: + """Handle errors that occur within the view. + + Args: + interaction: The interaction object for the command. + error: The exception that was raised. + item: The item that caused the error. + """ + with sentry_sdk.push_scope() as scope: + # Interaction-related context + scope.set_extra("interaction_id", interaction.id) + scope.set_extra("interaction_user", interaction.user.id) + scope.set_extra("interaction_user_tag", str(interaction.user)) + scope.set_extra("interaction_command", interaction.command.name if interaction.command else None) + scope.set_extra("interaction_channel", str(interaction.channel)) + scope.set_extra("interaction_guild", str(interaction.guild) if interaction.guild else None) + + # Item-related context + scope.set_extra("item_type", type(item).__name__) + scope.set_extra("item_label", getattr(item, "label", None)) + + # Job and scheduler context + scope.set_extra("job_id", self.job.id if self.job else None) + scope.set_extra("job_kwargs", self.job.kwargs if self.job else None) + + # Guild and message context + scope.set_extra("guild_id", self.guild.id if self.guild else None) + scope.set_extra("guild_name", self.guild.name if self.guild else None) + scope.set_extra("message_id", self.message.id if self.message else None) + scope.set_extra("message_content", self.message.content if self.message else None) + + # Tags for categorization + scope.set_tag("error_type", type(error).__name__) + scope.set_tag("interaction_type", "command" if interaction.command else "other") + if interaction.guild: + scope.set_tag("guild_id", interaction.guild.id) + scope.set_tag("guild_name", interaction.guild.name) + + sentry_sdk.capture_exception(error) + @discord.ui.button(label="Delete", style=discord.ButtonStyle.danger) async def delete_button(self, interaction: discord.Interaction, button: Button) -> None: # noqa: ARG002 """Delete the job. diff --git a/pyproject.toml b/pyproject.toml index a1493be..a50b2c2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,9 @@ dependencies = [ # For loading environment variables from a .env file "python-dotenv>=1.0.1,<2.0.0", # https://github.com/theskumar/python-dotenv + + # For error tracking + "sentry-sdk>=2.20.0,<3.0.0", # https://github.com/getsentry/sentry-python ] [dependency-groups] @@ -72,6 +75,9 @@ discord-webhook = {version = ">=1.3.1,<2.0.0"} # For loading environment variables from a .env file python-dotenv = {version = ">=1.0.1,<2.0.0"} +# For error tracking +sentry-sdk = {version = ">=2.20.0,<3.0.0"} + [tool.poetry.dev-dependencies] pytest = "*" pre-commit = "*"