From 494a0a045bd7957c971953215eaf84434eb85ec8 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Wed, 13 Aug 2025 02:17:24 +0300 Subject: [PATCH 1/5] Initial cleanup and map out issues --- bot/exts/moderation/modpings.py | 117 ++++++++++++++++++-------------- 1 file changed, 65 insertions(+), 52 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 002bc4cfe6..289790a32b 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,11 +1,12 @@ import asyncio -import datetime +from datetime import UTC, datetime, timedelta import arrow +import dateutil from async_rediscache import RedisCache from dateutil.parser import isoparse, parse as dateutil_parse from discord import Member -from discord.ext.commands import Cog, Context, group, has_any_role +from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role from pydis_core.utils.members import get_or_fetch_member from pydis_core.utils.scheduling import Scheduler @@ -17,7 +18,7 @@ log = get_logger(__name__) -MAXIMUM_WORK_LIMIT = 16 +MAXIMUM_WORK_LIMIT = 23 class ModPings(Cog): @@ -29,29 +30,33 @@ class ModPings(Cog): pings_off_mods = RedisCache() # RedisCache[discord.Member.id, 'start timestamp|total worktime in seconds'] - # The cache's keys are mod's ID + # The cache's keys are mods' IDs # The cache's values are their pings on schedule timestamp and the total seconds (work time) until pings off modpings_schedule = RedisCache() def __init__(self, bot: Bot): self.bot = bot self._role_scheduler = Scheduler("ModPingsOnOff") - self._modpings_scheduler = Scheduler("ModPingsSchedule") + self._shift_scheduler = Scheduler("ModPingsSchedule") self.guild = None self.moderators_role = None + async def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return await has_any_role(*MODERATION_ROLES).predicate(ctx) + async def cog_load(self) -> None: """Schedule both when to reapply role and all mod ping schedules.""" - # await self.reschedule_modpings_schedule() - await self.reschedule_roles() - - async def reschedule_roles(self) -> None: - """Reschedule moderators role re-apply times.""" await self.bot.wait_until_guild_available() self.guild = self.bot.get_guild(Guild.id) self.moderators_role = self.guild.get_role(Roles.moderators) + # await self.reschedule_modpings_schedule() TODO uncomment + await self.reschedule_roles() + + async def reschedule_roles(self) -> None: + """Reschedule moderators role re-apply times.""" mod_team = self.guild.get_role(Roles.mod_team) pings_on = self.moderators_role.members pings_off = await self.pings_off_mods.to_dict() @@ -83,40 +88,39 @@ async def reschedule_roles(self) -> None: async def reschedule_modpings_schedule(self) -> None: """Reschedule moderators schedule ping.""" - await self.bot.wait_until_guild_available() schedule_cache = await self.modpings_schedule.to_dict() log.info("Scheduling modpings schedule for applicable moderators found in cache.") for mod_id, schedule in schedule_cache.items(): start_timestamp, work_time = schedule.split("|") - start = datetime.datetime.fromtimestamp(float(start_timestamp), tz=datetime.UTC) + start = datetime.fromtimestamp(float(start_timestamp), tz=UTC) # TODO What if it's in the past? mod = await self.bot.fetch_user(mod_id) - self._modpings_scheduler.schedule_at( + self._shift_scheduler.schedule_at( start, mod_id, - self.add_role_schedule(mod, work_time, start) + self.add_role_by_schedule(mod, work_time, start) ) - async def remove_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None: - """Removes the moderator's role to the given moderator.""" + async def remove_role_by_schedule(self, mod: Member, shift_time: float, schedule_start: datetime) -> None: + """Removes the moderators role from the given moderator according to schedule.""" log.trace(f"Removing moderator role from mod with ID {mod.id}") await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.") # Remove the task before scheduling it again - self._modpings_scheduler.cancel(mod.id) + self._shift_scheduler.cancel(mod.id) # Add the task again log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}") - schedule_start += datetime.timedelta(days=1) - self._modpings_scheduler.schedule_at( + schedule_start += timedelta(days=1) + self._shift_scheduler.schedule_at( schedule_start, mod.id, - self.add_role_schedule(mod, work_time, schedule_start) + self.add_role_by_schedule(mod, shift_time, schedule_start) ) - async def add_role_schedule(self, mod: Member, work_time: int, schedule_start: datetime.datetime) -> None: - """Adds the moderator's role to the given moderator.""" + async def add_role_by_schedule(self, mod: Member, shift_time: float, schedule_start: datetime) -> None: + """Adds the moderators role to the given moderator.""" # If the moderator has pings off, then skip adding role if mod.id in await self.pings_off_mods.to_dict(): log.trace(f"Skipping adding moderator role to mod with ID {mod.id} - found in pings off cache.") @@ -124,24 +128,23 @@ async def add_role_schedule(self, mod: Member, work_time: int, schedule_start: d log.trace(f"Applying moderator role to mod with ID {mod.id}") await mod.add_roles(self.moderators_role, reason="Moderator scheduled time started!") - log.trace(f"Sleeping for {work_time} seconds, worktime for mod with ID {mod.id}") - await asyncio.sleep(work_time) - await self.remove_role_schedule(mod, work_time, schedule_start) + log.trace(f"Sleeping for {shift_time} seconds, worktime for mod with ID {mod.id}") + await asyncio.sleep(shift_time) # TODO don't hang the coroutine or call directly, rely on the scheduler. + await self.remove_role_by_schedule(mod, shift_time, schedule_start) async def reapply_role(self, mod: Member) -> None: - """Reapply the moderator's role to the given moderator.""" + """Reapply the moderators role to the given moderator.""" log.trace(f"Re-applying role to mod with ID {mod.id}.") + # TODO currently doesn't care about whether mod is off schedule await mod.add_roles(self.moderators_role, reason="Pings off period expired.") await self.pings_off_mods.delete(mod.id) @group(name="modpings", aliases=("modping",), invoke_without_command=True) - @has_any_role(*MODERATION_ROLES) async def modpings_group(self, ctx: Context) -> None: """Allow the removal and re-addition of the pingable moderators role.""" await ctx.send_help(ctx.command) @modpings_group.command(name="off") - @has_any_role(*MODERATION_ROLES) async def off_command(self, ctx: Context, duration: Expiry) -> None: """ Temporarily removes the pingable moderators role for a set amount of time. @@ -161,7 +164,7 @@ async def off_command(self, ctx: Context, duration: Expiry) -> None: The duration cannot be longer than 30 days. """ # noqa: RUF002 delta = duration - arrow.utcnow() - if delta > datetime.timedelta(days=30): + if delta > timedelta(days=30): await ctx.send(":x: Cannot remove the role for longer than 30 days.") return @@ -183,7 +186,6 @@ async def off_command(self, ctx: Context, duration: Expiry) -> None: ) @modpings_group.command(name="on") - @has_any_role(*MODERATION_ROLES) async def on_command(self, ctx: Context) -> None: """Re-apply the pingable moderators role.""" mod = ctx.author @@ -205,41 +207,51 @@ async def on_command(self, ctx: Context) -> None: aliases=("s",), invoke_without_command=True ) - @has_any_role(*MODERATION_ROLES) - async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None: - """Schedule modpings role to be added at and removed at everyday at UTC time!""" - start, end = dateutil_parse(start), dateutil_parse(end) + async def schedule_modpings(self, ctx: Context, start: str, end: str, tz: int | None) -> None: + """ + Schedule modpings role to be added at time and removed at time. + + Start and end times should be specified in a HH:MM format. + + You may specify a time zone offset for convenience. Times are considered in UTC by default. + + The schedule may be temporarily overridden using the on/off commands. + """ + try: + start, end = dateutil_parse(start), dateutil_parse(end) + except dateutil.parser._parser.ParserError as e: + raise BadArgument(str(e).capitalize()) if end < start: - end += datetime.timedelta(days=1) + end += timedelta(days=1) - if (end - start) > datetime.timedelta(hours=MAXIMUM_WORK_LIMIT): - await ctx.send( - f":x: {ctx.author.mention} You can't have the modpings role for" - f" more than {MAXIMUM_WORK_LIMIT} hours!" + if (end - start) > timedelta(hours=MAXIMUM_WORK_LIMIT): + await ctx.reply( + f":x: You can't have a schedule with mod pings on for more than {MAXIMUM_WORK_LIMIT} hours!" + " If you want to remove your schedule use the `modpings schedule delete` command." ) return - if start < datetime.datetime.now(datetime.UTC): + if start < datetime.now(UTC): # The datetime has already gone for the day, so make it tomorrow - # otherwise the scheduler would schedule it immediately - start += datetime.timedelta(days=1) + # otherwise the scheduler would schedule it immediately TODO but why not? + start += timedelta(days=1) - work_time = (end - start).total_seconds() + shift_time = (end - start).total_seconds() - await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{work_time}") + await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{shift_time}") - if ctx.author.id in self._modpings_scheduler: - self._modpings_scheduler.cancel(ctx.author.id) + if ctx.author.id in self._shift_scheduler: + self._shift_scheduler.cancel(ctx.author.id) # TODO here as well need to see if role should be re-applied. - self._modpings_scheduler.schedule_at( + self._shift_scheduler.schedule_at( start, ctx.author.id, - self.add_role_schedule(ctx.author, work_time, start) + self.add_role_by_schedule(ctx.author, shift_time, start) ) - await ctx.send( - f"{Emojis.ok_hand} {ctx.author.mention} Scheduled mod pings from " + await ctx.reply( + f"{Emojis.ok_hand} Scheduled mod pings from " f"{discord_timestamp(start, TimestampFormats.TIME)} to " f"{discord_timestamp(end, TimestampFormats.TIME)}!" ) @@ -247,15 +259,16 @@ async def schedule_modpings(self, ctx: Context, start: str, end: str) -> None: @schedule_modpings.command(name="delete", aliases=("del", "d")) async def modpings_schedule_delete(self, ctx: Context) -> None: """Delete your modpings schedule.""" - self._modpings_scheduler.cancel(ctx.author.id) + self._shift_scheduler.cancel(ctx.author.id) await self.modpings_schedule.delete(ctx.author.id) + # TODO: Apply the pingable role if was off schedule and pings not off await ctx.send(f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!") async def cog_unload(self) -> None: """Cancel role tasks when the cog unloads.""" log.trace("Cog unload: cancelling all scheduled tasks.") self._role_scheduler.cancel_all() - self._modpings_scheduler.cancel_all() + self._shift_scheduler.cancel_all() async def setup(bot: Bot) -> None: From 35289c52f1a0bd2b640f8256f7bdb1141cf4619a Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 14 Aug 2025 18:29:06 +0300 Subject: [PATCH 2/5] Fix modpings schedule command --- bot/exts/moderation/modpings.py | 191 ++++++++++++++++---------------- 1 file changed, 93 insertions(+), 98 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 289790a32b..8a27933648 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,5 +1,4 @@ -import asyncio -from datetime import UTC, datetime, timedelta +from datetime import UTC, timedelta import arrow import dateutil @@ -18,7 +17,8 @@ log = get_logger(__name__) -MAXIMUM_WORK_LIMIT = 23 +MIN_SHIFT_HOURS = 1 +MAX_SHIFT_HOURS = 23 class ModPings(Cog): @@ -29,10 +29,10 @@ class ModPings(Cog): # The cache's values are the times when the role should be re-applied to them, stored in ISO format. pings_off_mods = RedisCache() - # RedisCache[discord.Member.id, 'start timestamp|total worktime in seconds'] + # RedisCache[discord.Member.id, 'start time in HH:MM|shift duration in seconds'] # The cache's keys are mods' IDs # The cache's values are their pings on schedule timestamp and the total seconds (work time) until pings off - modpings_schedule = RedisCache() + modpings_schedules = RedisCache() def __init__(self, bot: Bot): self.bot = bot @@ -52,7 +52,6 @@ async def cog_load(self) -> None: self.guild = self.bot.get_guild(Guild.id) self.moderators_role = self.guild.get_role(Roles.moderators) - # await self.reschedule_modpings_schedule() TODO uncomment await self.reschedule_roles() async def reschedule_roles(self) -> None: @@ -68,76 +67,74 @@ async def reschedule_roles(self) -> None: await self.pings_off_mods.delete(mod.id) continue - # Keep the role off only for those in the redis cache. - if mod.id not in pings_off: - await self.reapply_role(mod) - else: - expiry = isoparse(pings_off[mod.id]) - self._role_scheduler.schedule_at(expiry, mod.id, self.reapply_role(mod)) + await self.handle_moderator_state(mod) # Add the role now or schedule it. # At this stage every entry in `pings_off` is expected to have a scheduled task, but that might not be the case # if the discord.py cache is missing members, or if the ID belongs to a former moderator. - for mod_id, expiry_iso in pings_off.items(): + for mod_id, _ in pings_off.items(): if mod_id not in self._role_scheduler: mod = await get_or_fetch_member(self.guild, mod_id) # Make sure the member is still a moderator and doesn't have the pingable role. if mod is None or mod.get_role(Roles.mod_team) is None or mod.get_role(Roles.moderators) is not None: await self.pings_off_mods.delete(mod_id) else: - self._role_scheduler.schedule_at(isoparse(expiry_iso), mod_id, self.reapply_role(mod)) - - async def reschedule_modpings_schedule(self) -> None: - """Reschedule moderators schedule ping.""" - schedule_cache = await self.modpings_schedule.to_dict() - - log.info("Scheduling modpings schedule for applicable moderators found in cache.") - for mod_id, schedule in schedule_cache.items(): - start_timestamp, work_time = schedule.split("|") - start = datetime.fromtimestamp(float(start_timestamp), tz=UTC) # TODO What if it's in the past? - - mod = await self.bot.fetch_user(mod_id) - self._shift_scheduler.schedule_at( - start, - mod_id, - self.add_role_by_schedule(mod, work_time, start) - ) + await self.handle_moderator_state(mod) - async def remove_role_by_schedule(self, mod: Member, shift_time: float, schedule_start: datetime) -> None: - """Removes the moderators role from the given moderator according to schedule.""" - log.trace(f"Removing moderator role from mod with ID {mod.id}") - await mod.remove_roles(self.moderators_role, reason="Moderator schedule time expired.") - - # Remove the task before scheduling it again - self._shift_scheduler.cancel(mod.id) - - # Add the task again - log.trace(f"Adding mod pings schedule task again for mod with ID {mod.id}") - schedule_start += timedelta(days=1) - self._shift_scheduler.schedule_at( - schedule_start, - mod.id, - self.add_role_by_schedule(mod, shift_time, schedule_start) - ) + # Similarly handle problems with the schedules cache. + for mod_id, _ in await self.modpings_schedules.items(): + if mod_id not in self._shift_scheduler: + mod = await get_or_fetch_member(self.guild, mod_id) + if mod is None or mod.get_role(Roles.mod_team) is None: + await self.modpings_schedules.delete(mod_id) + else: + await self.handle_moderator_state(mod) + + async def handle_moderator_state(self, mod: Member) -> None: + """Add/remove and/or schedule add/remove of the moderators role according to the mod's state in the caches.""" + expiry_iso = await self.pings_off_mods.get(mod.id, None) + if expiry_iso is not None: # The moderator has pings off regardless of recurring schedule. + if mod.id not in self._role_scheduler: + self._role_scheduler.schedule_at(isoparse(expiry_iso), mod.id, self.end_pings_off_period(mod)) + return # The recurring schedule will be handled when the pings off period ends. + + schedule_str = await self.modpings_schedules.get(mod.id, None) + if schedule_str is None: # No recurring schedule to handle. + if mod.get_role(self.moderators_role.id) is None: # The case of having pings off was already handled. + await mod.add_roles(self.moderators_role, reason="Pings off period expired.") + return - async def add_role_by_schedule(self, mod: Member, shift_time: float, schedule_start: datetime) -> None: - """Adds the moderators role to the given moderator.""" - # If the moderator has pings off, then skip adding role - if mod.id in await self.pings_off_mods.to_dict(): - log.trace(f"Skipping adding moderator role to mod with ID {mod.id} - found in pings off cache.") - else: - log.trace(f"Applying moderator role to mod with ID {mod.id}") - await mod.add_roles(self.moderators_role, reason="Moderator scheduled time started!") + start_time, shift_duration = schedule_str.split("|") + start = dateutil_parse(start_time).replace(tzinfo=UTC) + end = start + timedelta(seconds=int(shift_duration)) + now = arrow.utcnow() + + # Move the shift's day such that the end time is in the future and is closest. + if start - timedelta(days=1) < now < end - timedelta(days=1): # The shift started yesterday and is ongoing. + start -= timedelta(days=1) + end -= timedelta(days=1) + elif now > end: # Today's shift already ended, next one is tomorrow. + start += timedelta(days=1) + end += timedelta(days=1) - log.trace(f"Sleeping for {shift_time} seconds, worktime for mod with ID {mod.id}") - await asyncio.sleep(shift_time) # TODO don't hang the coroutine or call directly, rely on the scheduler. - await self.remove_role_by_schedule(mod, shift_time, schedule_start) + # The calls to `handle_moderator_state` here aren't recursive as the scheduler creates separate tasks. + # Start/end have to be differentiated in scheduler task ID. The task is removed from the scheduler only after + # completion. That means that task with ID X can't schedule a task with the same ID X. + if start < now < end: + if mod.get_role(self.moderators_role.id) is None: + await mod.add_roles(self.moderators_role, reason="Mod active hours started.") + if f"{mod.id}_end" not in self._shift_scheduler: + self._shift_scheduler.schedule_at(end, f"{mod.id}_end", self.handle_moderator_state(mod)) + else: + if mod.get_role(self.moderators_role.id) is not None: + await mod.remove_roles(self.moderators_role, reason="Mod active hours ended.") + if f"{mod.id}_start" not in self._shift_scheduler: + self._shift_scheduler.schedule_at(start, f"{mod.id}_start", self.handle_moderator_state(mod)) - async def reapply_role(self, mod: Member) -> None: + async def end_pings_off_period(self, mod: Member) -> None: """Reapply the moderators role to the given moderator.""" - log.trace(f"Re-applying role to mod with ID {mod.id}.") - # TODO currently doesn't care about whether mod is off schedule - await mod.add_roles(self.moderators_role, reason="Pings off period expired.") + log.trace(f"Ending pings off period of mod with ID {mod.id}.") await self.pings_off_mods.delete(mod.id) + await self.handle_moderator_state(mod) @group(name="modpings", aliases=("modping",), invoke_without_command=True) async def modpings_group(self, ctx: Context) -> None: @@ -147,7 +144,7 @@ async def modpings_group(self, ctx: Context) -> None: @modpings_group.command(name="off") async def off_command(self, ctx: Context, duration: Expiry) -> None: """ - Temporarily removes the pingable moderators role for a set amount of time. + Temporarily removes the pingable moderators role for a set amount of time. Overrides recurring schedule. A unit of time should be appended to the duration. Units (∗case-sensitive): @@ -178,7 +175,7 @@ async def off_command(self, ctx: Context, duration: Expiry) -> None: # Allow rescheduling the task without cancelling it separately via the `on` command. if mod.id in self._role_scheduler: self._role_scheduler.cancel(mod.id) - self._role_scheduler.schedule_at(duration, mod.id, self.reapply_role(mod)) + self._role_scheduler.schedule_at(duration, mod.id, self.end_pings_off_period(mod)) await ctx.send( f"{Emojis.check_mark} Moderators role has been removed " @@ -187,29 +184,29 @@ async def off_command(self, ctx: Context, duration: Expiry) -> None: @modpings_group.command(name="on") async def on_command(self, ctx: Context) -> None: - """Re-apply the pingable moderators role.""" + """ + Stops the pings-off period. + + Puts you back on your daily schedule if there is one, or re-applies the pingable moderators role immediately. + """ mod = ctx.author - if mod in self.moderators_role.members: - await ctx.send(":question: You already have the role.") + if not await self.pings_off_mods.contains(mod.id): + await ctx.send(":question: You're not in a special off period. Maybe you're off schedule?") return - await mod.add_roles(self.moderators_role, reason="Pings off period canceled.") - await self.pings_off_mods.delete(mod.id) # We assume the task exists. Lack of it may indicate a bug. self._role_scheduler.cancel(mod.id) - await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") + await self.handle_moderator_state(mod) - @modpings_group.group( - name="schedule", - aliases=("s",), - invoke_without_command=True - ) - async def schedule_modpings(self, ctx: Context, start: str, end: str, tz: int | None) -> None: + await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") # TODO make message more accurate. + + @modpings_group.group(name="schedule", aliases=("s",), invoke_without_command=True) + async def schedule_modpings(self, ctx: Context, start_time: str, end_time: str, tz: float | None) -> None: """ - Schedule modpings role to be added at time and removed at time. + Schedule pingable role to be added at `start` time and removed at `end` time. Any previous schedule is dropped. Start and end times should be specified in a HH:MM format. @@ -218,51 +215,49 @@ async def schedule_modpings(self, ctx: Context, start: str, end: str, tz: int | The schedule may be temporarily overridden using the on/off commands. """ try: - start, end = dateutil_parse(start), dateutil_parse(end) + start, end = dateutil_parse(start_time).replace(tzinfo=UTC), dateutil_parse(end_time).replace(tzinfo=UTC) except dateutil.parser._parser.ParserError as e: raise BadArgument(str(e).capitalize()) if end < start: end += timedelta(days=1) - if (end - start) > timedelta(hours=MAXIMUM_WORK_LIMIT): + if (end - start) < timedelta(hours=MIN_SHIFT_HOURS) or (end - start) > timedelta(hours=MAX_SHIFT_HOURS): await ctx.reply( - f":x: You can't have a schedule with mod pings on for more than {MAXIMUM_WORK_LIMIT} hours!" + f":x: Daily pings-on schedule duration must be between {MIN_SHIFT_HOURS} and {MAX_SHIFT_HOURS} hours." " If you want to remove your schedule use the `modpings schedule delete` command." + " If you want to remove pings for an extended period of time use the `modpings off` command." ) return - if start < datetime.now(UTC): - # The datetime has already gone for the day, so make it tomorrow - # otherwise the scheduler would schedule it immediately TODO but why not? - start += timedelta(days=1) - - shift_time = (end - start).total_seconds() + shift_duration = int((end - start).total_seconds()) - await self.modpings_schedule.set(ctx.author.id, f"{start.timestamp()}|{shift_time}") + if tz is not None: + start -= timedelta(hours=tz) + end -= timedelta(hours=tz) + start_time = f"{start.hour}:{start.minute}" + await self.modpings_schedules.set(ctx.author.id, f"{start_time}|{shift_duration}") - if ctx.author.id in self._shift_scheduler: - self._shift_scheduler.cancel(ctx.author.id) # TODO here as well need to see if role should be re-applied. + if f"{ctx.author.id}_start" in self._shift_scheduler: + self._shift_scheduler.cancel(f"{ctx.author.id}_start") + if f"{ctx.author.id}_end" in self._shift_scheduler: + self._shift_scheduler.cancel(f"{ctx.author.id}_end") - self._shift_scheduler.schedule_at( - start, - ctx.author.id, - self.add_role_by_schedule(ctx.author, shift_time, start) - ) + await self.handle_moderator_state(ctx.author) await ctx.reply( - f"{Emojis.ok_hand} Scheduled mod pings from " + f"{Emojis.ok_hand} Scheduled mod pings to be on every day from " f"{discord_timestamp(start, TimestampFormats.TIME)} to " - f"{discord_timestamp(end, TimestampFormats.TIME)}!" + f"{discord_timestamp(end, TimestampFormats.TIME)}." ) @schedule_modpings.command(name="delete", aliases=("del", "d")) async def modpings_schedule_delete(self, ctx: Context) -> None: """Delete your modpings schedule.""" self._shift_scheduler.cancel(ctx.author.id) - await self.modpings_schedule.delete(ctx.author.id) - # TODO: Apply the pingable role if was off schedule and pings not off - await ctx.send(f"{Emojis.ok_hand} {ctx.author.mention} Deleted your modpings schedule!") + await self.modpings_schedules.delete(ctx.author.id) + await self.handle_moderator_state(ctx.author) + await ctx.reply(f"{Emojis.ok_hand} Deleted your modpings schedule.") async def cog_unload(self) -> None: """Cancel role tasks when the cog unloads.""" From 6f4331882c143db3e6dc83f585a34b30dfe215c5 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 14 Aug 2025 18:35:47 +0300 Subject: [PATCH 3/5] Add sync command --- bot/exts/moderation/modpings.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 8a27933648..5889a94de4 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -259,6 +259,16 @@ async def modpings_schedule_delete(self, ctx: Context) -> None: await self.handle_moderator_state(ctx.author) await ctx.reply(f"{Emojis.ok_hand} Deleted your modpings schedule.") + @modpings_group.command(name="sync") + async def sync_command(self, ctx: Context) -> None: + """ + Attempt to re-sync your pingable moderators role with the stored state. + + If there is a reoccurring problem, please report it. + """ + await self.handle_moderator_state(ctx.author) + await ctx.reply(f"{Emojis.ok_hand} State re-synced.") + async def cog_unload(self) -> None: """Cancel role tasks when the cog unloads.""" log.trace("Cog unload: cancelling all scheduled tasks.") From ad9752faaa0b42515b156c31258cc8647d6f05eb Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 14 Aug 2025 18:50:18 +0300 Subject: [PATCH 4/5] Make the cog check more specific The commands in this cog don't make sense for admins and directors who aren't in the mod team --- bot/exts/moderation/modpings.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index 5889a94de4..fdb2ada8a7 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -5,12 +5,12 @@ from async_rediscache import RedisCache from dateutil.parser import isoparse, parse as dateutil_parse from discord import Member -from discord.ext.commands import BadArgument, Cog, Context, group, has_any_role +from discord.ext.commands import BadArgument, Cog, Context, group from pydis_core.utils.members import get_or_fetch_member from pydis_core.utils.scheduling import Scheduler from bot.bot import Bot -from bot.constants import Emojis, Guild, MODERATION_ROLES, Roles +from bot.constants import Emojis, Guild, Roles from bot.converters import Expiry from bot.log import get_logger from bot.utils.time import TimestampFormats, discord_timestamp @@ -44,7 +44,7 @@ def __init__(self, bot: Bot): async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return await has_any_role(*MODERATION_ROLES).predicate(ctx) + return ctx.author.get_role(Roles.mod_team) is not None async def cog_load(self) -> None: """Schedule both when to reapply role and all mod ping schedules.""" From 29238c56f326f417e25a423768db1c8e7052c034 Mon Sep 17 00:00:00 2001 From: Boris Muratov <8bee278@gmail.com> Date: Thu, 14 Aug 2025 20:38:59 +0300 Subject: [PATCH 5/5] Add status command --- bot/exts/moderation/modpings.py | 73 ++++++++++++++++++++++++++------- 1 file changed, 58 insertions(+), 15 deletions(-) diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index fdb2ada8a7..0be1afdd00 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -1,4 +1,4 @@ -from datetime import UTC, timedelta +from datetime import UTC, datetime, timedelta import arrow import dateutil @@ -89,6 +89,24 @@ async def reschedule_roles(self) -> None: else: await self.handle_moderator_state(mod) + @staticmethod + async def _parse_schedule(schedule: str) -> tuple[datetime, datetime]: + """Parse the schedule string stored in the schedules cache into the closest start and end times.""" + start_time, shift_duration = schedule.split("|") + start = dateutil_parse(start_time).replace(tzinfo=UTC) + end = start + timedelta(seconds=int(shift_duration)) + now = arrow.utcnow() + + # Move the shift's day such that the end time is in the future and is closest. + if start - timedelta(days=1) < now < end - timedelta(days=1): # The shift started yesterday and is ongoing. + start -= timedelta(days=1) + end -= timedelta(days=1) + elif now > end: # Today's shift already ended, next one is tomorrow. + start += timedelta(days=1) + end += timedelta(days=1) + + return start, end + async def handle_moderator_state(self, mod: Member) -> None: """Add/remove and/or schedule add/remove of the moderators role according to the mod's state in the caches.""" expiry_iso = await self.pings_off_mods.get(mod.id, None) @@ -103,23 +121,12 @@ async def handle_moderator_state(self, mod: Member) -> None: await mod.add_roles(self.moderators_role, reason="Pings off period expired.") return - start_time, shift_duration = schedule_str.split("|") - start = dateutil_parse(start_time).replace(tzinfo=UTC) - end = start + timedelta(seconds=int(shift_duration)) - now = arrow.utcnow() - - # Move the shift's day such that the end time is in the future and is closest. - if start - timedelta(days=1) < now < end - timedelta(days=1): # The shift started yesterday and is ongoing. - start -= timedelta(days=1) - end -= timedelta(days=1) - elif now > end: # Today's shift already ended, next one is tomorrow. - start += timedelta(days=1) - end += timedelta(days=1) + start, end = await self._parse_schedule(schedule_str) # The calls to `handle_moderator_state` here aren't recursive as the scheduler creates separate tasks. # Start/end have to be differentiated in scheduler task ID. The task is removed from the scheduler only after # completion. That means that task with ID X can't schedule a task with the same ID X. - if start < now < end: + if start < arrow.utcnow() < end: if mod.get_role(self.moderators_role.id) is None: await mod.add_roles(self.moderators_role, reason="Mod active hours started.") if f"{mod.id}_end" not in self._shift_scheduler: @@ -136,6 +143,33 @@ async def end_pings_off_period(self, mod: Member) -> None: await self.pings_off_mods.delete(mod.id) await self.handle_moderator_state(mod) + async def _get_current_status(self, mod_id: int) -> str: + """Build a string summarizing the moderator's current state and schedule (if one exists).""" + state = "on" + expiry_iso = await self.pings_off_mods.get(mod_id) + if expiry_iso is not None: + state = f"off until {discord_timestamp(isoparse(expiry_iso), format=TimestampFormats.DAY_TIME)}" + + schedule = "" + schedule_str = await self.modpings_schedules.get(mod_id, None) + if schedule_str is not None: + start, end = await self._parse_schedule(schedule_str) + if state == "on": + if start < arrow.utcnow() < end: + state = "on according to schedule" + else: + state = "off according to schedule" + if state.startswith("off until"): + schedule = " Otherwise, pings are on every day between " + else: + schedule = " Pings are on every day between " + schedule += ( + f"{discord_timestamp(start, TimestampFormats.TIME)} and " + f"{discord_timestamp(end, TimestampFormats.TIME)}." + ) + + return f"Pings are {state}.{schedule}" + @group(name="modpings", aliases=("modping",), invoke_without_command=True) async def modpings_group(self, ctx: Context) -> None: """Allow the removal and re-addition of the pingable moderators role.""" @@ -201,7 +235,8 @@ async def on_command(self, ctx: Context) -> None: await self.handle_moderator_state(mod) - await ctx.send(f"{Emojis.check_mark} Moderators role has been re-applied.") # TODO make message more accurate. + status = await self._get_current_status(mod.id) + await ctx.send(f"{Emojis.check_mark} {status}") @modpings_group.group(name="schedule", aliases=("s",), invoke_without_command=True) async def schedule_modpings(self, ctx: Context, start_time: str, end_time: str, tz: float | None) -> None: @@ -259,11 +294,19 @@ async def modpings_schedule_delete(self, ctx: Context) -> None: await self.handle_moderator_state(ctx.author) await ctx.reply(f"{Emojis.ok_hand} Deleted your modpings schedule.") + @modpings_group.command(name="status", aliases=("state",)) + async def status_command(self, ctx: Context) -> None: + """Show your current state and schedule (if one exists).""" + status = await self._get_current_status(ctx.author.id) + await ctx.reply(f":information: {status}") + @modpings_group.command(name="sync") async def sync_command(self, ctx: Context) -> None: """ Attempt to re-sync your pingable moderators role with the stored state. + You can view your stored state using the `modpings status` command. + If there is a reoccurring problem, please report it. """ await self.handle_moderator_state(ctx.author)