Add Loguru for enhanced logging and update logging statements for clarity

This commit is contained in:
2025-01-26 04:34:23 +01:00
parent 1287f8ca4b
commit 4bd937a570
6 changed files with 113 additions and 108 deletions

View File

@ -18,6 +18,7 @@
"jobstore", "jobstore",
"jobstores", "jobstores",
"levelname", "levelname",
"loguru",
"Lovinator", "Lovinator",
"pycodestyle", "pycodestyle",
"pydocstyle", "pydocstyle",

View File

@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
import datetime import datetime
import json import json
import logging
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
@ -13,6 +12,7 @@ import sentry_sdk
from apscheduler.job import Job from apscheduler.job import Job
from discord.abc import PrivateChannel from discord.abc import PrivateChannel
from discord_webhook import DiscordWebhook from discord_webhook import DiscordWebhook
from loguru import logger
from discord_reminder_bot.misc import calc_time, calculate from discord_reminder_bot.misc import calc_time, calculate
from discord_reminder_bot.parser import parse_time from discord_reminder_bot.parser import parse_time
@ -28,17 +28,13 @@ if TYPE_CHECKING:
from discord_reminder_bot import settings from discord_reminder_bot import settings
logger: logging.Logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
logging.getLogger("discord.client").setLevel(logging.INFO)
sentry_sdk.init( sentry_sdk.init(
dsn="https://c4c61a52838be9b5042144420fba5aaa@o4505228040339456.ingest.us.sentry.io/4508707268984832", dsn="https://c4c61a52838be9b5042144420fba5aaa@o4505228040339456.ingest.us.sentry.io/4508707268984832",
traces_sample_rate=1.0, traces_sample_rate=1.0,
send_default_pii=True,
) )
GUILD_ID = discord.Object(id=341001473661992962)
scheduler: settings.AsyncIOScheduler = get_scheduler() scheduler: settings.AsyncIOScheduler = get_scheduler()
msg_to_cleanup: list[discord.InteractionMessage] = [] msg_to_cleanup: list[discord.InteractionMessage] = []
@ -58,7 +54,7 @@ class RemindBotClient(discord.Client):
async def on_error(self, event_method: str, *args: list[Any], **kwargs: dict[str, Any]) -> None: async def on_error(self, event_method: str, *args: list[Any], **kwargs: dict[str, Any]) -> None:
"""Log errors that occur in the bot.""" """Log errors that occur in the bot."""
# Log the error # Log the error
logger.exception("An error occurred in %s", event_method) logger.exception(f"An error occurred in {event_method} with args: {args} and kwargs: {kwargs}")
# Add context to Sentry # Add context to Sentry
with sentry_sdk.push_scope() as scope: with sentry_sdk.push_scope() as scope:
@ -100,26 +96,26 @@ class RemindBotClient(discord.Client):
async def on_ready(self) -> None: async def on_ready(self) -> None:
"""Log when the bot is ready.""" """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") logger.info(f"Logged in as {self.user} ({self.user.id if self.user else 'Unknown'})")
async def close(self) -> None: async def close(self) -> None:
"""Close the bot and cleanup views.""" """Close the bot and cleanup views."""
logger.info("Closing bot and cleaning up views.") logger.info("Closing bot and cleaning up views.")
for msg in msg_to_cleanup: for msg in msg_to_cleanup:
logger.debug("Removing view: %s", msg.id) logger.debug(f"Removing view: {msg.id}")
try: try:
# If the message is "/remind list timed out.", skip it # If the message is "/remind list timed out.", skip it
if "/remind list timed out." in msg.content: if "`/remind list` timed out." in msg.content:
logger.debug("Message %s is a timeout message. Skipping.", msg.id) logger.debug(f"Message {msg.id} is a timeout message. Skipping.")
continue continue
await msg.delete() await msg.delete()
except discord.HTTPException as e: except discord.HTTPException as e:
if e.status != 401: if e.status != 401:
# Skip if the webhook token is invalid # Skip if the webhook token is invalid
logger.error("Failed to remove view: %s", e) # noqa: TRY400 logger.error(f"Failed to remove view: {msg.id} - {e.text} - {e.status} - {e.code}")
except asyncio.exceptions.CancelledError: except asyncio.exceptions.CancelledError:
logger.error("Failed to remove view: Task was cancelled.") # noqa: TRY400 logger.error("Failed to remove view: Task was cancelled.")
return await super().close() return await super().close()
@ -138,13 +134,12 @@ class RemindBotClient(discord.Client):
time: str = "Paused" time: str = "Paused"
if hasattr(job, "next_run_time") and job.next_run_time and isinstance(job.next_run_time, datetime.datetime): if hasattr(job, "next_run_time") and job.next_run_time and isinstance(job.next_run_time, datetime.datetime):
time = job.next_run_time.strftime("%Y-%m-%d %H:%M:%S") time = job.next_run_time.strftime("%Y-%m-%d %H:%M:%S")
logger.info(rf"\t{job.id}: {job.name} - {time} - {msg}")
logger.info("\t%s: %s (%s)", msg[:50] or "No message", time, job.id) except (AttributeError, LookupError):
except Exception:
logger.exception("Failed to loop through jobs") logger.exception("Failed to loop through jobs")
self.tree.copy_global_to(guild=GUILD_ID) await self.tree.sync()
await self.tree.sync(guild=GUILD_ID)
class RemindGroup(discord.app_commands.Group): class RemindGroup(discord.app_commands.Group):
@ -178,8 +173,8 @@ class RemindGroup(discord.app_commands.Group):
# TODO(TheLovinator): Check if we have access to the channel and user # noqa: TD003 # TODO(TheLovinator): Check if we have access to the channel and user # noqa: TD003
await interaction.response.defer() await interaction.response.defer()
logger.info("New reminder from %s (%s) in %s", interaction.user, interaction.user.id, interaction.channel) logger.info(f"New reminder from {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.info("Arguments: %s", {k: v for k, v in locals().items() if k != "self" and v is not None}) logger.info(f"Arguments: {locals()}")
# Check if we have access to the specified channel or the current channel # Check if we have access to the specified channel or the current channel
target_channel: InteractionChannel | None = channel or interaction.channel target_channel: InteractionChannel | None = channel or interaction.channel
@ -218,7 +213,7 @@ class RemindGroup(discord.app_commands.Group):
"message": message, "message": message,
}, },
) )
logger.info("User reminder job created: %s for %s at %s", user_reminder, user.id, time) logger.info(f"User reminder job created: {user_reminder} for {user.id} at {parsed_time}")
dm_message = f" and a DM to {user.display_name}" dm_message = f" and a DM to {user.display_name}"
if not dm_and_current_channel: if not dm_and_current_channel:
@ -239,7 +234,7 @@ class RemindGroup(discord.app_commands.Group):
"author_id": interaction.user.id, "author_id": interaction.user.id,
}, },
) )
logger.info("Channel reminder job created: %s for %s at %s", channel_job, channel_id, time) logger.info(f"Channel reminder job created: {channel_job} for {channel_id}")
msg: str = ( msg: str = (
f"Hello {interaction.user.display_name},\n" f"Hello {interaction.user.display_name},\n"
@ -251,7 +246,7 @@ class RemindGroup(discord.app_commands.Group):
# /remind event # /remind event
@discord.app_commands.command(name="event", description="Add a new Discord event.") @discord.app_commands.command(name="event", description="Add a new Discord event.")
async def add_event( # noqa: PLR0913, PLR0917, PLR6301 async def add_event( # noqa: C901, PLR0913, PLR0917, PLR6301
self, self,
interaction: discord.Interaction, interaction: discord.Interaction,
message: str, message: str,
@ -272,8 +267,8 @@ class RemindGroup(discord.app_commands.Group):
""" """
await interaction.response.defer() await interaction.response.defer()
logger.info("New event from %s (%s) in %s", interaction.user, interaction.user.id, interaction.channel) logger.info(f"New event from {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.info("Arguments: %s", {k: v for k, v in locals().items() if k != "self" and v is not None}) logger.info(f"Arguments: {locals()}")
guild: discord.Guild | None = interaction.guild guild: discord.Guild | None = interaction.guild
if not guild: if not guild:
@ -296,15 +291,19 @@ class RemindGroup(discord.app_commands.Group):
reason_msg: str = f"Event created by {interaction.user} ({interaction.user.id})." reason_msg: str = f"Event created by {interaction.user} ({interaction.user.id})."
event: discord.ScheduledEvent = await guild.create_scheduled_event( try:
name=message, event: discord.ScheduledEvent = await guild.create_scheduled_event(
start_time=event_start_time, name=message,
entity_type=discord.EntityType.external, start_time=event_start_time,
privacy_level=discord.PrivacyLevel.guild_only, entity_type=discord.EntityType.external,
end_time=event_end_time, privacy_level=discord.PrivacyLevel.guild_only,
reason=reason or reason_msg, end_time=event_end_time,
location=location, reason=reason or reason_msg,
) location=location,
)
except discord.Forbidden as e:
await interaction.followup.send(content=f"I don't have permission to create events in this guild: {e}", ephemeral=True)
return
if start_immediately: if start_immediately:
await event.start() await event.start()
@ -354,12 +353,12 @@ class RemindGroup(discord.app_commands.Group):
for job in jobs: for job in jobs:
# If the job has guild_id and it's not the current guild, skip it # If the job has guild_id and it's not the current guild, skip it
if job.kwargs.get("guild_id") and job.kwargs.get("guild_id") != guild.id: if job.kwargs.get("guild_id") and job.kwargs.get("guild_id") != guild.id:
logger.debug("Skipping job: %s because it's not in the current guild.", job.id) logger.debug(f"Skipping job: {job.id} because it's not in the current guild.")
continue continue
# If the job has channel_id and it's not in the current guild, skip it # If the job has channel_id and it's not in the current guild, skip it
if job.kwargs.get("channel_id") and job.kwargs.get("channel_id") not in list_of_channels_in_current_guild: if job.kwargs.get("channel_id") and job.kwargs.get("channel_id") not in list_of_channels_in_current_guild:
logger.debug("Skipping job: %s because it's not in the current guild's channels.", job.id) logger.debug(f"Skipping job: {job.id} because it's not in the current guild.")
continue continue
jobs_in_guild.append(job) jobs_in_guild.append(job)
@ -421,15 +420,15 @@ class RemindGroup(discord.app_commands.Group):
try: try:
await interaction.response.defer() await interaction.response.defer()
except discord.HTTPException as e: except discord.HTTPException as e:
logger.exception("Failed to defer interaction: text=%s, status=%s, code=%s", e.text, e.status, e.code) logger.exception(f"Failed to defer interaction: {e.text=}, {e.status=}, {e.code=}")
return return
except discord.InteractionResponded as e: except discord.InteractionResponded as e:
logger.exception("Interaction already responded to - interaction: %s", e.interaction) logger.exception(f"Interaction already responded to - interaction: {interaction}, {e}")
return return
# Log kwargs # Log kwargs
logger.info("New cron job from %s (%s) in %s", interaction.user, interaction.user.id, interaction.channel) logger.info(f"New cron job from {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.info("Cron job arguments: %s", {k: v for k, v in locals().items() if k != "self" and v is not None}) logger.info(f"Cron job arguments: {locals()}")
# Get the channel ID # Get the channel ID
channel_id: int | None = channel.id if channel else (interaction.channel.id if interaction.channel else None) channel_id: int | None = channel.id if channel else (interaction.channel.id if interaction.channel else None)
@ -548,8 +547,8 @@ class RemindGroup(discord.app_commands.Group):
""" # noqa: E501 """ # noqa: E501
await interaction.response.defer() await interaction.response.defer()
logger.info("New interval job from %s (%s) in %s", interaction.user, interaction.user.id, interaction.channel) logger.info(f"New interval job from {interaction.user} ({interaction.user.id}) in {interaction.channel}")
logger.info("Arguments: %s", {k: v for k, v in locals().items() if k != "self" and v is not None}) logger.info(f"Arguments: {locals()}")
# Only allow intervals of 30 seconds or more so we don't spam the channel # Only allow intervals of 30 seconds or more so we don't spam the channel
if weeks == days == hours == minutes == 0 and seconds < 30: if weeks == days == hours == minutes == 0 and seconds < 30:
@ -673,18 +672,18 @@ class RemindGroup(discord.app_commands.Group):
# Can't be 0 because that's the default value for jobs without a guild # Can't be 0 because that's the default value for jobs without a guild
guild_id: int = interaction.guild.id if interaction.guild else -1 guild_id: int = interaction.guild.id if interaction.guild else -1
channels_in_this_guild: list[int] = [c.id for c in interaction.guild.channels] if interaction.guild else [] channels_in_this_guild: list[int] = [c.id for c in interaction.guild.channels] if interaction.guild else []
logger.debug("Guild ID: %s, Channels in this guild: %s", guild_id, channels_in_this_guild) logger.debug(f"Guild ID: {guild_id}")
for job in jobs_data.get("jobs", []): for job in jobs_data.get("jobs", []):
# Check if the job is in the current guild # Check if the job is in the current guild
job_guild_id = job.get("kwargs", {}).get("guild_id", 0) job_guild_id = job.get("kwargs", {}).get("guild_id", 0)
if job_guild_id and job_guild_id != guild_id: if job_guild_id and job_guild_id != guild_id:
logger.debug("Removing job: %s because it's not in the current guild. %s vs %s", job.get("id"), job_guild_id, guild_id) logger.debug(f"Removing job: {job.get('id')} because it's not in the current guild.")
jobs_data["jobs"].remove(job) jobs_data["jobs"].remove(job)
# Check if the channel is in the current guild # Check if the channel is in the current guild
if job.get("kwargs", {}).get("channel_id") not in channels_in_this_guild: if job.get("kwargs", {}).get("channel_id") not in channels_in_this_guild:
logger.debug("Removing job: %s because it's not in the current guild's channels.", job.get("id")) logger.debug(f"Removing job: {job.get('id')} because it's not in the current guild.")
jobs_data["jobs"].remove(job) jobs_data["jobs"].remove(job)
msg: str = "All reminders in this server have been backed up." if not all_servers else "All reminders have been backed up." msg: str = "All reminders in this server have been backed up." if not all_servers else "All reminders have been backed up."
@ -712,7 +711,7 @@ class RemindGroup(discord.app_commands.Group):
""" """
await interaction.response.defer() await interaction.response.defer()
logger.info("Restoring reminders from file for %s (%s) in %s", interaction.user, interaction.user.id, interaction.channel) logger.info(f"Restoring reminders from file for {interaction.user} ({interaction.user.id}) in {interaction.channel}")
# Get the old jobs # Get the old jobs
old_jobs: list[Job] = scheduler.get_jobs() old_jobs: list[Job] = scheduler.get_jobs()
@ -755,7 +754,7 @@ class RemindGroup(discord.app_commands.Group):
# Save the file to a temporary file and import the jobs # Save the file to a temporary file and import the jobs
with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding="utf-8", suffix=".json") as temp_file: with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding="utf-8", suffix=".json") as temp_file:
logger.info("Saving attachment to %s", temp_file.name) logger.info(f"Saving attachment to {temp_file.name}")
await attachment.save(Path(temp_file.name)) await attachment.save(Path(temp_file.name))
# Load the jobs data from the file # Load the jobs data from the file
@ -763,7 +762,7 @@ class RemindGroup(discord.app_commands.Group):
jobs_data: dict = json.load(temp_file) jobs_data: dict = json.load(temp_file)
logger.info("Importing jobs from file") logger.info("Importing jobs from file")
logger.debug("Jobs data: %s", jobs_data) logger.debug(f"Jobs data: {jobs_data}")
with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding="utf-8", suffix=".json") as temp_import_file: with tempfile.NamedTemporaryFile(mode="w+", delete=False, encoding="utf-8", suffix=".json") as temp_import_file:
# We can't import jobs with the same ID so remove them from the JSON # We can't import jobs with the same ID so remove them from the JSON
@ -771,10 +770,10 @@ class RemindGroup(discord.app_commands.Group):
jobs_already_exist = [job.get("id") for job in jobs_data.get("jobs", []) if scheduler.get_job(job.get("id"))] jobs_already_exist = [job.get("id") for job in jobs_data.get("jobs", []) if scheduler.get_job(job.get("id"))]
jobs_data["jobs"] = jobs jobs_data["jobs"] = jobs
for job_id in jobs_already_exist: for job_id in jobs_already_exist:
logger.debug("Removed job: %s because it already exists.", job_id) logger.debug(f"Removed job: {job_id} because it already exists.")
logger.debug("Jobs data after removing existing jobs: %s", jobs_data) logger.debug(f"Jobs data after removing existing jobs: {jobs_data}")
logger.info("Jobs already exist: %s", jobs_already_exist) logger.info(f"Jobs already exist: {jobs_already_exist}")
# Write the new data to a temporary file # Write the new data to a temporary file
json.dump(jobs_data, temp_import_file) json.dump(jobs_data, temp_import_file)
@ -823,7 +822,7 @@ def send_webhook(url: str = "", message: str = "") -> None:
if not url: if not url:
url = get_webhook_url() url = get_webhook_url()
logger.error("No webhook URL provided. Using the one from settings.") logger.error(f"No webhook URL provided. Using the one from settings: {url}")
webhook: DiscordWebhook = DiscordWebhook( webhook: DiscordWebhook = DiscordWebhook(
url=url, url=url,
username="discord-reminder-bot", username="discord-reminder-bot",
@ -851,7 +850,7 @@ async def send_to_discord(channel_id: int, message: str, author_id: int) -> None
# Channels we can't send messages to # Channels we can't send messages to
if isinstance(channel, discord.ForumChannel | discord.CategoryChannel | PrivateChannel): if isinstance(channel, discord.ForumChannel | discord.CategoryChannel | PrivateChannel):
logger.warning("We haven't implemented sending messages to this channel type (%s)", type(channel)) logger.warning(f"We haven't implemented sending messages to this channel type {type(channel)}")
return return
await channel.send(f"<@{author_id}>\n{message}") await channel.send(f"<@{author_id}>\n{message}")
@ -865,17 +864,17 @@ async def send_to_user(user_id: int, guild_id: int, message: str) -> None:
guild_id: The guild ID to get the user from. guild_id: The guild ID to get the user from.
message: The message to send. message: The message to send.
""" """
logger.info("Sending message to user %s in guild %s:\n%s", user_id, guild_id, message) logger.info(f"Sending message to user {user_id} in guild {guild_id}")
try: try:
guild: discord.Guild | None = bot.get_guild(guild_id) guild: discord.Guild | None = bot.get_guild(guild_id)
if guild is None: if guild is None:
guild = await bot.fetch_guild(guild_id) guild = await bot.fetch_guild(guild_id)
except discord.NotFound: except discord.NotFound:
current_guilds: Sequence[discord.Guild] = bot.guilds current_guilds: Sequence[discord.Guild] = bot.guilds
logger.exception("Guild not found. Current guilds: %s", current_guilds) logger.exception(f"Guild not found. Current guilds: {current_guilds}")
return return
except discord.HTTPException: except discord.HTTPException:
logger.exception("Failed to fetch guild") logger.exception(f"Failed to fetch guild {guild_id}")
return return
try: try:
@ -883,21 +882,21 @@ async def send_to_user(user_id: int, guild_id: int, message: str) -> None:
if member is None: if member is None:
member = await guild.fetch_member(user_id) member = await guild.fetch_member(user_id)
except discord.Forbidden: except discord.Forbidden:
logger.exception("We do not have access to the guild. Guild: %s, User: %s", guild_id, user_id) logger.exception(f"We do not have access to the guild. Guild: {guild_id}, User: {user_id}")
return return
except discord.NotFound: except discord.NotFound:
logger.exception("Member not found. Guild: %s, User: %s", guild_id, user_id) logger.exception(f"Member not found. Guild: {guild_id}, User: {user_id}")
return return
except discord.HTTPException: except discord.HTTPException:
logger.exception("Fetching the member failed. Guild: %s, User: %s", guild_id, user_id) logger.exception(f"Fetching the member failed. Guild: {guild_id}, User: {user_id}")
return return
try: try:
await member.send(message) await member.send(message)
except discord.HTTPException: except discord.HTTPException:
logger.exception("Failed to send message (%s) to user (%s) in guild (%s)", message, user_id, guild_id) logger.exception(f"Failed to send message '{message}' to user '{user_id}' in guild '{guild_id}'")
if __name__ == "__main__": if __name__ == "__main__":
bot_token: str = get_bot_token() bot_token: str = get_bot_token()
bot.run(bot_token, root_logger=True) bot.run(bot_token)

View File

@ -1,17 +1,15 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from apscheduler.triggers.date import DateTrigger from apscheduler.triggers.date import DateTrigger
from loguru import logger
if TYPE_CHECKING: if TYPE_CHECKING:
import datetime import datetime
from apscheduler.job import Job from apscheduler.job import Job
logger: logging.Logger = logging.getLogger(__name__)
def calculate(job: Job) -> str | None: def calculate(job: Job) -> str | None:
"""Calculate the time left for a job. """Calculate the time left for a job.
@ -26,8 +24,8 @@ def calculate(job: Job) -> str | None:
# Check if the job is paused # Check if the job is paused
if trigger_time is None: if trigger_time is None:
logger.error("Couldn't calculate time for job: %s: %s", job.id, job.name) logger.error(f"Couldn't calculate time for job: {job.id}")
logger.error("State: %s", job.__getstate__() if hasattr(job, "__getstate__") else "No state") logger.error(f"State: {job.__getstate__() if hasattr(job, '__getstate__') else 'No state'}")
return None return None
return f"<t:{int(trigger_time.timestamp())}:R>" return f"<t:{int(trigger_time.timestamp())}:R>"

View File

@ -1,15 +1,13 @@
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import logging
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import dateparser import dateparser
from loguru import logger
from discord_reminder_bot.settings import get_timezone from discord_reminder_bot.settings import get_timezone
logger: logging.Logger = logging.getLogger(__name__)
def parse_time(date_to_parse: str | None, timezone: str | None = None, use_dotenv: bool = True) -> datetime.datetime | None: # noqa: FBT001, FBT002 def parse_time(date_to_parse: str | None, timezone: str | None = None, use_dotenv: bool = True) -> datetime.datetime | None: # noqa: FBT001, FBT002
"""Parse a date string into a datetime object. """Parse a date string into a datetime object.
@ -22,7 +20,7 @@ def parse_time(date_to_parse: str | None, timezone: str | None = None, use_doten
Returns: Returns:
datetime.datetime: The parsed datetime object. datetime.datetime: The parsed datetime object.
""" """
logger.info("Parsing date: '%s' with timezone: '%s'", date_to_parse, timezone) logger.info(f"Parsing date: '{date_to_parse}' with timezone: '{timezone}'")
if not date_to_parse: if not date_to_parse:
logger.error("No date provided to parse.") logger.error("No date provided to parse.")
@ -35,7 +33,7 @@ def parse_time(date_to_parse: str | None, timezone: str | None = None, use_doten
try: try:
tz = ZoneInfo(timezone) tz = ZoneInfo(timezone)
except (ZoneInfoNotFoundError, ModuleNotFoundError): except (ZoneInfoNotFoundError, ModuleNotFoundError):
logger.error("Invalid timezone provided: '%s'. Using default timezone: '%s'", timezone, get_timezone(use_dotenv)) # noqa: TRY400 logger.error(f"Invalid timezone provided: '{timezone}'. Using {get_timezone(use_dotenv)} instead.")
tz = ZoneInfo("UTC") tz = ZoneInfo("UTC")
try: try:
@ -51,4 +49,6 @@ def parse_time(date_to_parse: str | None, timezone: str | None = None, use_doten
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
logger.debug(f"Parsed date: {parsed_date} from '{date_to_parse}'")
return parsed_date return parsed_date

View File

@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
import discord import discord
@ -10,6 +9,7 @@ from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.cron import CronTrigger
from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.interval import IntervalTrigger
from discord.ui import Button, Select from discord.ui import Button, Select
from loguru import logger
from discord_reminder_bot.misc import DateTrigger, calc_time, calculate from discord_reminder_bot.misc import DateTrigger, calc_time, calculate
from discord_reminder_bot.parser import parse_time from discord_reminder_bot.parser import parse_time
@ -21,9 +21,6 @@ if TYPE_CHECKING:
from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.schedulers.asyncio import AsyncIOScheduler
logger: logging.Logger = logging.getLogger(__name__)
class ModifyJobModal(discord.ui.Modal, title="Modify Job"): class ModifyJobModal(discord.ui.Modal, title="Modify Job"):
"""Modal for modifying a job.""" """Modal for modifying a job."""
@ -53,12 +50,12 @@ class ModifyJobModal(discord.ui.Modal, title="Modify Job"):
self.job_name.placeholder = self.job.kwargs.get("message", "No message found") self.job_name.placeholder = self.job.kwargs.get("message", "No message found")
self.job_date.placeholder = self.job.next_run_time.strftime("%Y-%m-%d %H:%M:%S %Z") self.job_date.placeholder = self.job.next_run_time.strftime("%Y-%m-%d %H:%M:%S %Z")
logger.info("Job '%s' Modal created", self.job.name) logger.info(f"Job '{job_name_label}' modified: Initializing modal")
logger.info("\tCurrent date: '%s'", self.job.next_run_time) logger.info(f"\tCurrent date: '{self.job.next_run_time}'")
logger.info("\tCurrent message: '%s'", self.job.kwargs.get("message", "N/A")) logger.info(f"\tCurrent message: '{self.job.kwargs.get('message', 'No message found')}")
logger.info("\tName label: '%s'", self.job_name.label) logger.info(f"\tName label: '{self.job_name.label}'")
logger.info("\tDate label: '%s'", self.job_date.label) logger.info(f"\tDate label: '{self.job_date.label}'")
async def on_submit(self, interaction: discord.Interaction) -> None: async def on_submit(self, interaction: discord.Interaction) -> None:
"""Submit the job modifications. """Submit the job modifications.
@ -66,27 +63,29 @@ class ModifyJobModal(discord.ui.Modal, title="Modify Job"):
Args: Args:
interaction: The interaction object for the command. interaction: The interaction object for the command.
""" """
logger.info("Job '%s' modified: Submitting changes", self.job.name) job_msg: str = self.job.kwargs.get("message", "No message found")
logger.info(f"Job '{job_msg}' modified: Submitting changes")
new_name: str = self.job_name.value new_name: str = self.job_name.value
new_date_str: str = self.job_date.value new_date_str: str = self.job_date.value
old_date: datetime.datetime = self.job.next_run_time old_date: datetime.datetime = self.job.next_run_time
# if both are empty, do nothing # if both are empty, do nothing
if not new_name and not new_date_str: if not new_name and not new_date_str:
logger.info("Job '%s' modified: No changes submitted", self.job.name) logger.info(f"Job '{job_msg}' modified: No changes submitted.")
await interaction.response.send_message( await interaction.response.send_message(
content=f"Job **{self.job.name}** was not modified by {interaction.user.mention}.\nNo changes submitted.", content=f"Job **{job_msg}**.\nNo changes submitted.",
ephemeral=True,
) )
return return
if new_date_str and new_date_str != old_date.strftime("%Y-%m-%d %H:%M:%S %Z"): if new_date_str and new_date_str != old_date.strftime("%Y-%m-%d %H:%M:%S %Z"):
new_date: datetime.datetime | None = parse_time(new_date_str) new_date: datetime.datetime | None = parse_time(new_date_str)
if not new_date: if not new_date:
logger.error("Job '%s' modified: Failed to parse date: '%s'", self.job.name, new_date_str) logger.error(f"Job '{job_msg}' modified: Failed to parse date: '{new_date_str}'")
await interaction.response.send_message( await interaction.response.send_message(
content=( content=(
f"Failed modifying job **{self.job.name}**\n" f"Failed modifying job **{job_msg}**\n"
f"Job ID: **{self.job.id}**\n" f"Job ID: **{self.job.id}**\n"
f"Failed to parse date: **{new_date_str}**\n" f"Failed to parse date: **{new_date_str}**\n"
f"Defaulting to old date: **{old_date.strftime('%Y-%m-%d %H:%M:%S')}** {calc_time(old_date)}" f"Defaulting to old date: **{old_date.strftime('%Y-%m-%d %H:%M:%S')}** {calc_time(old_date)}"
@ -94,8 +93,8 @@ class ModifyJobModal(discord.ui.Modal, title="Modify Job"):
) )
return return
logger.info("Job '%s' modified: New date: '%s'", self.job.name, new_date) logger.info(f"Job '{job_msg}' modified: New date: '{new_date}'")
logger.info("Job '%s' modified: Old date: '%s'", self.job.name, old_date) logger.info(f"Job '{job_msg}' modified: Old date: '{old_date}'")
self.job.modify(next_run_time=new_date) self.job.modify(next_run_time=new_date)
old_date_str: str = old_date.strftime("%Y-%m-%d %H:%M:%S") old_date_str: str = old_date.strftime("%Y-%m-%d %H:%M:%S")
@ -103,16 +102,16 @@ class ModifyJobModal(discord.ui.Modal, title="Modify Job"):
await interaction.response.send_message( await interaction.response.send_message(
content=( content=(
f"Job **{self.job.name}** was modified by {interaction.user.mention}:\n" f"Job **{job_msg}** was modified by {interaction.user.mention}:\n"
f"Job ID: **{self.job.id}**\n" f"Job ID: **{self.job.id}**\n"
f"Old date: **{old_date_str}** {calculate(self.job)} {calc_time(old_date)}\n" f"Old date: **{old_date_str}** {calculate(self.job)} {calc_time(old_date)}\n"
f"New date: **{new_date_str}** {calculate(self.job)} {calc_time(new_date)}" f"New date: **{new_date_str}** {calculate(self.job)} {calc_time(new_date)}"
), ),
) )
if self.job_name.value and self.job.name != new_name: if self.job_name.value and job_msg != new_name:
logger.info("Job '%s' modified: New name: '%s'", self.job.name, new_name) logger.info(f"Job '{job_msg}' modified: New name: '{new_name}'")
logger.info("Job '%s' modified: Old name: '%s'", self.job.name, self.job.name) logger.info(f"Job '{job_msg}' modified: Old name: '{job_msg}'")
self.job.modify(name=new_name) self.job.modify(name=new_name)
await interaction.response.send_message( await interaction.response.send_message(
@ -187,12 +186,12 @@ class JobSelector(Select):
for job in jobs: for job in jobs:
# If the job has guild_id and it's not the current guild, skip it # If the job has guild_id and it's not the current guild, skip it
if job.kwargs.get("guild_id") and job.kwargs.get("guild_id") != guild.id: if job.kwargs.get("guild_id") and job.kwargs.get("guild_id") != guild.id:
logger.debug("Skipping job: %s because it's not in the current guild.", job.id) logger.debug(f"Skipping job: {job.id} because it's not in the current guild.")
continue continue
# If the job has channel_id and it's not in the current guild, skip it # If the job has channel_id and it's not in the current guild, skip it
if job.kwargs.get("channel_id") and job.kwargs.get("channel_id") not in list_of_channels_in_current_guild: if job.kwargs.get("channel_id") and job.kwargs.get("channel_id") not in list_of_channels_in_current_guild:
logger.debug("Skipping job: %s because it's not in the current guild's channels.", job.id) logger.debug(f"Skipping job: {job.id} because it's not from a channel in the current guild.")
continue continue
jobs_in_guild.append(job) jobs_in_guild.append(job)
@ -252,14 +251,14 @@ class JobManagementView(discord.ui.View):
self.add_item(JobSelector(scheduler, self.guild)) self.add_item(JobSelector(scheduler, self.guild))
self.update_buttons() self.update_buttons()
logger.debug("JobManagementView created for job: %s", job.id) logger.debug(f"JobManagementView created for job: {self.job.id}")
async def on_timeout(self) -> None: async def on_timeout(self) -> None:
"""Handle the view timeout.""" """Handle the view timeout."""
if self.message: if self.message:
await self.message.edit(content="`/remind list` timed out.", embed=None, view=None) await self.message.edit(content="`/remind list` timed out.", embed=None, view=None)
else: else:
logger.debug("No message to edit for job: %s", self.job.id) logger.debug(f"No message to edit for job: {self.job.id}")
self.stop() self.stop()
async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item) -> None: async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item) -> None:
@ -312,9 +311,9 @@ class JobManagementView(discord.ui.View):
""" """
job_kwargs: dict = self.job.kwargs or {} job_kwargs: dict = self.job.kwargs or {}
logger.info("Deleting job: %s because %s clicked the button.", self.job.id, interaction.user.name) logger.info(f"Deleting job: {self.job.id}. Clicked by {interaction.user.name}")
if hasattr(self.job, "__getstate__"): if hasattr(self.job, "__getstate__"):
logger.debug("State: %s", self.job.__getstate__() if hasattr(self.job, "__getstate__") else "No state") logger.debug(f"State: {self.job.__getstate__() if hasattr(self.job, '__getstate__') else 'No state'}")
job_msg: str | int = job_kwargs.get("message", "No message found") job_msg: str | int = job_kwargs.get("message", "No message found")
msg: str = f"**Job '{job_msg}' has been deleted.**\n" msg: str = f"**Job '{job_msg}' has been deleted.**\n"
@ -346,7 +345,7 @@ class JobManagementView(discord.ui.View):
if job_kwargs.get("guild_id"): if job_kwargs.get("guild_id"):
msg += f"**Guild**: {job_kwargs.get('guild_id')}\n" msg += f"**Guild**: {job_kwargs.get('guild_id')}\n"
logger.debug("Deletion message: %s", msg) logger.debug(f"Deletion message: {msg}")
self.job.remove() self.job.remove()
await interaction.response.send_message(msg) await interaction.response.send_message(msg)
@ -360,9 +359,9 @@ class JobManagementView(discord.ui.View):
interaction: The interaction object for the command. interaction: The interaction object for the command.
button: The button that was clicked. button: The button that was clicked.
""" """
logger.info("Modifying job: %s. Clicked by %s", self.job.id, interaction.user.name) logger.info(f"Modifying job: {self.job.id}. Clicked by {interaction.user.name}")
if hasattr(self.job, "__getstate__"): if hasattr(self.job, "__getstate__"):
logger.debug("State: %s", self.job.__getstate__() if hasattr(self.job, "__getstate__") else "No state") logger.debug(f"State: {self.job.__getstate__() if hasattr(self.job, '__getstate__') else 'No state'}")
modal = ModifyJobModal(self.job, self.scheduler) modal = ModifyJobModal(self.job, self.scheduler)
await interaction.response.send_modal(modal) await interaction.response.send_modal(modal)
@ -377,17 +376,17 @@ class JobManagementView(discord.ui.View):
""" """
if hasattr(self.job, "next_run_time"): if hasattr(self.job, "next_run_time"):
if self.job.next_run_time is None: if self.job.next_run_time is None:
logger.info("State: %s", self.job.__getstate__()) logger.info(f"State: {self.job.__getstate__() if hasattr(self.job, '__getstate__') else 'No state'}")
self.job.resume() self.job.resume()
status = "resumed" status = "resumed"
button.label = "Pause" button.label = "Pause"
else: else:
logger.info("State: %s", self.job.__getstate__()) logger.info(f"State: {self.job.__getstate__() if hasattr(self.job, '__getstate__') else 'No state'}")
self.job.pause() self.job.pause()
status = "paused" status = "paused"
button.label = "Resume" button.label = "Resume"
else: else:
logger.error("Got a job without a next_run_time: %s", self.job.id) logger.error(f"Got a job without a next_run_time: {self.job.id}")
status: str = f"What is this? {self.job.__getstate__()}" status: str = f"What is this? {self.job.__getstate__()}"
button.label = "What?" button.label = "What?"
@ -409,11 +408,11 @@ class JobManagementView(discord.ui.View):
def update_buttons(self) -> None: def update_buttons(self) -> None:
"""Update the visibility of buttons based on job status.""" """Update the visibility of buttons based on job status."""
logger.debug("Updating buttons for job: %s", self.job.id) logger.debug(f"Updating buttons for job: {self.job.id}")
self.pause_button.label = "Resume" if self.job.next_run_time is None else "Pause" self.pause_button.label = "Resume" if self.job.next_run_time is None else "Pause"
logger.debug("Pause button disabled: %s", self.pause_button.disabled) logger.debug(f"Pause button disabled: {self.pause_button.disabled}")
logger.debug("Pause button label: %s", self.pause_button.label) logger.debug(f"Pause button label: {self.pause_button.label}")
async def interaction_check(self, interaction: discord.Interaction) -> bool: # noqa: ARG002 async def interaction_check(self, interaction: discord.Interaction) -> bool: # noqa: ARG002
"""Check the interaction and update buttons before responding. """Check the interaction and update buttons before responding.
@ -424,11 +423,11 @@ class JobManagementView(discord.ui.View):
Returns: Returns:
bool: Whether the interaction is valid. bool: Whether the interaction is valid.
""" """
logger.info("Interaction check for job: %s", self.job.id) logger.info(f"Interaction check for job: {self.job.id}")
logger.debug("Timeout was %s before interaction check", self.timeout) logger.debug(f"Timeout was {self.timeout} before interaction check.")
self.timeout = 30 self.timeout = 30
logger.debug("Checking interaction for job: %s", self.job.id) logger.debug(f"Checking interaction for job: {self.job.id}")
self.update_buttons() self.update_buttons()
return True return True

View File

@ -30,6 +30,9 @@ dependencies = [
# For error tracking # For error tracking
"sentry-sdk>=2.20.0,<3.0.0", # https://github.com/getsentry/sentry-python "sentry-sdk>=2.20.0,<3.0.0", # https://github.com/getsentry/sentry-python
# For logging
"loguru>=0.7.3,<1.0.0", # https://github.com/Delgan/loguru
] ]
[dependency-groups] [dependency-groups]
@ -75,9 +78,14 @@ discord-webhook = {version = ">=1.3.1,<2.0.0"}
# For loading environment variables from a .env file # For loading environment variables from a .env file
python-dotenv = {version = ">=1.0.1,<2.0.0"} python-dotenv = {version = ">=1.0.1,<2.0.0"}
# https://github.com/getsentry/sentry-python
# For error tracking # For error tracking
sentry-sdk = {version = ">=2.20.0,<3.0.0"} sentry-sdk = {version = ">=2.20.0,<3.0.0"}
# https://github.com/Delgan/loguru
# For logging
loguru = {version = ">=0.7.3,<1.0.0"}
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "*" pytest = "*"
pre-commit = "*" pre-commit = "*"