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)  |