aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/converters.py50
-rw-r--r--bot/exts/moderation/modpings.py50
-rw-r--r--tests/bot/test_converters.py60
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)