From 7dd641df876a2004adb685ebd03b6e92f1bae413 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:48:24 -0700 Subject: [PATCH 01/13] Start working on a new scheduler --- bot.py | 9 +- core/scheduler.py | 154 ++++++++++++++ main.py | 1 + modules/operation/application.py | 348 +++++++++++++++++++------------ pyproject.toml | 1 + uv.lock | 14 ++ 6 files changed, 390 insertions(+), 137 deletions(-) create mode 100644 core/scheduler.py 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/core/scheduler.py b/core/scheduler.py new file mode 100644 index 00000000..926afd4c --- /dev/null +++ b/core/scheduler.py @@ -0,0 +1,154 @@ +import datetime +import random +import uuid +from typing import List, Self + +import discord +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.date import DateTrigger + + +class SchedulerService: + """ + Simple, explicit scheduler. + """ + + def __init__(self: Self, bot): + self.bot = bot + self.scheduler = AsyncIOScheduler() + self.tasks = {} # task_name -> coroutine + + async def start(self: Self) -> None: + """ + Start scheduler. + """ + self.scheduler.start() + + def register_task(self, name: str, func): + """ + Register execution handler for a task. + """ + self.tasks[name] = func + + async def _execute( + self: Self, + task_name: str, + guild_id: int, + payload: dict, + ) -> None: + """ + Execute a scheduled task. + Does checks to ensure the task is capable of being executed + """ + + handler = self.tasks.get(task_name) + if not handler: + return + + guild = self.bot.get_guild(guild_id) + if not guild: + return + + await handler( + guild, + payload or {}, + ) + + # Schedulers to be called by cogs + + async def schedule_date( + self: Self, + task_name: str, + guild: discord.Guild, + run_at: datetime.datetime, + payload: dict | None = None, + ) -> str: + """ + Schedule a task at an exact datetime. + """ + + job_id = f"{task_name}:{uuid.uuid4()}" + + self.scheduler.add_job( + self._execute, + trigger=DateTrigger(run_date=run_at), + args=[task_name, guild.id, payload or {}], + id=job_id, + replace_existing=True, + ) + + return job_id + + async def schedule_delay( + self: Self, + task_name: str, + guild: discord.Guild, + seconds: int, + payload: dict | None = None, + ) -> str: + """ + Schedule a task N seconds in the future. + """ + + run_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) + + return await self.schedule_date(task_name, guild, run_at, payload) + + async def schedule_cron( + self: Self, + task_name: str, + guild: discord.Guild, + cron: str, + payload: dict | None = None, + ) -> str: + """ + Schedule a task using cron syntax. + Converts cron → next execution datetime immediately. + """ + + 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, guild, run_at, payload) + + async def schedule_random( + self: Self, + task_name: str, + guild: discord.Guild, + min_hours: float, + max_hours: float, + payload: dict | None = None, + ) -> str: + """ + Schedule a task at a random time between min/max hours. + """ + + 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, guild, run_at, payload) + + # Getting tasks and other internal functions + + async def get_upcoming_tasks(self: Self) -> List[dict]: + """ + Return all scheduled tasks sorted by next execution time. + """ + + return sorted( + [ + { + "job_id": job.id, + "task_name": (job.args[0] if job.args else "unknown"), + "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/operation/application.py b/modules/operation/application.py index 07725d2d..c01827a0 100644 --- a/modules/operation/application.py +++ b/modules/operation/application.py @@ -43,7 +43,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 +85,242 @@ 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", + guild=guild, + cron=cron, + ) + + 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", + guild=guild, + cron=cron, + ) + + async def run_application_notifier( + self: Self, + guild: discord.Guild, + _: dict, + ) -> None: + """This posts an application notification in every configured channel for the passed guild + + Args: + guild (discord.Guild): The guild to post the notification in + """ + # 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, guild: discord.Guild, _: dict + ) -> None: + """The executes the reminder of pending applications Args: guild (discord.Guild): The guild the loop is executing for """ - await aiocron.crontab( - configuration.get_config_entry( - guild.id, "application_notification_cron_config" + # 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 @@ -865,116 +1051,6 @@ async def get_ban_entry(self: Self, member: discord.Member) -> bot.models.AppBan # 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 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" }, From 4977c65a987cf8654d6b799f7196e5e9659b4b2f Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:52:07 -0700 Subject: [PATCH 02/13] Fix small typo --- core/scheduler.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/core/scheduler.py b/core/scheduler.py index 926afd4c..85af6d38 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -10,10 +10,6 @@ class SchedulerService: - """ - Simple, explicit scheduler. - """ - def __init__(self: Self, bot): self.bot = bot self.scheduler = AsyncIOScheduler() From 4581b3271276c1297daf8ba459c22c956398e0c8 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:53:49 -0700 Subject: [PATCH 03/13] Add more doc string detail --- core/scheduler.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/scheduler.py b/core/scheduler.py index 85af6d38..2816fef6 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -1,7 +1,7 @@ import datetime import random import uuid -from typing import List, Self +from typing import Self import discord from apscheduler.schedulers.asyncio import AsyncIOScheduler @@ -62,6 +62,7 @@ async def schedule_date( ) -> str: """ Schedule a task at an exact datetime. + Ideally to be used for voting """ job_id = f"{task_name}:{uuid.uuid4()}" @@ -85,6 +86,7 @@ async def schedule_delay( ) -> str: """ Schedule a task N seconds in the future. + Ideally to be used for modmail, forum """ run_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) @@ -101,6 +103,7 @@ async def schedule_cron( """ Schedule a task using cron syntax. Converts cron → next execution datetime immediately. + Ideally to be used for news, application, factoid jobs """ trigger = CronTrigger.from_crontab(cron) @@ -123,6 +126,7 @@ async def schedule_random( ) -> str: """ Schedule a task at a random time between min/max hours. + Ideally to be used for duck, kanye """ seconds = random.uniform(min_hours * 3600, max_hours * 3600) @@ -132,7 +136,7 @@ async def schedule_random( # Getting tasks and other internal functions - async def get_upcoming_tasks(self: Self) -> List[dict]: + async def get_upcoming_tasks(self: Self) -> list[dict]: """ Return all scheduled tasks sorted by next execution time. """ From a158f7db071415cac7dc49d542c507b1444e205f Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:55:16 -0700 Subject: [PATCH 04/13] Make a todo list --- core/scheduler.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/core/scheduler.py b/core/scheduler.py index 2816fef6..3cef205a 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -1,3 +1,10 @@ +""" +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 +""" + import datetime import random import uuid @@ -18,6 +25,7 @@ def __init__(self: Self, bot): async def start(self: Self) -> None: """ Start scheduler. + We should only start the scheduler after tasks have been registered """ self.scheduler.start() From da992cf56482e5c5c833a790e20bd557df72519c Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:55:56 -0700 Subject: [PATCH 05/13] Clear unused function from application --- modules/operation/application.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/modules/operation/application.py b/modules/operation/application.py index c01827a0..5e0b37d0 100644 --- a/modules/operation/application.py +++ b/modules/operation/application.py @@ -1048,15 +1048,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 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() From 587e9fa092affee7bba8ed9fdd0c8574f6c3fd6a Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:06:22 -0700 Subject: [PATCH 06/13] Do some more loop work. Totally break factoids --- core/scheduler.py | 62 ++++------- main.py | 2 +- modules/fun/duck.py | 148 ++++++++++++++++++------- modules/operation/application.py | 22 ++-- modules/operation/factoids.py | 182 ++++++++++++++++++++----------- modules/operation/forum.py | 105 +++++++++++------- 6 files changed, 324 insertions(+), 197 deletions(-) diff --git a/core/scheduler.py b/core/scheduler.py index 3cef205a..216b3ce3 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -29,44 +29,23 @@ async def start(self: Self) -> None: """ self.scheduler.start() - def register_task(self, name: str, func): - """ - Register execution handler for a task. - """ - self.tasks[name] = func + def register_task(self: Self, name: str, func: callable): + """This registers a callback location for a scheduled tasks + Modules wishing to schedule tasks should call this to setup tasks first - async def _execute( - self: Self, - task_name: str, - guild_id: int, - payload: dict, - ) -> None: + Args: + name (str): The globally unique name of a task + func (callable): The function to call when the task executes """ - Execute a scheduled task. - Does checks to ensure the task is capable of being executed - """ - - handler = self.tasks.get(task_name) - if not handler: - return - - guild = self.bot.get_guild(guild_id) - if not guild: - return - - await handler( - guild, - payload or {}, - ) + self.tasks[name] = func # Schedulers to be called by cogs async def schedule_date( self: Self, task_name: str, - guild: discord.Guild, run_at: datetime.datetime, - payload: dict | None = None, + payload: dict, ) -> str: """ Schedule a task at an exact datetime. @@ -75,10 +54,14 @@ async def schedule_date( 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( - self._execute, + func=handler, trigger=DateTrigger(run_date=run_at), - args=[task_name, guild.id, payload or {}], + args=[payload], id=job_id, replace_existing=True, ) @@ -88,9 +71,8 @@ async def schedule_date( async def schedule_delay( self: Self, task_name: str, - guild: discord.Guild, seconds: int, - payload: dict | None = None, + payload: dict, ) -> str: """ Schedule a task N seconds in the future. @@ -99,14 +81,13 @@ async def schedule_delay( run_at = datetime.datetime.utcnow() + datetime.timedelta(seconds=seconds) - return await self.schedule_date(task_name, guild, run_at, payload) + return await self.schedule_date(task_name, run_at, payload) async def schedule_cron( self: Self, task_name: str, - guild: discord.Guild, cron: str, - payload: dict | None = None, + payload: dict, ) -> str: """ Schedule a task using cron syntax. @@ -122,15 +103,14 @@ async def schedule_cron( if run_at is None: raise ValueError("Invalid cron expression") - return await self.schedule_date(task_name, guild, run_at, payload) + return await self.schedule_date(task_name, run_at, payload) async def schedule_random( self: Self, task_name: str, - guild: discord.Guild, min_hours: float, max_hours: float, - payload: dict | None = None, + payload: dict, ) -> str: """ Schedule a task at a random time between min/max hours. @@ -140,7 +120,7 @@ async def schedule_random( 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, guild, run_at, payload) + return await self.schedule_date(task_name, run_at, payload) # Getting tasks and other internal functions @@ -153,7 +133,7 @@ async def get_upcoming_tasks(self: Self) -> list[dict]: [ { "job_id": job.id, - "task_name": (job.args[0] if job.args else "unknown"), + "payload": job.args[0], "run_at": job.next_run_time, } for job in self.scheduler.get_jobs() diff --git a/main.py b/main.py index 4fd771d7..3a8e3dd0 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ MODULE_LOG_LEVELS = { "discord": logging.INFO, "gino": logging.WARNING, - "apscheduler": logging.WARNING, + "apscheduler": logging.INFO, } for module_name, level in MODULE_LOG_LEVELS.items(): diff --git a/modules/fun/duck.py b/modules/fun/duck.py index e37e3f71..40863420 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,43 +62,56 @@ 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 """ - 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: + async def run_duck_hunt(self: Self, payload: dict) -> None: """Sends a duck in the given channel Can be manually called, and will be called automatically after wait() @@ -108,26 +121,49 @@ async def execute( banned_user (discord.User, optional): A user that is not allowed to claim the duck. Defaults to None. """ + 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: self.cooldowns[guild.id] = {} - del self.next_duck[str(guild.id)] embed = discord.Embed( title="*Quack Quack*", @@ -136,7 +172,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 +182,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 +194,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 +207,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 +468,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 +815,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 +1010,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 5e0b37d0..06e20ed8 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 @@ -138,9 +137,7 @@ async def schedule_notifier_by_guild( ) await self.bot.scheduler.schedule_cron( - task_name="application_notifier", - guild=guild, - cron=cron, + task_name="application_notifier", cron=cron, payload={"guild": guild} ) async def schedule_manager_by_guild( @@ -162,21 +159,21 @@ async def schedule_manager_by_guild( ) await self.bot.scheduler.schedule_cron( - task_name="application_manager", - guild=guild, - cron=cron, + task_name="application_manager", cron=cron, payload={"guild": guild} ) async def run_application_notifier( self: Self, - guild: discord.Guild, - _: dict, + payload: dict, ) -> None: """This posts an application notification in every configured channel for the passed guild Args: guild (discord.Guild): The guild to post the notification 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 @@ -203,14 +200,15 @@ async def run_application_notifier( ), ) - async def run_application_manager( - self: Self, guild: discord.Guild, _: dict - ) -> None: + 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 """ + # 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 diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py index 587a7895..8b627394 100644 --- a/modules/operation/factoids.py +++ b/modules/operation/factoids.py @@ -209,6 +209,7 @@ async def preconfig(self: Self) -> None: message="Loading factoid jobs", level=LogLevel.DEBUG, ) + await self.kickoff_jobs() # -- DB calls -- @@ -828,14 +829,50 @@ async def response( ) return - # Checking for disabled or restricted + mentions = auxiliary.construct_mention_string(ctx.message.mentions) + await self.send_factoid( + factoid=factoid, + channel=ctx.channel, + author=ctx.author, + mentions_str=mentions, + calling_message=ctx.message, + ) + + async def send_factoid( + self: Self, + factoid: bot.models.Factoid, + channel: discord.abc.GuildChannel, + author: discord.Member = None, + mentions_str: str = None, + calling_message: discord.message = None, + ) -> None: + """Sends a factoid to discord + If relevant, forwards to IRC and/or the logger + Checks for restricted channels, and disabled factoids + + Args: + factoid (bot.models.Factoid): The database entry of the root factoid to send. Should not be an alias + channel (discord.abc.GuildChannel): The channel to send the factoid to + author (discord.Member): If this factoid was manually called, the user who called the factoid + mentions_str (str): A string of mentions to prepend to a factoid being sent + """ + # First, ensure that mentions_str is not >2000 + if mentions_str and len(mentions_str) > 2000: + await auxiliary.send_deny_embed( + message="I ran into an error sending that factoid: " + + "The factoid message is longer than the discord size limit (2000)", + channel=channel, + ) + raise custom_errors.TooLongFactoidMessageError + + # Second, do not send the factoid if its disabled if factoid.disabled: return + # Third, if the factoid is restricted, ensure the channel is on the restricted list if factoid.restricted: - channel = ctx.channel restricted_list = configuration.get_config_entry( - ctx.guild.id, "factoids_restricted_list" + channel.guild.id, "factoids_restricted_list" ) if isinstance(channel, discord.Thread): if str(channel.parent.id) not in restricted_list: @@ -844,82 +881,97 @@ async def response( if str(channel.id) not in restricted_list: return - if configuration.get_config_entry(ctx.guild.id, "factoids_disable_embeds"): - embed = None - else: + # At this point, we should be sending the factoid. + # We will create the embed if enabled and possible + plaintext_content = factoid.message + embed = None + + if not configuration.get_config_entry( + channel.guild.id, "factoids_disable_embeds" + ): try: embed = self.get_embed_from_factoid(factoid) except TypeError as exception: + await self.bot.logger.send_log( + message=( + f"Unable to make embed for factoid `{factoid.name}`, " + "sending fallback." + ), + level=LogLevel.ERROR, + channel=configuration.get_config_entry( + channel.guild.id, + "core_logging_channel", + ), + context=LogContext( + guild=channel.guild, + channel=channel, + ), + exception=exception, + ) + + # If an embed was generated, we should attempt to send it + embed_success = False + if embed: + try: + if calling_message: + sent_message = await calling_message.reply( + content=mentions_str, + embed=embed, + mention_author=not mentions_str, + ) + else: + sent_message = await channel.send(content=mentions_str, embed=embed) + embed_success = True + except discord.errors.HTTPException as exception: log_channel = configuration.get_config_entry( - ctx.guild.id, "core_logging_channel" + channel.guild.id, "core_logging_channel" ) await self.bot.logger.send_log( - message=f"Unable to make embed for factoid `{factoid.name}`, sending fallback.", + message="Could not send factoid", level=LogLevel.ERROR, + context=LogContext(guild=channel.guild, channel=channel), channel=log_channel, - context=LogContext(guild=ctx.guild, channel=ctx.channel), exception=exception, ) - embed = None - - # if the json doesn't include non embed argument, then don't send anything - # otherwise send message text with embed - try: - plaintext_content = factoid.message if not embed else None - except ValueError: - # The not embed causes a ValueError in certain cases. This ensures fallback works - plaintext_content = factoid.message - mentions = auxiliary.construct_mention_string(ctx.message.mentions) - content = " ".join(filter(None, [mentions, plaintext_content])) or None - if content and len(content) > 2000: - await auxiliary.send_deny_embed( - message="I ran into an error sending that factoid: " - + "The factoid message is longer than the discord size limit (2000)", - channel=ctx.channel, + # This means either we aren't sending an embed, or the embed failed for some reason + # We need to send this factoid as a plaintext factoid + if not embed_success: + plaintext_with_mentions = " ".join( + filter( + None, + [ + mentions_str, + plaintext_content, + ], + ) ) - raise custom_errors.TooLongFactoidMessageError - try: - # define the message and send it - sent_message = await ctx.reply( - content=content, embed=embed, mention_author=not mentions - ) - # log it in the logging channel with type info and generic content - log_channel = configuration.get_config_entry( - ctx.guild.id, "core_logging_channel" - ) - await self.bot.logger.send_log( - message=( - f"Sending factoid: {query} (triggered by {ctx.author} in" - f" #{ctx.channel.name})" - ), - level=LogLevel.INFO, - context=LogContext(guild=ctx.guild, channel=ctx.channel), - channel=log_channel, - ) - # If something breaks, also log it - except discord.errors.HTTPException as exception: - log_channel = configuration.get_config_entry( - ctx.guild.id, "core_logging_channel" - ) - await self.bot.logger.send_log( - message="Could not send factoid", - level=LogLevel.ERROR, - context=LogContext(guild=ctx.guild, channel=ctx.channel), - channel=log_channel, - exception=exception, - ) - # Sends the raw factoid instead of the embed as fallback - sent_message = await ctx.reply( - f"{mentions + ' ' if mentions else ''}{factoid.message}", - mention_author=not mentions, - ) + if len(plaintext_with_mentions) > 2000: + await auxiliary.send_deny_embed( + message=( + "I ran into an error sending that factoid: " + "The factoid message is longer than the Discord " + "size limit (2000)" + ), + channel=channel, + ) + raise custom_errors.TooLongFactoidMessageError - await self.send_to_irc(ctx.channel, ctx.message, factoid.message) - await self.send_to_logger( - sent_message, ctx.author, ctx.channel, factoid.message - ) + if calling_message: + sent_message = await calling_message.reply( + content=plaintext_with_mentions, + mention_author=not mentions_str, + ) + else: + sent_message = await channel.send( + content=plaintext_with_mentions, + ) + + # At this point its time to move on to irc and logger + # TODO: Rewrite IRC processing + # await self.send_to_irc(channel, ctx.message, factoid.message) + await self.send_to_logger(sent_message, author, channel, plaintext_content) async def send_to_irc( self: Self, diff --git a/modules/operation/forum.py b/modules/operation/forum.py index 08f99ef7..f1d6ee85 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: + guild (discord.Guild): The guild where the loop is taking place + """ + # 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 = 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 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 From 0e8c8668e32168f670913be670af7300ec1d9cab Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Tue, 16 Jun 2026 10:37:49 -0700 Subject: [PATCH 07/13] Rollback all changes to factoids --- modules/operation/factoids.py | 182 ++++++++++++---------------------- 1 file changed, 65 insertions(+), 117 deletions(-) diff --git a/modules/operation/factoids.py b/modules/operation/factoids.py index 8b627394..587a7895 100644 --- a/modules/operation/factoids.py +++ b/modules/operation/factoids.py @@ -209,7 +209,6 @@ async def preconfig(self: Self) -> None: message="Loading factoid jobs", level=LogLevel.DEBUG, ) - await self.kickoff_jobs() # -- DB calls -- @@ -829,50 +828,14 @@ async def response( ) return - mentions = auxiliary.construct_mention_string(ctx.message.mentions) - await self.send_factoid( - factoid=factoid, - channel=ctx.channel, - author=ctx.author, - mentions_str=mentions, - calling_message=ctx.message, - ) - - async def send_factoid( - self: Self, - factoid: bot.models.Factoid, - channel: discord.abc.GuildChannel, - author: discord.Member = None, - mentions_str: str = None, - calling_message: discord.message = None, - ) -> None: - """Sends a factoid to discord - If relevant, forwards to IRC and/or the logger - Checks for restricted channels, and disabled factoids - - Args: - factoid (bot.models.Factoid): The database entry of the root factoid to send. Should not be an alias - channel (discord.abc.GuildChannel): The channel to send the factoid to - author (discord.Member): If this factoid was manually called, the user who called the factoid - mentions_str (str): A string of mentions to prepend to a factoid being sent - """ - # First, ensure that mentions_str is not >2000 - if mentions_str and len(mentions_str) > 2000: - await auxiliary.send_deny_embed( - message="I ran into an error sending that factoid: " - + "The factoid message is longer than the discord size limit (2000)", - channel=channel, - ) - raise custom_errors.TooLongFactoidMessageError - - # Second, do not send the factoid if its disabled + # Checking for disabled or restricted if factoid.disabled: return - # Third, if the factoid is restricted, ensure the channel is on the restricted list if factoid.restricted: + channel = ctx.channel restricted_list = configuration.get_config_entry( - channel.guild.id, "factoids_restricted_list" + ctx.guild.id, "factoids_restricted_list" ) if isinstance(channel, discord.Thread): if str(channel.parent.id) not in restricted_list: @@ -881,97 +844,82 @@ async def send_factoid( if str(channel.id) not in restricted_list: return - # At this point, we should be sending the factoid. - # We will create the embed if enabled and possible - plaintext_content = factoid.message - embed = None - - if not configuration.get_config_entry( - channel.guild.id, "factoids_disable_embeds" - ): + if configuration.get_config_entry(ctx.guild.id, "factoids_disable_embeds"): + embed = None + else: try: embed = self.get_embed_from_factoid(factoid) except TypeError as exception: - await self.bot.logger.send_log( - message=( - f"Unable to make embed for factoid `{factoid.name}`, " - "sending fallback." - ), - level=LogLevel.ERROR, - channel=configuration.get_config_entry( - channel.guild.id, - "core_logging_channel", - ), - context=LogContext( - guild=channel.guild, - channel=channel, - ), - exception=exception, - ) - - # If an embed was generated, we should attempt to send it - embed_success = False - if embed: - try: - if calling_message: - sent_message = await calling_message.reply( - content=mentions_str, - embed=embed, - mention_author=not mentions_str, - ) - else: - sent_message = await channel.send(content=mentions_str, embed=embed) - embed_success = True - except discord.errors.HTTPException as exception: log_channel = configuration.get_config_entry( - channel.guild.id, "core_logging_channel" + ctx.guild.id, "core_logging_channel" ) await self.bot.logger.send_log( - message="Could not send factoid", + message=f"Unable to make embed for factoid `{factoid.name}`, sending fallback.", level=LogLevel.ERROR, - context=LogContext(guild=channel.guild, channel=channel), channel=log_channel, + context=LogContext(guild=ctx.guild, channel=ctx.channel), exception=exception, ) + embed = None - # This means either we aren't sending an embed, or the embed failed for some reason - # We need to send this factoid as a plaintext factoid - if not embed_success: - plaintext_with_mentions = " ".join( - filter( - None, - [ - mentions_str, - plaintext_content, - ], - ) - ) + # if the json doesn't include non embed argument, then don't send anything + # otherwise send message text with embed + try: + plaintext_content = factoid.message if not embed else None + except ValueError: + # The not embed causes a ValueError in certain cases. This ensures fallback works + plaintext_content = factoid.message + mentions = auxiliary.construct_mention_string(ctx.message.mentions) - if len(plaintext_with_mentions) > 2000: - await auxiliary.send_deny_embed( - message=( - "I ran into an error sending that factoid: " - "The factoid message is longer than the Discord " - "size limit (2000)" - ), - channel=channel, - ) - raise custom_errors.TooLongFactoidMessageError + content = " ".join(filter(None, [mentions, plaintext_content])) or None + if content and len(content) > 2000: + await auxiliary.send_deny_embed( + message="I ran into an error sending that factoid: " + + "The factoid message is longer than the discord size limit (2000)", + channel=ctx.channel, + ) + raise custom_errors.TooLongFactoidMessageError - if calling_message: - sent_message = await calling_message.reply( - content=plaintext_with_mentions, - mention_author=not mentions_str, - ) - else: - sent_message = await channel.send( - content=plaintext_with_mentions, - ) + try: + # define the message and send it + sent_message = await ctx.reply( + content=content, embed=embed, mention_author=not mentions + ) + # log it in the logging channel with type info and generic content + log_channel = configuration.get_config_entry( + ctx.guild.id, "core_logging_channel" + ) + await self.bot.logger.send_log( + message=( + f"Sending factoid: {query} (triggered by {ctx.author} in" + f" #{ctx.channel.name})" + ), + level=LogLevel.INFO, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + channel=log_channel, + ) + # If something breaks, also log it + except discord.errors.HTTPException as exception: + log_channel = configuration.get_config_entry( + ctx.guild.id, "core_logging_channel" + ) + await self.bot.logger.send_log( + message="Could not send factoid", + level=LogLevel.ERROR, + context=LogContext(guild=ctx.guild, channel=ctx.channel), + channel=log_channel, + exception=exception, + ) + # Sends the raw factoid instead of the embed as fallback + sent_message = await ctx.reply( + f"{mentions + ' ' if mentions else ''}{factoid.message}", + mention_author=not mentions, + ) - # At this point its time to move on to irc and logger - # TODO: Rewrite IRC processing - # await self.send_to_irc(channel, ctx.message, factoid.message) - await self.send_to_logger(sent_message, author, channel, plaintext_content) + await self.send_to_irc(ctx.channel, ctx.message, factoid.message) + await self.send_to_logger( + sent_message, ctx.author, ctx.channel, factoid.message + ) async def send_to_irc( self: Self, From 40df8082797ed2603520051fa7846e4252c317d6 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:01:18 -0700 Subject: [PATCH 08/13] Formatting changes --- core/scheduler.py | 17 ++++++++++++++--- modules/fun/duck.py | 20 ++++++++++++++------ modules/operation/forum.py | 2 +- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/core/scheduler.py b/core/scheduler.py index 216b3ce3..5a3ed583 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -5,19 +5,30 @@ 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 Self +from typing import TYPE_CHECKING, Self -import discord 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: - def __init__(self: Self, bot): + """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): self.bot = bot self.scheduler = AsyncIOScheduler() self.tasks = {} # task_name -> coroutine diff --git a/modules/fun/duck.py b/modules/fun/duck.py index 40863420..e939d5b1 100644 --- a/modules/fun/duck.py +++ b/modules/fun/duck.py @@ -90,6 +90,7 @@ async def schedule_duck_hunt( 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") max_wait = configuration.get_config_entry(guild.id, "duck_max_wait") @@ -112,14 +113,12 @@ async def schedule_duck_hunt( ) async def run_duck_hunt(self: Self, payload: dict) -> None: - """Sends a duck in the given channel - Can be manually called, and will be called automatically after wait() + """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"] @@ -163,6 +162,15 @@ async def spawn_duck( channel: discord.TextChannel, banned_user: discord.User = None, ) -> None: + """This spawns a duck in the passed channel + + Args: + self (Self): _description_ + 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] = {} embed = discord.Embed( diff --git a/modules/operation/forum.py b/modules/operation/forum.py index f1d6ee85..5eed9642 100644 --- a/modules/operation/forum.py +++ b/modules/operation/forum.py @@ -117,7 +117,7 @@ async def run_forum_manager(self: Self, payload: dict) -> None: # Schedule the next check await self.schedule_forum_manager(guild) - channel = await guild.fetch_channel( + channel = guild.get_channel( int(configuration.get_config_entry(guild.id, "forum_forum_channel_id")) ) for existing_thread in channel.threads: From ba824359ebdc803e9661ff73f4c6bd3c908ea2ea Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:01:49 -0700 Subject: [PATCH 09/13] Changelog --- changelog.md | 1 + 1 file changed, 1 insertion(+) 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 From b40b368b115fdc45bed3917b446189c29ca3145d Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:03:15 -0700 Subject: [PATCH 10/13] Change log leve for apscheduler --- main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.py b/main.py index 3a8e3dd0..4fd771d7 100644 --- a/main.py +++ b/main.py @@ -10,7 +10,7 @@ MODULE_LOG_LEVELS = { "discord": logging.INFO, "gino": logging.WARNING, - "apscheduler": logging.INFO, + "apscheduler": logging.WARNING, } for module_name, level in MODULE_LOG_LEVELS.items(): From 80b8c8f0c676c526d6c1540bb079a9a16ee9e13a Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:10:50 -0700 Subject: [PATCH 11/13] Fix docstrings --- core/scheduler.py | 67 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/core/scheduler.py b/core/scheduler.py index 5a3ed583..63695b3d 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -58,9 +58,19 @@ async def schedule_date( run_at: datetime.datetime, payload: dict, ) -> str: - """ - Schedule a task at an exact datetime. - Ideally to be used for voting + """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()}" @@ -85,9 +95,16 @@ async def schedule_delay( seconds: int, payload: dict, ) -> str: - """ - Schedule a task N seconds in the future. - Ideally to be used for modmail, forum + """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) @@ -100,10 +117,19 @@ async def schedule_cron( cron: str, payload: dict, ) -> str: - """ - Schedule a task using cron syntax. - Converts cron → next execution datetime immediately. - Ideally to be used for news, application, factoid jobs + """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) @@ -123,9 +149,17 @@ async def schedule_random( max_hours: float, payload: dict, ) -> str: - """ - Schedule a task at a random time between min/max hours. - Ideally to be used for duck, kanye + """This schedules a task based on a randomly picked time + + Args: + task_name (str): The name of the task to register + min_hours (int): The minimum number of hours to wait + max_hours (int): 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) @@ -136,8 +170,11 @@ async def schedule_random( # Getting tasks and other internal functions async def get_upcoming_tasks(self: Self) -> list[dict]: - """ - Return all scheduled tasks sorted by next execution time. + """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( From 0672db63de530d79a2ef1aa48fa16ba37a89dcd4 Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:15:06 -0700 Subject: [PATCH 12/13] More docstring changes --- core/scheduler.py | 4 ++-- modules/fun/duck.py | 1 - modules/operation/application.py | 4 ++-- modules/operation/forum.py | 2 +- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/core/scheduler.py b/core/scheduler.py index 63695b3d..3007725c 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -28,7 +28,7 @@ class SchedulerService: bot (bot.TechSupportBot): The running bot object """ - def __init__(self: Self, bot: bot.TechSupportBot): + def __init__(self: Self, bot: bot.TechSupportBot) -> None: self.bot = bot self.scheduler = AsyncIOScheduler() self.tasks = {} # task_name -> coroutine @@ -40,7 +40,7 @@ async def start(self: Self) -> None: """ self.scheduler.start() - def register_task(self: Self, name: str, func: callable): + 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 diff --git a/modules/fun/duck.py b/modules/fun/duck.py index e939d5b1..e5f12c04 100644 --- a/modules/fun/duck.py +++ b/modules/fun/duck.py @@ -165,7 +165,6 @@ async def spawn_duck( """This spawns a duck in the passed channel Args: - self (Self): _description_ 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 diff --git a/modules/operation/application.py b/modules/operation/application.py index 06e20ed8..2e9f0185 100644 --- a/modules/operation/application.py +++ b/modules/operation/application.py @@ -169,7 +169,7 @@ async def run_application_notifier( """This posts an application notification in every configured channel for the passed guild Args: - guild (discord.Guild): The guild to post the notification in + payload (dict): A dictionary containing a guild to run this job in """ # Expant the payload parameters guild: discord.Guild = payload["guild"] @@ -204,7 +204,7 @@ 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 """ # Expant the payload parameters guild: discord.Guild = payload["guild"] diff --git a/modules/operation/forum.py b/modules/operation/forum.py index 5eed9642..45cd69fb 100644 --- a/modules/operation/forum.py +++ b/modules/operation/forum.py @@ -106,7 +106,7 @@ async def run_forum_manager(self: Self, payload: dict) -> None: """This is what closes threads after inactivity Args: - guild (discord.Guild): The guild where the loop is taking place + payload (dict): A dictionary containing a guild to run this job in """ # Expand the payload guild: discord.Guild = payload["guild"] From dd3ae57fb773ebb8a06718db55cb3d813b755a4d Mon Sep 17 00:00:00 2001 From: ajax146 <31014239+ajax146@users.noreply.github.com> Date: Tue, 16 Jun 2026 12:16:57 -0700 Subject: [PATCH 13/13] Even more docstring changes --- core/scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/scheduler.py b/core/scheduler.py index 3007725c..d10e3bfd 100644 --- a/core/scheduler.py +++ b/core/scheduler.py @@ -153,8 +153,8 @@ async def schedule_random( Args: task_name (str): The name of the task to register - min_hours (int): The minimum number of hours to wait - max_hours (int): The maximum number of hours to wait + 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