diff --git a/bot.py b/bot.py index 5576a951..682c9dbe 100644 --- a/bot.py +++ b/bot.py @@ -25,7 +25,7 @@ import ircrelay import ui from botlogging import LogContext, LogLevel -from core import auxiliary, custom_errors, databases, http +from core import auxiliary, custom_errors, databases, http, scheduler loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) @@ -174,6 +174,10 @@ async def setup_hook(self: Self) -> None: # Adds persistent views to the bot self.add_view(ui.VotingButtonPersistent()) + # Make the scheduler + # We must wait for tasks to be registered to start it + self.scheduler = scheduler.SchedulerService(self) + # The very last step should be loading extensions # Some extensions will require the database or config when loading await self.logger.send_log( @@ -182,6 +186,9 @@ async def setup_hook(self: Self) -> None: self.extension_name_list = [] await self.load_extensions() + # Star the scheduler + await self.scheduler.start() + async def on_guild_remove(self: Self, guild: discord.Guild) -> None: """See: https://discordpy.readthedocs.io/en/latest/api.html#discord.on_guild_remove diff --git a/changelog.md b/changelog.md index eb899500..f76e612c 100644 --- a/changelog.md +++ b/changelog.md @@ -1,6 +1,7 @@ Changes since 2026.06.15 # Core +- Create a new scheduling system, to replace LoopCog. # Modules diff --git a/core/scheduler.py b/core/scheduler.py new file mode 100644 index 00000000..d10e3bfd --- /dev/null +++ b/core/scheduler.py @@ -0,0 +1,190 @@ +""" +Biggest issues I still want to look at: +Generalizing the setup for the scheduler. +Removing guild from the execution, putting it in the payload +Creating a way to reset specifc tasks, especially after a config change +""" + +from __future__ import annotations + +import datetime +import random +import uuid +from typing import TYPE_CHECKING, Self + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger + +if TYPE_CHECKING: + import bot + + +class SchedulerService: + """This is the scheduler service + This schedules a given task and runs it at later date + + Args: + bot (bot.TechSupportBot): The running bot object + """ + + def __init__(self: Self, bot: bot.TechSupportBot) -> None: + self.bot = bot + self.scheduler = AsyncIOScheduler() + self.tasks = {} # task_name -> coroutine + + async def start(self: Self) -> None: + """ + Start scheduler. + We should only start the scheduler after tasks have been registered + """ + self.scheduler.start() + + def register_task(self: Self, name: str, func: callable) -> None: + """This registers a callback location for a scheduled tasks + Modules wishing to schedule tasks should call this to setup tasks first + + Args: + name (str): The globally unique name of a task + func (callable): The function to call when the task executes + """ + self.tasks[name] = func + + # Schedulers to be called by cogs + + async def schedule_date( + self: Self, + task_name: str, + run_at: datetime.datetime, + payload: dict, + ) -> str: + """This schedules a task at a particular date in the future + + Args: + task_name (str): The name of the task to register + run_at (datetime.datetime): The time to run this task + payload (dict): The data needed to run this task. + May include channels, guilds, strings, members, etc + + Raises: + AttributeError: Raised if the job being scheduled hasn't been registered + + Returns: + str: The job ID number created for this job + """ + + job_id = f"{task_name}:{uuid.uuid4()}" + + handler = self.tasks.get(task_name) + if not handler: + raise AttributeError(f"Missing task for {task_name}") + + self.scheduler.add_job( + func=handler, + trigger=DateTrigger(run_date=run_at), + args=[payload], + id=job_id, + replace_existing=True, + ) + + return job_id + + async def schedule_delay( + self: Self, + task_name: str, + seconds: int, + payload: dict, + ) -> str: + """This schedules a task in the future a given amount of seconds + + Args: + task_name (str): The name of the task to register + seconds (int): The amount of seconds to schedule the task into the future + payload (dict): The data needed to run this task. + May include channels, guilds, strings, members, etc + + Returns: + str: The job ID number created for this job + """ + + run_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) + + return await self.schedule_date(task_name, run_at, payload) + + async def schedule_cron( + self: Self, + task_name: str, + cron: str, + payload: dict, + ) -> str: + """This schedules a task based on the next execution of a given cron + + Args: + task_name (str): The name of the task to register + cron (str): The crontab syntax for the job + payload (dict): The data needed to run this task. + May include channels, guilds, strings, members, etc + + Raises: + ValueError: Raised if the passed crontab is invalid + + Returns: + str: The job ID number created for this job + """ + + trigger = CronTrigger.from_crontab(cron) + + now = datetime.datetime.utcnow() + run_at = trigger.get_next_fire_time(None, now) + + if run_at is None: + raise ValueError("Invalid cron expression") + + return await self.schedule_date(task_name, run_at, payload) + + async def schedule_random( + self: Self, + task_name: str, + min_hours: float, + max_hours: float, + payload: dict, + ) -> str: + """This schedules a task based on a randomly picked time + + Args: + task_name (str): The name of the task to register + min_hours (float): The minimum number of hours to wait + max_hours (float): The maximum number of hours to wait + payload (dict): The data needed to run this task. + May include channels, guilds, strings, members, etc + + Returns: + str: The job ID number created for this job + """ + + seconds = random.uniform(min_hours * 3600, max_hours * 3600) + run_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) + + return await self.schedule_date(task_name, run_at, payload) + + # Getting tasks and other internal functions + + async def get_upcoming_tasks(self: Self) -> list[dict]: + """This gets a list of all upcoming tasks in the scheduler, to allow for parsing + This includes the ID, the payload, and the run_at time + + Returns: + list[dict]: The list of upcoming tasks + """ + + return sorted( + [ + { + "job_id": job.id, + "payload": job.args[0], + "run_at": job.next_run_time, + } + for job in self.scheduler.get_jobs() + ], + key=lambda x: x["run_at"], + ) diff --git a/main.py b/main.py index f09889b5..4fd771d7 100644 --- a/main.py +++ b/main.py @@ -10,6 +10,7 @@ MODULE_LOG_LEVELS = { "discord": logging.INFO, "gino": logging.WARNING, + "apscheduler": logging.WARNING, } for module_name, level in MODULE_LOG_LEVELS.items(): diff --git a/modules/fun/duck.py b/modules/fun/duck.py index e37e3f71..e5f12c04 100644 --- a/modules/fun/duck.py +++ b/modules/fun/duck.py @@ -32,7 +32,7 @@ async def setup(bot: bot.TechSupportBot) -> None: await bot.add_cog(DuckHunt(bot=bot)) -class DuckHunt(cogs.LoopCog): +class DuckHunt(cogs.BaseCog): """Class for the actual duck commands Attributes: @@ -62,72 +62,115 @@ class DuckHunt(cogs.LoopCog): ON_START: bool = False CHANNELS_KEY: str = "duck_hunt_channels" - async def loop_preconfig(self: Self) -> None: + async def preconfig(self: Self) -> None: """Preconfig for cooldowns""" self.cooldowns = {} - # "guild_id": datetime - self.next_duck: dict[str, datetime.datetime] = {} + # Scheduled task stuff + self.bot.scheduler.register_task( + "duck_hunt_game", + self.run_duck_hunt, + ) + + # Start the initial tasks + for guild in self.bot.guilds: + for channel_id in configuration.get_config_entry( + guild.id, self.CHANNELS_KEY + ): + channel = guild.get_channel(int(channel_id)) + await self.schedule_duck_hunt(guild, channel) - async def wait(self: Self, guild: discord.Guild) -> None: + # Loop Stuff + + async def schedule_duck_hunt( + self: Self, guild: discord.Guild, channel: discord.abc.GuildChannel + ) -> None: """Waits a random amount of time before sending another duck This function shouldn't be manually called Args: guild (discord.Guild): The guild where the duck is going to appear + channel (discord.abc.GuildChannel): This is the channel the duck is going to appear in """ - min_wait = configuration.get_config_entry(guild.id, "duck_min_wait") * 3600 - max_wait = configuration.get_config_entry(guild.id, "duck_max_wait") * 3600 + min_wait = configuration.get_config_entry(guild.id, "duck_min_wait") + max_wait = configuration.get_config_entry(guild.id, "duck_max_wait") - fuzzed_min = int(min_wait * random.uniform(0.9, 1.1)) - fuzzed_max = int(max_wait * random.uniform(0.9, 1.1)) + fuzzed_min = float(min_wait * random.uniform(0.9, 1.1)) + fuzzed_max = float(max_wait * random.uniform(0.9, 1.1)) if fuzzed_min > fuzzed_max: fuzzed_min, fuzzed_max = fuzzed_max, fuzzed_min - wait_time = random.randint(fuzzed_min, fuzzed_max) + # Only schedule if the extension is enabled + if not self.extension_enabled(guild): + return - self.next_duck[str(guild.id)] = datetime.datetime.now() + datetime.timedelta( - seconds=wait_time + await self.bot.scheduler.schedule_random( + task_name="duck_hunt_game", + max_hours=fuzzed_max, + min_hours=fuzzed_min, + payload={"guild": guild, "channel": channel}, ) - await asyncio.sleep(wait_time) - - async def execute( - self: Self, - guild: discord.Guild, - channel: discord.TextChannel, - banned_user: discord.User = None, - ) -> None: - """Sends a duck in the given channel - Can be manually called, and will be called automatically after wait() + async def run_duck_hunt(self: Self, payload: dict) -> None: + """This runs a game of duck hunt + This function should only ever be called by the scheduler Args: - guild (discord.Guild): The guild where the duck is going - channel (discord.TextChannel): The channel to spawn the duck in - banned_user (discord.User, optional): A user that is not allowed to claim the duck. - Defaults to None. + payload (dict): A dictionary containing the guild and channel data + passed from the scheduler """ + guild: discord.Guild = payload["guild"] + channel: discord.abc.GuildChannel = payload["channel"] + if not channel: log_channel = configuration.get_config_entry( guild.id, "core_logging_channel" ) await self.bot.logger.send_log( - message="Channel not found for Duckhunt loop - continuing", + message="Channel not found for Duckhunt loop - ending schedule", level=LogLevel.WARNING, context=LogContext(guild=guild), channel=log_channel, ) return + # Only execute if the extension is enabled + if not self.extension_enabled(guild): + return + + # Only execute if channel is in config + if not str(channel.id) in configuration.get_config_entry( + guild.id, self.CHANNELS_KEY + ): + return + + # If this is set, we assume the config has a list of categories if configuration.get_config_entry(guild.id, "duck_use_category"): - all_valid_channels = channel.category.text_channels - use_channel = random.choice(all_valid_channels) + use_channel = random.choice(channel.text_channels) else: use_channel = channel + await self.spawn_duck(guild, use_channel) + + # Reschedule task after duck game has ended + await self.schedule_duck_hunt(guild, channel) + + async def spawn_duck( + self: Self, + guild: discord.Guild, + channel: discord.TextChannel, + banned_user: discord.User = None, + ) -> None: + """This spawns a duck in the passed channel + + Args: + guild (discord.Guild): The guild in which to spawn a duck + channel (discord.TextChannel): The channel in which to spawn a duck + banned_user (discord.User, optional): If a user is banned from + participating in this duck hunt instance. Defaults to None. + """ self.cooldowns[guild.id] = {} - del self.next_duck[str(guild.id)] embed = discord.Embed( title="*Quack Quack*", @@ -136,7 +179,7 @@ async def execute( embed.set_image(url=self.DUCK_PIC_URL) embed.color = discord.Color.green() - duck_message = await use_channel.send(embed=embed) + duck_message = await channel.send(embed=embed) start_time = duck_message.created_at response_message = None @@ -146,7 +189,7 @@ async def execute( timeout=configuration.get_config_entry(guild.id, "duck_timeout"), # can't pull the config in a non-coroutine check=functools.partial( - self.message_check, use_channel, duck_message, banned_user + self.message_check, channel, duck_message, banned_user ), ) except asyncio.TimeoutError: @@ -158,7 +201,7 @@ async def execute( await self.bot.logger.send_log( message="Exception thrown waiting for duckhunt input", level=LogLevel.ERROR, - context=LogContext(guild=guild, channel=use_channel), + context=LogContext(guild=guild, channel=channel), channel=log_channel, exception=exception, ) @@ -171,10 +214,10 @@ async def execute( "befriended" if response_message.content.lower() == "bef" else "killed" ) await self.handle_winner( - response_message.author, guild, action, raw_duration, use_channel + response_message.author, guild, action, raw_duration, channel ) else: - await self.got_away(use_channel) + await self.got_away(channel) async def got_away(self: Self, channel: discord.TextChannel) -> None: """Sends a message telling everyone the duck got away @@ -432,26 +475,56 @@ async def get_global_record(self: Self, guild_id: int) -> float: @app_commands.checks.has_permissions(administrator=True) @duck_group.command( name="next", - description="Displays the time for the next duck for this guild", + description="Displays the time for the next ducks for this guild", ) - async def lookup_next_duck(self: Self, interaction: discord.Interaction) -> None: - """A simple command to show an admin when the next duck will be spawning + async def lookup_next_duck( + self: Self, + interaction: discord.Interaction, + ) -> None: + """Show an admin when the next ducks will spawn. Args: interaction (discord.Interaction): The interaction that called this command """ - if str(interaction.guild.id) not in self.next_duck: + await interaction.response.defer(ephemeral=True) + + upcoming_tasks = await self.bot.scheduler.get_upcoming_tasks() + + guild_ducks = [ + task + for task in upcoming_tasks + if task["job_id"].startswith("duck_hunt_game:") + and task["payload"]["guild"] == interaction.guild + ] + + if not guild_ducks: embed = auxiliary.prepare_deny_embed( - "Couldn't find a future duck for this guild." + "Couldn't find any future ducks for this guild." + ) + await interaction.followup.send( + embed=embed, + ephemeral=True, ) - await interaction.response.send_message(embed=embed, ephemeral=True) return - embed = auxiliary.prepare_confirm_embed( - "The next duck in this guild:" - f"" + guild_ducks.sort(key=lambda task: task["run_at"]) + + embed = auxiliary.prepare_confirm_embed("Upcoming ducks for this guild") + + for task in guild_ducks: + embed.add_field( + name=task["payload"]["channel"].mention, + value=( + f"\n" + f"()" + ), + inline=False, + ) + + await interaction.followup.send( + embed=embed, + ephemeral=True, ) - await interaction.response.send_message(embed=embed, ephemeral=True) @commands.group( brief="Executes a duck command", @@ -749,7 +822,7 @@ async def release(self: Self, ctx: commands.Context) -> None: channel=ctx.channel, ) - await self.execute(ctx.guild, ctx.channel, banned_user=ctx.author) + await self.spawn_duck(ctx.guild, ctx.channel, banned_user=ctx.author) @auxiliary.with_typing @commands.guild_only() @@ -944,7 +1017,7 @@ async def spawn(self: Self, ctx: commands.Context) -> None: spawn_user = configuration.get_config_entry(ctx.guild.id, "duck_spawn_user") for person in spawn_user: if ctx.author.id == int(person): - await self.execute(ctx.guild, ctx.channel, ctx.author) + await self.spawn_duck(ctx.guild, ctx.channel, ctx.author) return await auxiliary.send_deny_embed( message="It looks like you don't have permissions to spawn a duck", diff --git a/modules/operation/application.py b/modules/operation/application.py index 07725d2d..2e9f0185 100644 --- a/modules/operation/application.py +++ b/modules/operation/application.py @@ -6,7 +6,6 @@ from enum import Enum from typing import TYPE_CHECKING, Self -import aiocron import discord from discord import app_commands @@ -43,7 +42,6 @@ async def setup(bot: bot.TechSupportBot) -> None: bot (bot.TechSupportBot): The bot object to register the cogs to """ await bot.add_cog(ApplicationManager(bot=bot)) - await bot.add_cog(ApplicationNotifier(bot=bot)) async def command_permission_check(interaction: discord.Interaction) -> bool: @@ -86,55 +84,241 @@ async def command_permission_check(interaction: discord.Interaction) -> bool: return True -class ApplicationNotifier(cogs.LoopCog): - """This cog is soley tasked with looping the application reminder for users - Everything else is handled in ApplicationManager""" +class ApplicationManager(cogs.BaseCog): + """This cog is responsible for the majority of functions in the application system + + Attributes: + application_group (app_commands.Group): The group for the /application commands + """ + + application_group: app_commands.Group = app_commands.Group( + name="application", + description="...", + ) + + # Loop work - async def execute(self: Self, guild: discord.Guild) -> None: - """The function that executes the from the LoopCog structure + async def preconfig(self: Self) -> None: + """Register the scheduler task and schedule all guilds.""" + + # Register tasks into the scheduler system + self.bot.scheduler.register_task( + "application_notifier", + self.run_application_notifier, + ) + + self.bot.scheduler.register_task( + "application_manager", + self.run_application_manager, + ) + + # Start the initial tasks + for guild in self.bot.guilds: + await self.schedule_notifier_by_guild(guild) + await self.schedule_manager_by_guild(guild) + + async def schedule_notifier_by_guild( + self: Self, + guild: discord.Guild, + ) -> None: + """This schedules an application notifier for a given guild + This will check if the extension is enabled Args: - guild (discord.Guild): The guild the loop is executing for + guild (discord.Guild): The guild to schedule the application notifier in """ + + if not self.extension_enabled(guild): + return + + cron = configuration.get_config_entry( + guild.id, + "application_notification_cron_config", + ) + + await self.bot.scheduler.schedule_cron( + task_name="application_notifier", cron=cron, payload={"guild": guild} + ) + + async def schedule_manager_by_guild( + self: Self, + guild: discord.Guild, + ) -> None: + """This schedules an application manager for a given guild + This will check if the extension is enabled + + Args: + guild (discord.Guild): The guild to schedule the application manager in + """ + if not self.extension_enabled(guild): + return + + cron = configuration.get_config_entry( + guild.id, + "application_reminder_cron_config", + ) + + await self.bot.scheduler.schedule_cron( + task_name="application_manager", cron=cron, payload={"guild": guild} + ) + + async def run_application_notifier( + self: Self, + payload: dict, + ) -> None: + """This posts an application notification in every configured channel for the passed guild + + Args: + payload (dict): A dictionary containing a guild to run this job in + """ + # Expant the payload parameters + guild: discord.Guild = payload["guild"] + + # Ensure that the extension has not been disabled between schedule and execution + if not self.extension_enabled(guild): + return + + # This loop is recurring, reschedule it for this guild + await self.schedule_notifier_by_guild(guild) + channels = configuration.get_config_entry( - guild.id, "application_notification_channels" + guild.id, + "application_notification_channels", ) - for channel in channels: - channel = guild.get_channel(int(channel)) + + for channel_id in channels: + channel = guild.get_channel(int(channel_id)) + if not channel: continue await ui.AppNotice(timeout=None).send( channel=channel, message=configuration.get_config_entry( - guild.id, "application_application_message" + guild.id, + "application_application_message", ), ) - async def wait(self: Self, guild: discord.Guild) -> None: - """The function that causes the sleep/delay the from the LoopCog structure + async def run_application_manager(self: Self, payload: dict) -> None: + """The executes the reminder of pending applications Args: - guild (discord.Guild): The guild the loop is executing for + payload (dict): A dictionary containing a guild to run this job in """ - await aiocron.crontab( - configuration.get_config_entry( - guild.id, "application_notification_cron_config" + # Expant the payload parameters + guild: discord.Guild = payload["guild"] + + # Ensure that the extension has not been disabled between schedule and execution + if not self.extension_enabled(guild): + return + + # This loop is recurring, reschedule it for this guild + await self.schedule_manager_by_guild(guild) + + channel = guild.get_channel( + int( + configuration.get_config_entry( + guild.id, "application_management_channel" + ) ) - ).next() + ) + if not channel: + return + apps = await self.get_applications_by_status(ApplicationStatus.PENDING, guild) + if not apps: + return -class ApplicationManager(cogs.LoopCog): - """This cog is responsible for the majority of functions in the application system + # Update the database + audit_log = [] + for app in apps: + try: + user = await guild.fetch_member(int(app.applicant_id)) + except discord.NotFound: + user = None - Attributes: - application_group (app_commands.Group): The group for the /application commands - """ + # User who made application left + if not user: + audit_log.append( + f"Application by user: `{app.applicant_name}` was rejected because" + " they left" + ) + await app.update( + application_status=ApplicationStatus.REJECTED.value + ).apply() + continue - application_group: app_commands.Group = app_commands.Group( - name="application", - description="...", - ) + # Application has been pending for max_age days + max_age_config = configuration.get_config_entry( + guild.id, "application_max_age" + ) + if app.application_time < datetime.datetime.now() - datetime.timedelta( + days=max_age_config + ): + audit_log.append( + f"Application by user: `{user.name}` was rejected since it's been" + f" inactive for {max_age_config} days" + ) + await app.update( + application_status=ApplicationStatus.REJECTED.value + ).apply() + continue + + # User changed their name + if user.name != app.applicant_name: + audit_log.append( + f"Application by user: `{app.applicant_name}` had the stored name" + f" updated to `{user.name}`" + ) + await app.update(applicant_name=user.name).apply() + + role = guild.get_role( + int( + configuration.get_config_entry( + guild.id, "application_application_role_id" + ) + ) + ) + + # User has the helper role + if role in getattr(user, "roles", []): + audit_log.append( + f"Application by user: `{user.name}` was approved since they have" + f" the `{role.name}` role" + ) + await app.update( + application_status=ApplicationStatus.APPROVED.value + ).apply() + + if audit_log: + embed = discord.Embed(title="Application manage events") + for event in audit_log: + if embed.description: + embed.description = f"{embed.description}\n{event}" + else: + embed.description = f"{event}" + await channel.send(embed=embed) + + apps = await self.get_applications_by_status(ApplicationStatus.PENDING, guild) + if not apps: + return + + embed = discord.Embed(title="All pending applcations") + list_of_applicants = [] + + for app in apps: + member = await guild.fetch_member(int(app.applicant_id)) + list_of_applicants.append( + ( + f"Application by: `{member.display_name} ({app.applicant_name})`" + f", applied on: " + ) + ) + + embed.description = "\n".join(list_of_applicants) + + await channel.send(embed=embed) # Slash Commands @@ -862,125 +1046,3 @@ async def get_ban_entry(self: Self, member: discord.Member) -> bot.models.AppBan ).where(self.bot.models.AppBans.guild_id == str(member.guild.id)) entry = await query.gino.all() return entry - - # Loop stuff - - async def execute(self: Self, guild: discord.Guild) -> None: - """The executes the reminder of pending applications - - Args: - guild (discord.Guild): The guild the loop is executing for - """ - channel = guild.get_channel( - int( - configuration.get_config_entry( - guild.id, "application_management_channel" - ) - ) - ) - if not channel: - return - - apps = await self.get_applications_by_status(ApplicationStatus.PENDING, guild) - if not apps: - return - - # Update the database - audit_log = [] - for app in apps: - try: - user = await guild.fetch_member(int(app.applicant_id)) - except discord.NotFound: - user = None - - # User who made application left - if not user: - audit_log.append( - f"Application by user: `{app.applicant_name}` was rejected because" - " they left" - ) - await app.update( - application_status=ApplicationStatus.REJECTED.value - ).apply() - continue - - # Application has been pending for max_age days - max_age_config = configuration.get_config_entry( - guild.id, "application_max_age" - ) - if app.application_time < datetime.datetime.now() - datetime.timedelta( - days=max_age_config - ): - audit_log.append( - f"Application by user: `{user.name}` was rejected since it's been" - f" inactive for {max_age_config} days" - ) - await app.update( - application_status=ApplicationStatus.REJECTED.value - ).apply() - continue - - # User changed their name - if user.name != app.applicant_name: - audit_log.append( - f"Application by user: `{app.applicant_name}` had the stored name" - f" updated to `{user.name}`" - ) - await app.update(applicant_name=user.name).apply() - - role = guild.get_role( - int( - configuration.get_config_entry( - guild.id, "application_application_role_id" - ) - ) - ) - - # User has the helper role - if role in getattr(user, "roles", []): - audit_log.append( - f"Application by user: `{user.name}` was approved since they have" - f" the `{role.name}` role" - ) - await app.update( - application_status=ApplicationStatus.APPROVED.value - ).apply() - - if audit_log: - embed = discord.Embed(title="Application manage events") - for event in audit_log: - if embed.description: - embed.description = f"{embed.description}\n{event}" - else: - embed.description = f"{event}" - await channel.send(embed=embed) - - apps = await self.get_applications_by_status(ApplicationStatus.PENDING, guild) - if not apps: - return - - embed = discord.Embed(title="All pending applcations") - list_of_applicants = [] - - for app in apps: - member = await guild.fetch_member(int(app.applicant_id)) - list_of_applicants.append( - ( - f"Application by: `{member.display_name} ({app.applicant_name})`" - f", applied on: " - ) - ) - - embed.description = "\n".join(list_of_applicants) - - await channel.send(embed=embed) - - async def wait(self: Self, guild: discord.Guild) -> None: - """The queues the pending application reminder based on the cron config - - Args: - guild (discord.Guild): The guild the loop is executing for - """ - await aiocron.crontab( - configuration.get_config_entry(guild.id, "application_reminder_cron_config") - ).next() diff --git a/modules/operation/forum.py b/modules/operation/forum.py index 08f99ef7..45cd69fb 100644 --- a/modules/operation/forum.py +++ b/modules/operation/forum.py @@ -75,7 +75,7 @@ async def setup(bot: bot.TechSupportBot) -> None: } -class ForumChannel(cogs.LoopCog): +class ForumChannel(cogs.BaseCog): """The cog that holds the forum channel commands and helper functions Attributes: @@ -90,6 +90,73 @@ async def preconfig(self: Self) -> None: """Sets up a small list of threads closed by TS""" self.thread_ID_closed = [] + # Scheduled task stuff + self.bot.scheduler.register_task( + "forum_manager", + self.run_forum_manager, + ) + + # Start the initial tasks + for guild in self.bot.guilds: + await self.schedule_forum_manager(guild) + + # Loop Stuff + + async def run_forum_manager(self: Self, payload: dict) -> None: + """This is what closes threads after inactivity + + Args: + payload (dict): A dictionary containing a guild to run this job in + """ + # Expand the payload + guild: discord.Guild = payload["guild"] + # Ensure forum is enabled + if not self.extension_enabled(guild): + return + + # Schedule the next check + await self.schedule_forum_manager(guild) + + channel = guild.get_channel( + int(configuration.get_config_entry(guild.id, "forum_forum_channel_id")) + ) + for existing_thread in channel.threads: + if not existing_thread.archived and not existing_thread.locked: + most_recent_message_id = existing_thread.last_message_id + # If there are NO messages in the thread, use the thread creation timestamp instead + if not most_recent_message_id: + most_recent_message_id = existing_thread.id + + message_timestamp = discord.utils.snowflake_time(most_recent_message_id) + timestamp_delta = ( + datetime.datetime.now(datetime.timezone.utc) - message_timestamp + ) + if timestamp_delta > datetime.timedelta( + minutes=configuration.get_config_entry( + guild.id, "forum_max_age_minutes" + ) + ): + await mark_thread( + existing_thread, + self.thread_ID_closed, + "abandoned", + "Threads are automatically closed after periods of no activity", + ) + + async def schedule_forum_manager(self: Self, guild: discord.Guild) -> None: + """This schedules the forum manager check for a 5 minute delay, for the given guild + + Args: + guild (discord.Guild): The guild to schedule for + """ + # Only schedule if the extension is enabled + if not self.extension_enabled(guild): + return + + await self.bot.scheduler.schedule_delay( + task_name="forum_manager", seconds=300, payload={"guild": guild} + ) + @forum_group.command( name="mark", description="Mark a support forum thread", @@ -560,42 +627,6 @@ async def on_raw_message_delete( reason=("It appears the original post for this thread was deleted."), ) - async def execute(self: Self, guild: discord.Guild) -> None: - """This is what closes threads after inactivity - - Args: - guild (discord.Guild): The guild where the loop is taking place - """ - channel = await guild.fetch_channel( - int(configuration.get_config_entry(guild.id, "forum_forum_channel_id")) - ) - for existing_thread in channel.threads: - if not existing_thread.archived and not existing_thread.locked: - most_recent_message_id = existing_thread.last_message_id - # If there are NO messages in the thread, use the thread creation timestamp instead - if not most_recent_message_id: - most_recent_message_id = existing_thread.id - - message_timestamp = discord.utils.snowflake_time(most_recent_message_id) - timestamp_delta = ( - datetime.datetime.now(datetime.timezone.utc) - message_timestamp - ) - if timestamp_delta > datetime.timedelta( - minutes=configuration.get_config_entry( - guild.id, "forum_max_age_minutes" - ) - ): - await mark_thread( - existing_thread, - self.thread_ID_closed, - "abandoned", - "Threads are automatically closed after periods of no activity", - ) - - async def wait(self: Self, _: discord.Guild) -> None: - """This waits and rechecks every 5 minutes to search for old threads""" - await asyncio.sleep(300) - def create_regex_list(str_list: list[str]) -> list[re.Pattern[str]]: """This turns a list of strings into a list of complied regex diff --git a/pyproject.toml b/pyproject.toml index 3012bc10..dcf70482 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" requires-python = ">=3.13,<3.14" dependencies = [ "aiocron==2.1", + "apscheduler==3.11.2", "bidict==0.23.1", "cryptography==49.0.0", "dateparser==1.4.0", diff --git a/uv.lock b/uv.lock index 26fa96f9..8cfce6c4 100644 --- a/uv.lock +++ b/uv.lock @@ -86,6 +86,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/07/12/3e4389e5920b4c1763390c6d371162f3784f86f85cd6d6c1bfe68eef14e2/apscheduler-3.11.2.tar.gz", hash = "sha256:2a9966b052ec805f020c8c4c3ae6e6a06e24b1bf19f2e11d91d8cca0473eef41", size = 108683, upload-time = "2025-12-22T00:39:34.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/64/2e54428beba8d9992aa478bb8f6de9e4ecaa5f8f513bcfd567ed7fb0262d/apscheduler-3.11.2-py3-none-any.whl", hash = "sha256:ce005177f741409db4e4dd40a7431b76feb856b9dd69d57e0da49d6715bfd26d", size = 64439, upload-time = "2025-12-22T00:39:33.303Z" }, +] + [[package]] name = "astroid" version = "4.0.4" @@ -310,6 +322,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "aiocron" }, + { name = "apscheduler" }, { name = "bidict" }, { name = "cryptography" }, { name = "dateparser" }, @@ -346,6 +359,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "aiocron", specifier = "==2.1" }, + { name = "apscheduler", specifier = "==3.11.2" }, { name = "bidict", specifier = "==0.23.1" }, { name = "black", marker = "extra == 'dev'", specifier = "==26.5.1" }, { name = "cryptography", specifier = "==49.0.0" },