diff options
-rw-r--r-- | bot/converters.py | 50 | ||||
-rw-r--r-- | bot/exts/moderation/modpings.py | 50 | ||||
-rw-r--r-- | tests/bot/test_converters.py | 60 |
3 files changed, 149 insertions, 11 deletions
diff --git a/bot/converters.py b/bot/converters.py index 3522a32aa..b31ef76eb 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -573,6 +573,55 @@ class Infraction(Converter): raise e +class DayDuration(Converter): + """ + Convert a string representing day time (hours and minutes) to a UTC datetime object. + + The hours and minutes would be combined with UTC day. If no 'am' or 'pm' is passed with + the string, then it is assumed that the time is in 24 hour format. + + The following formats are accepted: + - H:M + - H:Mam/pm + - HMam/pm + - Ham/pm + - H + + where `H` represents Hours and `M` represents Minutes. + """ + + TIME_RE = re.compile( + r"^(1[0-2]|0?[1-9]):?([0-5][0-9])? ?([AaPp][Mm])$" # Twelve hour format + "|" + r"^([0-9]|0[0-9]|1[0-9]|2[0-4]):?([0-5][0-9])?$" # Twenty four hour format + ) + + async def convert(self, _ctx: Context, argument: str) -> datetime: + """Attempts to convert `argument` to a UTC datetime object.""" + match = self.TIME_RE.fullmatch(argument) + if not match: + raise BadArgument(f"`{argument}` is not a valid time duration string.") + + hour_12, minute_12, meridiem, hour_24, minute_24 = match.groups() + time = None + + if hour_12 and meridiem and minute_12: + time = datetime.strptime(f"{hour_12}:{minute_12} {meridiem}", "%I:%M %p") + elif hour_12 and meridiem: + time = datetime.strptime(f"{hour_12} {meridiem}", "%I %p") + elif hour_24 and minute_24: + time = datetime.strptime(f"{hour_24}:{minute_24}", "%H:%M") + else: + time = datetime.strptime(hour_24, "%H") + + today = datetime.utcnow().date() + return time.replace( + year=today.year, + month=today.month, + day=today.day + ) + + if t.TYPE_CHECKING: ValidDiscordServerInvite = dict # noqa: F811 ValidFilterListType = str # noqa: F811 @@ -591,6 +640,7 @@ if t.TYPE_CHECKING: UnambiguousUser = discord.User # noqa: F811 UnambiguousMember = discord.Member # noqa: F811 Infraction = t.Optional[dict] # noqa: F811 + DayDuration = datetime # noqa: F811 Expiry = t.Union[Duration, ISODateTime] MemberOrUser = t.Union[discord.Member, discord.User] diff --git a/bot/exts/moderation/modpings.py b/bot/exts/moderation/modpings.py index b5cd29b12..f8044ea7a 100644 --- a/bot/exts/moderation/modpings.py +++ b/bot/exts/moderation/modpings.py @@ -3,20 +3,21 @@ import datetime import arrow from async_rediscache import RedisCache -from dateutil.parser import isoparse, parse as dateutil_parse +from dateutil.parser import isoparse from discord import Embed, Member from discord.ext.commands import Cog, Context, group, has_any_role from bot.bot import Bot from bot.constants import Colours, Emojis, Guild, Icons, MODERATION_ROLES, Roles -from bot.converters import Expiry +from bot.converters import DayDuration, Expiry from bot.log import get_logger from bot.utils import scheduling, time +from bot.utils.members import get_or_fetch_member from bot.utils.scheduling import Scheduler log = get_logger(__name__) -MAXIMUM_WORK_LIMIT = 16 +MAXIMUM_WORK_OFF_LIMIT = 16 class ModPings(Cog): @@ -84,7 +85,12 @@ class ModPings(Cog): start_timestamp, work_time = schedule.split("|") start = datetime.datetime.fromtimestamp(float(start_timestamp)) - mod = await self.bot.fetch_user(mod_id) + guild = self.bot.get_guild(Guild.id) + mod = await get_or_fetch_member(guild, mod_id) + if not mod: + log.warning(f"I tried to get moderator with ID `{mod_id}`, but they don't appear to be on the server 😔") + continue + self._modpings_scheduler.schedule_at( start, mod_id, @@ -123,6 +129,15 @@ class ModPings(Cog): async def reapply_role(self, mod: Member) -> None: """Reapply the moderator's role to the given moderator.""" + mod_schedule = self.modpings_schedule.get(mod.id) + if ( + mod_schedule + and datetime.datetime.utcnow() + < datetime.datetime.utcfromtimestamp(mod_schedule.split("|")[0]) + ): + log.trace(f"Skipping re-applying role to mod with ID {mod.id} as their modpings schedule is over.") + return + log.trace(f"Re-applying role to mod with ID {mod.id}.") await mod.add_roles(self.moderators_role, reason="Pings off period expired.") await self.pings_off_mods.delete(mod.id) @@ -198,17 +213,32 @@ class ModPings(Cog): 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 <start> and removed at <end> everyday at UTC time!""" - start, end = dateutil_parse(start), dateutil_parse(end) + async def schedule_modpings(self, ctx: Context, start: DayDuration, end: DayDuration) -> None: + """ + Schedule modpings role to be added at <start> and removed at <end> every day (UTC time)! + + You must have the pingable Moderators role for a minimum of 8 hours a day, + meaning the schedule removing this role has a maximum duration of 16 hours. + The command expects two arguments, `start` and `end`, which are when the role is removed and re-added. + The following formats are accepted for `start` and `end`: + - H:Mam/pm (10:14pm) + - HMam/pm (1014pm) + - Ham/pm (10pm) + - H (22 - 24hour format as no meridiem is specified) + - HM (2214 - 24hour format as no meridiem is specified) + + The pingable Moderators role won't be re-added until the scheduled time has finished. + """ if end < start: end += datetime.timedelta(days=1) - if (end - start) > datetime.timedelta(hours=MAXIMUM_WORK_LIMIT): + modpings_on_period = end - start + # Check if the modpings off period for a day is more than the max + if datetime.timedelta(hours=24) - modpings_on_period > datetime.timedelta(hours=MAXIMUM_WORK_OFF_LIMIT): await ctx.send( - f":x: {ctx.author.mention} You can't have the modpings role for" - f" more than {MAXIMUM_WORK_LIMIT} hours!" + f":x: {ctx.author.mention} You can't have the modpings role off for" + f" more than {MAXIMUM_WORK_OFF_LIMIT} hours!" ) return diff --git a/tests/bot/test_converters.py b/tests/bot/test_converters.py index 1bb678db2..880f86e28 100644 --- a/tests/bot/test_converters.py +++ b/tests/bot/test_converters.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument -from bot.converters import Duration, HushDurationConverter, ISODateTime, PackageName +from bot.converters import DayDuration, Duration, HushDurationConverter, ISODateTime, PackageName class ConverterTests(unittest.IsolatedAsyncioTestCase): @@ -252,3 +252,61 @@ class ConverterTests(unittest.IsolatedAsyncioTestCase): with self.subTest(invalid_minutes_string=invalid_minutes_string, exception_message=exception_message): with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): await converter.convert(self.context, invalid_minutes_string) + + async def test_day_duration_convertor_for_valid(self): + """DayDuration converter returns correct datetime for valid datetime string.""" + test_values = ( + # H:M am/pm + ("2:14 pm", 51240), + ("2:14 am", 8040), + ("2:14Pm", 51240), + ("2:14AM", 8040), + + # HM am/pm + ("942pm", 78120), + ("854 am", 32040), + + # H am/pm + ("11pm", 82800), + ("2 am", 7200), + + # H:M + ("2:14", 8040), + ("23:05", 83100), + ("2305", 83100), + + # H + ("5", 18000), + ("18", 64800), + ) + converter = DayDuration() + for day_duration_string, expected_1970 in test_values: + with self.subTest(day_duration_string=day_duration_string, expected_1970_dt=expected_1970): + converted = await converter.convert(self.context, day_duration_string) + + expected_1970_dt = datetime.utcfromtimestamp(expected_1970) + today = datetime.utcnow().date() + expected_now = expected_1970_dt.replace( + year=today.year, + month=today.month, + day=today.day + ) + + self.assertEqual(expected_now, converted) + + async def test_day_duration_convertor_for_invalid(self): + """DayDuration converter raises the correct exception for invalid datetime strings.""" + test_values = ( + # Check if it fails when providing the date part + '2019-11-12 09:15', + + # Other non-valid strings + 'fisk the tag master', + ) + + converter = DayDuration() + for datetime_string in test_values: + with self.subTest(datetime_string=datetime_string): + exception_message = f"`{datetime_string}` is not a valid time duration string." + with self.assertRaisesRegex(BadArgument, re.escape(exception_message)): + await converter.convert(self.context, datetime_string) |