From a6ae842fec87e0467de91438c1c4ac1b1a4a2684 Mon Sep 17 00:00:00 2001 From: Izan Date: Mon, 25 Jul 2022 22:20:50 +0100 Subject: Add ability to delete multiple reminders --- bot/exts/utils/reminders.py | 71 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 58 insertions(+), 13 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 45cddd7a2..a08ba8e75 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -5,14 +5,18 @@ from datetime import datetime, timezone from operator import itemgetter import discord +from botcore.site_api import ResponseCodeError from botcore.utils import scheduling from botcore.utils.scheduling import Scheduler from dateutil.parser import isoparse from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES +from bot.constants import ( + Guild, Icons, MODERATION_ROLES, NEGATIVE_REPLIES, POSITIVE_REPLIES, Roles, STAFF_PARTNERS_COMMUNITY_ROLES +) from bot.converters import Duration, UnambiguousUser +from bot.errors import LockedResourceError from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import time @@ -450,35 +454,76 @@ class Reminders(Cog): ) await self._reschedule_reminder(reminder) - @remind_group.command("delete", aliases=("remove", "cancel")) @lock_arg(LOCK_NAMESPACE, "id_", raise_error=True) - async def delete_reminder(self, ctx: Context, id_: int) -> None: - """Delete one of your active reminders.""" - if not await self._can_modify(ctx, id_): - return + async def _delete_reminder(self, ctx: Context, id_: int) -> bool: + """Acquires a lock on `id_` and returns `True` if reminder is deleted, otherwise `False`.""" + if not await self._can_modify(ctx, id_, send_on_denial=False): + return False await self.bot.api_client.delete(f"bot/reminders/{id_}") self.scheduler.cancel(id_) + return True - await self._send_confirmation( - ctx, - on_success="That reminder has been deleted successfully!", - reminder_id=id_ + @remind_group.command("delete", aliases=("remove", "cancel")) + async def delete_reminder(self, ctx: Context, ids: Greedy[int]) -> None: + """Delete one of your active reminders.""" + deleted_ids = [] + for id_ in ids: + try: + reminder_deleted = await self._delete_reminder(ctx, id_) + except LockedResourceError: + continue + else: + if reminder_deleted: + deleted_ids.append(str(id_)) + + if deleted_ids: + colour = discord.Colour.green() + title = random.choice(POSITIVE_REPLIES) + deletion_message = f"Successfully deleted the following reminder(s): {', '.join(deleted_ids)}" + + if len(deleted_ids) != len(ids): + deletion_message += ( + "\n\nThe other reminder(s) could not be deleted as they're either locked, " + "belong to someone else, or don't exist." + ) + else: + colour = discord.Colour.red() + title = random.choice(NEGATIVE_REPLIES) + deletion_message = ( + "Could not delete the reminder(s) as they're either locked, " + "belong to someone else, or don't exist." + ) + + embed = discord.Embed( + description=deletion_message, + colour=colour, + title=title ) + await ctx.send(embed=embed) - async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: + async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int], send_on_denial: bool = True) -> bool: """ Check whether the reminder can be modified by the ctx author. The check passes when the user is an admin, or if they created the reminder. """ + try: + api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") + except ResponseCodeError as e: + # Override error-handling so that a 404 message isn't sent to Discord when `send_on_denial` is `False` + if not send_on_denial: + if e.status == 404: + return False + raise e + if await has_any_role_check(ctx, Roles.admins): return True - api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") if not api_response["author"] == ctx.author.id: log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") - await send_denial(ctx, "You can't modify reminders of other users!") + if send_on_denial: + await send_denial(ctx, "You can't modify reminders of other users!") return False log.debug(f"{ctx.author} is the reminder author and passes the check.") -- cgit v1.2.3 From f7bff3d0c2e9711e147e327f44169e4718535f66 Mon Sep 17 00:00:00 2001 From: Izan Date: Mon, 25 Jul 2022 22:21:41 +0100 Subject: Display mentions instead of name attribute in `!reminder list` --- bot/exts/utils/reminders.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index a08ba8e75..51fdd7873 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -352,8 +352,8 @@ class Reminders(Cog): expiry = time.format_relative(remind_at) mentions = ", ".join([ - # Both Role and User objects have the `name` attribute - mention.name async for mention in self.get_mentionables(mentions) + # Both Role and User objects have the `mention` attribute + mentionable.mention async for mentionable in self.get_mentionables(mentions) ]) mention_string = f"\n**Mentions:** {mentions}" if mentions else "" @@ -381,7 +381,6 @@ class Reminders(Cog): lines, ctx, embed, max_lines=3, - empty=True ) @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) -- cgit v1.2.3 From 40ab60fdf85f44b08f0a9316a22e1b0bef6e0646 Mon Sep 17 00:00:00 2001 From: Izan Date: Mon, 25 Jul 2022 23:14:27 +0100 Subject: Allow referencing message as argument to `!remind edit content` --- bot/exts/utils/reminders.py | 43 ++++++++++++++++++++++++++++++++----------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 45cddd7a2..f33a9f448 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -209,6 +209,29 @@ class Reminders(Cog): log.debug(f"Deleting reminder #{reminder['id']} (the user has been reminded).") await self.bot.api_client.delete(f"bot/reminders/{reminder['id']}") + @staticmethod + async def try_get_content_from_reply(ctx: Context) -> t.Optional[str]: + """ + Attempts to get content from the referenced message, if applicable. + + Differs from botcore.utils.commands.clean_text_or_reply as allows for embeds. + """ + content = None + if reference := ctx.message.reference: + if isinstance((resolved_message := reference.resolved), discord.Message): + content = resolved_message.content + + # If we weren't able to get the content of a replied message + if content is None: + await send_denial(ctx, "Your reminder must have a content and/or reply to a message.") + return + + # If the replied message has no content (e.g. only attachments/embeds) + if content == "": + content = "See referenced message." + + return content + @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( self, ctx: Context, mentions: Greedy[ReminderMention], expiration: Duration, *, content: t.Optional[str] = None @@ -282,18 +305,11 @@ class Reminders(Cog): # If `content` isn't provided then we try to get message content of a replied message if not content: - if reference := ctx.message.reference: - if isinstance((resolved_message := reference.resolved), discord.Message): - content = resolved_message.content - # If we weren't able to get the content of a replied message - if content is None: - await send_denial(ctx, "Your reminder must have a content and/or reply to a message.") + content = await self.try_get_content_from_reply(ctx) + if not content: + # Couldn't get content from reply return - # If the replied message has no content (e.g. only attachments/embeds) - if content == "": - content = "See referenced message." - # Now we can attempt to actually set the reminder. reminder = await self.bot.api_client.post( 'bot/reminders', @@ -417,8 +433,13 @@ class Reminders(Cog): await self.edit_reminder(ctx, id_, {'expiration': expiration.isoformat()}) @edit_reminder_group.command(name="content", aliases=("reason",)) - async def edit_reminder_content(self, ctx: Context, id_: int, *, content: str) -> None: + async def edit_reminder_content(self, ctx: Context, id_: int, *, content: t.Optional[str] = None) -> None: """Edit one of your reminder's content.""" + if not content: + content = await self.try_get_content_from_reply(ctx) + if not content: + # Couldn't get content from reply + return await self.edit_reminder(ctx, id_, {"content": content}) @edit_reminder_group.command(name="mentions", aliases=("pings",)) -- cgit v1.2.3 From 0be3332904690730589113b99198f2f3fc2f1091 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 03:42:31 -0400 Subject: Added `DurationOrExpiry` type union --- bot/converters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/converters.py b/bot/converters.py index 5800ea044..e97a25bdd 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -574,5 +574,6 @@ if t.TYPE_CHECKING: Infraction = t.Optional[dict] # noqa: F811 Expiry = t.Union[Duration, ISODateTime] +DurationOrExpiry = t.Union[DurationDelta, ISODateTime] MemberOrUser = t.Union[discord.Member, discord.User] UnambiguousMemberOrUser = t.Union[UnambiguousMember, UnambiguousUser] -- cgit v1.2.3 From 0921838112120355aed0936923983b545320edef Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 04:31:05 -0400 Subject: Refactoring for DurationOrExpiry --- bot/exts/moderation/infraction/_utils.py | 20 ++++++++++----- bot/exts/moderation/infraction/infractions.py | 34 +++++++++++++------------- bot/exts/moderation/infraction/superstarify.py | 4 +-- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 3a2485ec2..368a637c2 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,5 +1,5 @@ import typing as t -from datetime import datetime +from datetime import datetime, timezone import arrow import discord @@ -8,7 +8,7 @@ from discord.ext.commands import Context import bot from bot.constants import Colours, Icons -from bot.converters import MemberOrUser +from bot.converters import MemberOrUser, DurationOrExpiry from bot.errors import InvalidInfractedUserError from bot.log import get_logger from bot.utils import time @@ -80,7 +80,7 @@ async def post_infraction( user: MemberOrUser, infr_type: str, reason: str, - expires_at: datetime = None, + duration_or_expiry: t.Optional[DurationOrExpiry] = None, hidden: bool = False, active: bool = True, dm_sent: bool = False, @@ -92,6 +92,8 @@ async def post_infraction( log.trace(f"Posting {infr_type} infraction for {user} to the API.") + current_time = datetime.now(tz=timezone.utc) + payload = { "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. "hidden": hidden, @@ -99,10 +101,16 @@ async def post_infraction( "type": infr_type, "user": user.id, "active": active, - "dm_sent": dm_sent + "dm_sent": dm_sent, + "last_applied": current_time, } - if expires_at: - payload['expires_at'] = expires_at.isoformat() + + # Parse duration or expiry + if duration_or_expiry is not None: + if isinstance(duration_or_expiry, datetime): + payload['expires_at'] = duration_or_expiry.isoformat() + else: # is relativedelta + payload['expires_at'] = (current_time + duration_or_expiry).isoformat() # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. for should_post_user in (True, False): diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 46fd3381c..1818997bb 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser +from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser, DurationOrExpiry from bot.decorators import ensure_future_timestamp, respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -86,7 +86,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[Expiry] = None, + duration: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] = None ) -> None: @@ -95,7 +95,7 @@ class Infractions(InfractionScheduler, commands.Cog): If duration is specified, it temporarily bans that user for the given duration. """ - await self.apply_ban(ctx, user, reason, expires_at=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) @command(aliases=("cban", "purgeban", "pban")) @ensure_future_timestamp(timestamp_arg=3) @@ -103,7 +103,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[Expiry] = None, + duration: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] = None ) -> None: @@ -115,10 +115,10 @@ class Infractions(InfractionScheduler, commands.Cog): clean_cog: t.Optional[Clean] = self.bot.get_cog("Clean") if clean_cog is None: # If we can't get the clean cog, fall back to native purgeban. - await self.apply_ban(ctx, user, reason, purge_days=1, expires_at=duration) + await self.apply_ban(ctx, user, reason, purge_days=1, duration_or_expiry=duration) return - infraction = await self.apply_ban(ctx, user, reason, expires_at=duration) + infraction = await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) if not infraction or not infraction.get("id"): # Ban was unsuccessful, quit early. await ctx.send(":x: Failed to apply ban.") @@ -168,7 +168,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[Expiry] = None, + duration: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] ) -> None: @@ -177,7 +177,7 @@ class Infractions(InfractionScheduler, commands.Cog): If duration is specified, it temporarily voice mutes that user for the given duration. """ - await self.apply_voice_mute(ctx, user, reason, expires_at=duration) + await self.apply_voice_mute(ctx, user, reason, duration_or_expiry=duration) # endregion # region: Temporary infractions @@ -187,7 +187,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempmute( self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[Expiry] = None, + duration: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] = None ) -> None: @@ -214,7 +214,7 @@ class Infractions(InfractionScheduler, commands.Cog): if duration is None: duration = await Duration().convert(ctx, "1h") - await self.apply_mute(ctx, user, reason, expires_at=duration) + await self.apply_mute(ctx, user, reason, duration_or_expiry=duration) @command(aliases=("tban",)) @ensure_future_timestamp(timestamp_arg=3) @@ -241,7 +241,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_ban(ctx, user, reason, expires_at=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) @command(aliases=("tempvban", "tvban")) async def tempvoiceban(self, ctx: Context) -> None: @@ -258,7 +258,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: Expiry, + duration: DurationOrExpiry, *, reason: t.Optional[str] ) -> None: @@ -277,7 +277,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_voice_mute(ctx, user, reason, expires_at=duration) + await self.apply_voice_mute(ctx, user, reason, duration_or_expiry=duration) # endregion # region: Permanent shadow infractions @@ -305,7 +305,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: Expiry, + duration: DurationOrExpiry, *, reason: t.Optional[str] = None ) -> None: @@ -324,7 +324,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration, hidden=True) # endregion # region: Remove infractions (un- commands) @@ -428,7 +428,7 @@ class Infractions(InfractionScheduler, commands.Cog): return None # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active - is_temporary = kwargs.get("expires_at") is not None + is_temporary = kwargs.get("duration_or_expiry") is not None active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary) if active_infraction: @@ -436,7 +436,7 @@ class Infractions(InfractionScheduler, commands.Cog): log.trace("Tempban ignored as it cannot overwrite an active ban.") return None - if active_infraction.get('expires_at') is None: + if active_infraction.get("duration_or_expiry") is None: log.trace("Permaban already exists, notify.") await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") return None diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 0e6aaa1e7..f2aab7a92 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -10,7 +10,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Duration, Expiry +from bot.converters import Duration, DurationOrExpiry from bot.decorators import ensure_future_timestamp from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -109,7 +109,7 @@ class Superstarify(InfractionScheduler, Cog): self, ctx: Context, member: Member, - duration: t.Optional[Expiry], + duration: t.Optional[DurationOrExpiry], *, reason: str = '', ) -> None: -- cgit v1.2.3 From 73a67d175283ca0525af4520018aa01f288bd242 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 04:38:18 -0400 Subject: Ran isort on imports --- bot/exts/moderation/infraction/_utils.py | 2 +- bot/exts/moderation/infraction/infractions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 368a637c2..80b65dae8 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -8,7 +8,7 @@ from discord.ext.commands import Context import bot from bot.constants import Colours, Icons -from bot.converters import MemberOrUser, DurationOrExpiry +from bot.converters import DurationOrExpiry, MemberOrUser from bot.errors import InvalidInfractedUserError from bot.log import get_logger from bot.utils import time diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 1818997bb..aff5e392f 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser, DurationOrExpiry +from bot.converters import Age, Duration, DurationOrExpiry, Expiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import ensure_future_timestamp, respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler -- cgit v1.2.3 From 7f153894d656d01a6221fa6520ec1df9a37bd478 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 04:54:26 -0400 Subject: Updated tests - Refactored tests for new time duration arguments --- .../exts/moderation/infraction/test_infractions.py | 11 ++++---- tests/bot/exts/moderation/infraction/test_utils.py | 29 +++++++++++++--------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 052048053..a18a4d23b 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -79,13 +79,13 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): """Should call voice mute applying function without expiry.""" self.cog.apply_voice_mute = AsyncMock() self.assertIsNone(await self.cog.voicemute(self.cog, self.ctx, self.user, reason="foobar")) - self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None) + self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", duration_or_expiry=None) async def test_temporary_voice_mute(self): """Should call voice mute applying function with expiry.""" self.cog.apply_voice_mute = AsyncMock() self.assertIsNone(await self.cog.tempvoicemute(self.cog, self.ctx, self.user, "baz", reason="foobar")) - self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", duration_or_expiry="baz") async def test_voice_unmute(self): """Should call infraction pardoning function.""" @@ -189,7 +189,8 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): user = MockUser() await self.cog.voicemute(self.cog, self.ctx, user, reason=None) - post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, expires_at=None) + post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, + duration_or_expiry=None) apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY) # Test action @@ -273,7 +274,7 @@ class CleanBanTests(unittest.IsolatedAsyncioTestCase): self.user, "FooBar", purge_days=1, - expires_at=None, + duration_or_expiry=None, ) async def test_cleanban_doesnt_purge_messages_if_clean_cog_available(self): @@ -285,7 +286,7 @@ class CleanBanTests(unittest.IsolatedAsyncioTestCase): self.ctx, self.user, "FooBar", - expires_at=None, + duration_or_expiry=None, ) @patch("bot.exts.moderation.infraction.infractions.Age") diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 5cf02033d..4c78c0bd8 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -318,14 +318,17 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "user": self.member.id, "active": False, "expires_at": now.isoformat(), - "dm_sent": False + "dm_sent": False, + "last_applied": datetime(2020, 1, 1).isoformat(), } - self.ctx.bot.api_client.post.return_value = "foo" - actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) + # Patch the time.now(tz=timezone.utc) function to return a specific time + with patch("bot.exts.moderation.infraction._utils.datetime.now", return_value=datetime(2020, 1, 1)): + self.ctx.bot.api_client.post.return_value = "foo" + actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) - self.assertEqual(actual, "foo") - self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.assertEqual(actual, "foo") + self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" @@ -356,12 +359,14 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "type": "mute", "user": self.user.id, "active": True, - "dm_sent": False + "dm_sent": False, + "last_applied": datetime(2020, 1, 1), } - self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] - - actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") - self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) - post_user_mock.assert_awaited_once_with(self.ctx, self.user) + # Patch the time.now(tz=timezone.utc) function to return a specific time + with patch("bot.exts.moderation.infraction._utils.datetime.now", return_value=datetime(2020, 1, 1)): + self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] + actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") + self.assertEqual(actual, "foo") + self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From 2670fc4027f30776eb091682c6cdd48188793163 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 05:04:38 -0400 Subject: Fixed test patches --- tests/bot/exts/moderation/infraction/test_utils.py | 32 ++++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 4c78c0bd8..def06932b 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -307,7 +307,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) - async def test_normal_post_infraction(self): + @patch("bot.exts.moderation.infraction._utils.datetime", wraps=datetime) + async def test_normal_post_infraction(self, mock_datetime): """Should return response from POST request if there are no errors.""" now = datetime.now() payload = { @@ -322,13 +323,13 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "last_applied": datetime(2020, 1, 1).isoformat(), } - # Patch the time.now(tz=timezone.utc) function to return a specific time - with patch("bot.exts.moderation.infraction._utils.datetime.now", return_value=datetime(2020, 1, 1)): - self.ctx.bot.api_client.post.return_value = "foo" - actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) + # Patch the datetime.now function to return a specific time + mock_datetime.now.return_value = datetime(2020, 1, 1) + self.ctx.bot.api_client.post.return_value = "foo" + actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) - self.assertEqual(actual, "foo") - self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.assertEqual(actual, "foo") + self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" @@ -349,8 +350,9 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.assertIsNone(actual) post_user_mock.assert_awaited_once_with(self.ctx, self.user) + @patch("bot.exts.moderation.infraction._utils.datetime", wraps=datetime) @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar") - async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): + async def test_first_fail_second_success_user_post_infraction(self, post_user_mock, mock_datetime): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" payload = { "actor": self.ctx.author.id, @@ -363,10 +365,10 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "last_applied": datetime(2020, 1, 1), } - # Patch the time.now(tz=timezone.utc) function to return a specific time - with patch("bot.exts.moderation.infraction._utils.datetime.now", return_value=datetime(2020, 1, 1)): - self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] - actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") - self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) - post_user_mock.assert_awaited_once_with(self.ctx, self.user) + # Patch the datetime.now function to return a specific time + mock_datetime.now.return_value = datetime(2020, 1, 1) + self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] + actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") + self.assertEqual(actual, "foo") + self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From c85f7a5ecb46448a235588329439545b42091006 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 05:19:30 -0400 Subject: Added isoformat for test payload --- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index def06932b..d3a908b28 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -362,7 +362,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "user": self.user.id, "active": True, "dm_sent": False, - "last_applied": datetime(2020, 1, 1), + "last_applied": datetime(2020, 1, 1).isoformat(), } # Patch the datetime.now function to return a specific time -- cgit v1.2.3 From 9d73552235b8e80f90dec06d7c0220e84734e205 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 05:20:48 -0400 Subject: Updated parameter names - Changed `duration` parameter names to `duration_or_expiry` to more accurately reflect options for help --- bot/exts/moderation/infraction/infractions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index aff5e392f..12bd084df 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Age, Duration, DurationOrExpiry, Expiry, MemberOrUser, UnambiguousMemberOrUser +from bot.converters import Age, Duration, DurationOrExpiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import ensure_future_timestamp, respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -86,7 +86,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[DurationOrExpiry] = None, + duration_or_expiry: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] = None ) -> None: @@ -95,7 +95,7 @@ class Infractions(InfractionScheduler, commands.Cog): If duration is specified, it temporarily bans that user for the given duration. """ - await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) @command(aliases=("cban", "purgeban", "pban")) @ensure_future_timestamp(timestamp_arg=3) @@ -222,7 +222,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: Expiry, + duration_or_expiry: DurationOrExpiry, *, reason: t.Optional[str] = None ) -> None: @@ -241,7 +241,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) @command(aliases=("tempvban", "tvban")) async def tempvoiceban(self, ctx: Context) -> None: -- cgit v1.2.3 From 517d3aad25c912a0f5ae47388a0d5a9deefe9d50 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 05:36:45 -0400 Subject: Updated ban command docstring - Updated docstring to be more explicit on parameter fields --- bot/exts/moderation/infraction/infractions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 12bd084df..83a947a19 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -91,9 +91,11 @@ class Infractions(InfractionScheduler, commands.Cog): reason: t.Optional[str] = None ) -> None: """ - Permanently ban a user for the given reason and stop watching them with Big Brother. + Permanently ban a `user` for the given `reason` and stop watching them with Big Brother. - If duration is specified, it temporarily bans that user for the given duration. + If a duration is specified, it temporarily bans the `user` for the given duration. + Alternatively, an ISO 8601 timestamp representing the expiry time can be provided + for `duration_or_expiry`. """ await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) -- cgit v1.2.3 From 14eb99bf130ab4f8a7e14d9b4a4172a969e7a1c0 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 06:11:11 -0400 Subject: Fixed tests - Corrected datetime patching --- tests/bot/exts/moderation/infraction/test_utils.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index d3a908b28..b1f23e31c 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -307,8 +307,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) - @patch("bot.exts.moderation.infraction._utils.datetime", wraps=datetime) - async def test_normal_post_infraction(self, mock_datetime): + async def test_normal_post_infraction(self): """Should return response from POST request if there are no errors.""" now = datetime.now() payload = { @@ -320,16 +319,18 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "active": False, "expires_at": now.isoformat(), "dm_sent": False, - "last_applied": datetime(2020, 1, 1).isoformat(), } - # Patch the datetime.now function to return a specific time - mock_datetime.now.return_value = datetime(2020, 1, 1) self.ctx.bot.api_client.post.return_value = "foo" actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) self.assertEqual(actual, "foo") - self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.ctx.bot.api_client.post.assert_awaited_once() + await_args = str(self.ctx.bot.api_client.post.await_args) + # Check existing keys present, allow for additional keys (e.g. `last_applied`) + for key, value in payload.items(): + self.assertTrue(key in await_args) + self.assertTrue(str(value) in await_args) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" -- cgit v1.2.3 From f3c15ed33185c110f486c6e933ea46272c25f6db Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 06:11:23 -0400 Subject: Correct last_applied formatting --- bot/exts/moderation/infraction/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 80b65dae8..cd82f5b2e 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -102,7 +102,7 @@ async def post_infraction( "user": user.id, "active": active, "dm_sent": dm_sent, - "last_applied": current_time, + "last_applied": current_time.isoformat(), } # Parse duration or expiry -- cgit v1.2.3 From a98c82c1ec6b244868356b1b2db1daa52e0f2555 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 18:57:01 -0400 Subject: Use `last_applied` to display duration --- bot/exts/moderation/infraction/_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index cd82f5b2e..23ae517e9 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -188,9 +188,10 @@ async def notify_infraction( expires_at = "Never" duration = "Permanent" else: + origin = arrow.get(infraction["last_applied"]) expiry = arrow.get(infraction["expires_at"]) expires_at = time.format_relative(expiry) - duration = time.humanize_delta(infraction["inserted_at"], expiry, max_units=2) + duration = time.humanize_delta(origin, expiry, max_units=2) if infraction["active"]: remaining = time.humanize_delta(expiry, arrow.utcnow(), max_units=2) -- cgit v1.2.3 From b115d42195841e69e38c601f40a6a49826e7eac6 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sun, 31 Jul 2022 02:09:35 -0400 Subject: Added new expiry usage to apply - Added new usage of `last_applied` time for duration calculation in `apply_infraction` --- bot/exts/moderation/infraction/_scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index c7f03b2e9..28aafec2a 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -137,7 +137,7 @@ class InfractionScheduler: infr_type = infraction["type"] icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = time.format_with_duration(infraction["expires_at"]) + expiry = time.format_with_duration(infraction["expires_at"], infraction["last_applied"]) id_ = infraction['id'] if user_reason is None: -- cgit v1.2.3 From b699d114c5fb5810915dc85f5aa0649a0b091a79 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Mon, 1 Aug 2022 00:55:00 -0400 Subject: Added microsecond rounding for `humanize_delta` --- bot/utils/time.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index a0379c3ef..104ea026d 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -195,7 +195,11 @@ def humanize_delta( end = arrow.get(args[0]) start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow() - delta = relativedelta(end.datetime, start.datetime) + # Round microseconds + end = round_datetime(end.datetime) + start = round_datetime(start.datetime) + + delta = relativedelta(end, start) if absolute: delta = abs(delta) else: @@ -326,3 +330,14 @@ def until_expiration(expiry: Optional[Timestamp]) -> str: return "Expired" return format_relative(expiry) + + +def round_datetime(dt: datetime.datetime) -> datetime.datetime: + """ + Round a datetime object to the nearest second. + + Resulting datetime objects will have microsecond values of 0, useful for delta comparisons. + """ + if dt.microsecond >= 500000: + dt += datetime.timedelta(seconds=1) + return dt.replace(microsecond=0) -- cgit v1.2.3 From 090824f125e1e6879d179b00bd3297d829c0920a Mon Sep 17 00:00:00 2001 From: ionite34 Date: Mon, 1 Aug 2022 01:00:00 -0400 Subject: Infraction duration fallback if no `last_applied` field --- bot/exts/moderation/infraction/_scheduler.py | 8 +++++++- bot/exts/moderation/infraction/_utils.py | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 28aafec2a..cfb585bf5 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -137,9 +137,15 @@ class InfractionScheduler: infr_type = infraction["type"] icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = time.format_with_duration(infraction["expires_at"], infraction["last_applied"]) id_ = infraction['id'] + if "last_applied" in infraction: + origin = infraction["last_applied"] + else: # Fallback for previous API versions without `last_applied` + log.trace(f"No last_applied for infraction {id_}, using inserted_at time.") + origin = infraction["inserted_at"] + expiry = time.format_with_duration(infraction["expires_at"], origin) + if user_reason is None: user_reason = reason diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 23ae517e9..470ce05e1 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -188,7 +188,12 @@ async def notify_infraction( expires_at = "Never" duration = "Permanent" else: - origin = arrow.get(infraction["last_applied"]) + if "last_applied" in infraction: + origin = infraction["last_applied"] + else: # Fallback for previous API versions without `last_applied` + log.trace(f"No last_applied for infraction {infraction}, using inserted_at time.") + origin = infraction["inserted_at"] + origin = arrow.get(origin) expiry = arrow.get(infraction["expires_at"]) expires_at = time.format_relative(expiry) duration = time.humanize_delta(origin, expiry, max_units=2) -- cgit v1.2.3 From 0f4bc18ceb5ef5e55e003da409a3da8e3e1d9cf8 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Thu, 4 Aug 2022 14:54:09 +0100 Subject: Disable nose plugin in pytest This fixes an issue with pytest running functions called setup in test files when they shouldn't be run --- pyproject.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 77d8ee3d4..255710386 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,3 +84,9 @@ case_sensitive = true combine_as_imports = true line_length = 120 atomic = true + +[tool.pytest.ini_options] +# We don't use nose style tests so disable them in pytest. +# This stops pytest from running functions named `setup` in test files. +# See https://github.com/python-discord/bot/pull/2229#issuecomment-1204436420 +addopts = "-p no:nose" -- cgit v1.2.3 From 31f6b907b77aceb12eb8ef5079d007828a66784e Mon Sep 17 00:00:00 2001 From: wookie184 Date: Thu, 4 Aug 2022 17:30:59 +0100 Subject: Refactor infractions to avoid passing around coroutines This avoids warnings if they are never awaited, e.g. when functions are mocked in tests --- bot/exts/moderation/infraction/_scheduler.py | 13 +++++++------ bot/exts/moderation/infraction/infractions.py | 15 ++++++++++----- bot/exts/moderation/infraction/superstarify.py | 11 ++++++----- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index c7f03b2e9..db2ae9454 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -1,6 +1,7 @@ import textwrap import typing as t from abc import abstractmethod +from collections.abc import Callable from gettext import ngettext import arrow @@ -79,7 +80,7 @@ class InfractionScheduler: async def reapply_infraction( self, infraction: _utils.Infraction, - apply_coro: t.Optional[t.Awaitable] + action: t.Optional[Callable[[], None]] ) -> None: """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" if infraction["expires_at"] is not None: @@ -101,7 +102,7 @@ class InfractionScheduler: # Allowing mod log since this is a passive action that should be logged. try: - await apply_coro + await action() except discord.HTTPException as e: # When user joined and then right after this left again before action completed, this can't apply roles if e.code == 10007 or e.status == 404: @@ -121,14 +122,14 @@ class InfractionScheduler: ctx: Context, infraction: _utils.Infraction, user: MemberOrUser, - action_coro: t.Optional[t.Awaitable] = None, + action: t.Optional[Callable[[], None]] = None, user_reason: t.Optional[str] = None, additional_info: str = "", ) -> bool: """ Apply an infraction to the user, log the infraction, and optionally notify the user. - `action_coro`, if not provided, will result in the infraction not getting scheduled for deletion. + `action`, if not provided, will result in the infraction not getting scheduled for deletion. `user_reason`, if provided, will be sent to the user in place of the infraction reason. `additional_info` will be attached to the text field in the mod-log embed. @@ -194,10 +195,10 @@ class InfractionScheduler: purge = infraction.get("purge", "") # Execute the necessary actions to apply the infraction on Discord. - if action_coro: + if action: log.trace(f"Awaiting the infraction #{id_} application action coroutine.") try: - await action_coro + await action() if expiry: # Schedule the expiration of the infraction. self.schedule_expiration(infraction) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 46fd3381c..8d784549f 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -52,8 +52,9 @@ class Infractions(InfractionScheduler, commands.Cog): if active_mutes: reason = f"Re-applying active mute: {active_mutes[0]['id']}" - action = member.add_roles(self._muted_role, reason=reason) + async def action() -> None: + await member.add_roles(self._muted_role, reason=reason) await self.reapply_infraction(active_mutes[0], action) # region: Permanent infractions @@ -388,7 +389,7 @@ class Infractions(InfractionScheduler, commands.Cog): log.trace(f"Attempting to kick {user} from voice because they've been muted.") await user.move_to(None, reason=reason) - await self.apply_infraction(ctx, infraction, user, action()) + await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy(member_arg=2) async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: @@ -406,7 +407,9 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - action = user.kick(reason=reason) + async def action() -> None: + await user.kick(reason=reason) + await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy(member_arg=2) @@ -455,7 +458,9 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") - action = ctx.guild.ban(user, reason=reason, delete_message_days=purge_days) + async def action() -> None: + await ctx.guild.ban(user, reason=reason, delete_message_days=purge_days) + await self.apply_infraction(ctx, infraction, user, action) bb_cog: t.Optional[BigBrother] = self.bot.get_cog("Big Brother") @@ -493,7 +498,7 @@ class Infractions(InfractionScheduler, commands.Cog): await user.move_to(None, reason="Disconnected from voice to apply voice mute.") await user.remove_roles(self._voice_verified_role, reason=reason) - await self.apply_infraction(ctx, infraction, user, action()) + await self.apply_infraction(ctx, infraction, user, action) # endregion # region: Base pardon functions diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 0e6aaa1e7..02fbe7957 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -96,11 +96,12 @@ class Superstarify(InfractionScheduler, Cog): if active_superstarifies: infraction = active_superstarifies[0] - action = member.edit( - nick=self.get_nick(infraction["id"], member.id), - reason=f"Superstarified member tried to escape the prison: {infraction['id']}" - ) + async def action() -> None: + await member.edit( + nick=self.get_nick(infraction["id"], member.id), + reason=f"Superstarified member tried to escape the prison: {infraction['id']}" + ) await self.reapply_infraction(infraction, action) @command(name="superstarify", aliases=("force_nick", "star", "starify", "superstar")) @@ -175,7 +176,7 @@ class Superstarify(InfractionScheduler, Cog): ).format successful = await self.apply_infraction( - ctx, infraction, member, action(), + ctx, infraction, member, action, user_reason=user_message(reason=f'**Additional details:** {reason}\n\n' if reason else ''), additional_info=nickname_info ) -- cgit v1.2.3 From fb419bc8f0525e8296abc5017a0d316adb22d4c4 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Thu, 4 Aug 2022 17:32:12 +0100 Subject: Fix tests --- .../exts/moderation/infraction/test_infractions.py | 30 +++++++++++++--------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 052048053..9636f0146 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -35,17 +35,20 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_infraction = AsyncMock() self.bot.get_cog.return_value = AsyncMock() self.cog.mod_log.ignore = Mock() - self.ctx.guild.ban = Mock() + self.ctx.guild.ban = AsyncMock() await self.cog.apply_ban(self.ctx, self.target, "foo bar" * 3000) - self.ctx.guild.ban.assert_called_once_with( + self.cog.apply_infraction.assert_awaited_once_with( + self.ctx, {"foo": "bar", "purge": ""}, self.target, ANY + ) + + action = self.cog.apply_infraction.call_args.args[-1] + await action() + self.ctx.guild.ban.assert_awaited_once_with( self.target, reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="..."), delete_message_days=0 ) - self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar", "purge": ""}, self.target, self.ctx.guild.ban.return_value - ) @patch("bot.exts.moderation.infraction._utils.post_infraction") async def test_apply_kick_reason_truncation(self, post_infraction_mock): @@ -54,14 +57,17 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_infraction = AsyncMock() self.cog.mod_log.ignore = Mock() - self.target.kick = Mock() + self.target.kick = AsyncMock() await self.cog.apply_kick(self.ctx, self.target, "foo bar" * 3000) - self.target.kick.assert_called_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) self.cog.apply_infraction.assert_awaited_once_with( - self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value + self.ctx, {"foo": "bar"}, self.target, ANY ) + action = self.cog.apply_infraction.call_args.args[-1] + await action() + self.target.kick.assert_awaited_once_with(reason=textwrap.shorten("foo bar" * 3000, 512, placeholder="...")) + @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): @@ -141,8 +147,8 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): async def action_tester(self, action, reason: str) -> None: """Helper method to test voice mute action.""" - self.assertTrue(inspect.iscoroutine(action)) - await action + self.assertTrue(inspect.iscoroutinefunction(action)) + await action() self.user.move_to.assert_called_once_with(None, reason=ANY) self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason=reason) @@ -194,8 +200,8 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): # Test action action = self.cog.apply_infraction.call_args[0][-1] - self.assertTrue(inspect.iscoroutine(action)) - await action + self.assertTrue(inspect.iscoroutinefunction(action)) + await action() async def test_voice_unmute_user_not_found(self): """Should include info to return dict when user was not found from guild.""" -- cgit v1.2.3 From 29d882065e12f9a042b14d6dfaf3161af215c953 Mon Sep 17 00:00:00 2001 From: Izan Date: Sat, 6 Aug 2022 15:49:02 +0100 Subject: Make reference message in reminders italic. --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index f33a9f448..f0da37f3b 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -228,7 +228,7 @@ class Reminders(Cog): # If the replied message has no content (e.g. only attachments/embeds) if content == "": - content = "See referenced message." + content = "*See referenced message.*" return content -- cgit v1.2.3 From 4a9bcdf44263d4ff1d144ec22d43d3e83c3ad85c Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 6 Aug 2022 13:10:59 -0400 Subject: Changed datetime.now to arrow.utcnow - Used arrow.utcnow to reduce complexity and import --- bot/exts/moderation/infraction/_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 470ce05e1..eda56b97c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,5 +1,5 @@ import typing as t -from datetime import datetime, timezone +from datetime import datetime import arrow import discord @@ -92,7 +92,7 @@ async def post_infraction( log.trace(f"Posting {infr_type} infraction for {user} to the API.") - current_time = datetime.now(tz=timezone.utc) + current_time = arrow.utcnow() payload = { "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. -- cgit v1.2.3 From 97b940a24ac90fd7ec8405b84e0b4f419c95d31a Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 6 Aug 2022 13:11:36 -0400 Subject: Removed `inserted_at` fallback Given API updates, the fallback is not needed --- bot/exts/moderation/infraction/_scheduler.py | 11 ++++------- bot/exts/moderation/infraction/_utils.py | 7 +------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index cfb585bf5..cb3f7149f 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -138,13 +138,10 @@ class InfractionScheduler: icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] id_ = infraction['id'] - - if "last_applied" in infraction: - origin = infraction["last_applied"] - else: # Fallback for previous API versions without `last_applied` - log.trace(f"No last_applied for infraction {id_}, using inserted_at time.") - origin = infraction["inserted_at"] - expiry = time.format_with_duration(infraction["expires_at"], origin) + expiry = time.format_with_duration( + infraction["expires_at"], + infraction["last_applied"] + ) if user_reason is None: user_reason = reason diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index eda56b97c..407c97251 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -188,12 +188,7 @@ async def notify_infraction( expires_at = "Never" duration = "Permanent" else: - if "last_applied" in infraction: - origin = infraction["last_applied"] - else: # Fallback for previous API versions without `last_applied` - log.trace(f"No last_applied for infraction {infraction}, using inserted_at time.") - origin = infraction["inserted_at"] - origin = arrow.get(origin) + origin = arrow.get(infraction["last_applied"]) expiry = arrow.get(infraction["expires_at"]) expires_at = time.format_relative(expiry) duration = time.humanize_delta(origin, expiry, max_units=2) -- cgit v1.2.3 From d042f29420abf3682cdae3b8a16e9b5e043ad9e5 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 6 Aug 2022 13:47:49 -0400 Subject: Refactored test to not use datetime patch - Used new method of dict subset comparison instead of datetime patching for better compat. with argument types --- tests/bot/exts/moderation/infraction/test_utils.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index b1f23e31c..5ba0f4273 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -1,7 +1,7 @@ import unittest from collections import namedtuple from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, patch from botcore.site_api import ResponseCodeError from discord import Embed, Forbidden, HTTPException, NotFound @@ -351,11 +351,10 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.assertIsNone(actual) post_user_mock.assert_awaited_once_with(self.ctx, self.user) - @patch("bot.exts.moderation.infraction._utils.datetime", wraps=datetime) @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar") - async def test_first_fail_second_success_user_post_infraction(self, post_user_mock, mock_datetime): + async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" - payload = { + expected = { "actor": self.ctx.author.id, "hidden": False, "reason": "Test reason", @@ -363,13 +362,17 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "user": self.user.id, "active": True, "dm_sent": False, - "last_applied": datetime(2020, 1, 1).isoformat(), } - # Patch the datetime.now function to return a specific time - mock_datetime.now.return_value = datetime(2020, 1, 1) self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) + await_args = self.bot.api_client.post.await_args_list + self.assertEqual(len(await_args), 2, "Expected 2 awaits") + + # Since `last_applied` is based on current time, just check if expected is a subset of payload + for args in await_args: + payload: dict = args.kwargs["json"] + self.assertEqual(payload, payload | expected) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From 1eff1d81a19883136501757738cd8444634be893 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 6 Aug 2022 18:51:51 +0100 Subject: Add note to docstring, fix type-hints, and update log messages --- bot/exts/moderation/infraction/_scheduler.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index db2ae9454..a44b66804 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -1,7 +1,7 @@ import textwrap import typing as t from abc import abstractmethod -from collections.abc import Callable +from collections.abc import Awaitable, Callable from gettext import ngettext import arrow @@ -80,9 +80,14 @@ class InfractionScheduler: async def reapply_infraction( self, infraction: _utils.Infraction, - action: t.Optional[Callable[[], None]] + action: t.Optional[Callable[[], Awaitable[None]]] ) -> None: - """Reapply an infraction if it's still active or deactivate it if less than 60 sec left.""" + """ + Reapply an infraction if it's still active or deactivate it if less than 60 sec left. + + Note: The `action` provided is an async function rather than a coroutine + to prevent getting a RuntimeWarning if it is not used (e.g. in mocked tests). + """ if infraction["expires_at"] is not None: # Calculate the time remaining, in seconds, for the mute. expiry = dateutil.parser.isoparse(infraction["expires_at"]) @@ -112,7 +117,7 @@ class InfractionScheduler: else: log.exception( f"Got unexpected HTTPException (HTTP {e.status}, Discord code {e.code})" - f"when awaiting {infraction['type']} coroutine for {infraction['user']}." + f"when running {infraction['type']} action for {infraction['user']}." ) else: log.info(f"Re-applied {infraction['type']} to user {infraction['user']} upon rejoining.") @@ -122,7 +127,7 @@ class InfractionScheduler: ctx: Context, infraction: _utils.Infraction, user: MemberOrUser, - action: t.Optional[Callable[[], None]] = None, + action: t.Optional[Callable[[], Awaitable[None]]] = None, user_reason: t.Optional[str] = None, additional_info: str = "", ) -> bool: @@ -133,6 +138,9 @@ class InfractionScheduler: `user_reason`, if provided, will be sent to the user in place of the infraction reason. `additional_info` will be attached to the text field in the mod-log embed. + Note: The `action` provided is an async function rather than just a coroutine + to prevent getting a RuntimeWarning if it is not used (e.g. in mocked tests). + Returns whether or not the infraction succeeded. """ infr_type = infraction["type"] @@ -196,7 +204,7 @@ class InfractionScheduler: # Execute the necessary actions to apply the infraction on Discord. if action: - log.trace(f"Awaiting the infraction #{id_} application action coroutine.") + log.trace(f"Running the infraction #{id_} application action.") try: await action() if expiry: -- cgit v1.2.3 From a309f4a2791831f975f98ddb4025323948fdf512 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 6 Aug 2022 13:57:51 -0400 Subject: Updated previous tests to use subset method --- tests/bot/exts/moderation/infraction/test_utils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 5ba0f4273..6c9af2555 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -310,7 +310,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): async def test_normal_post_infraction(self): """Should return response from POST request if there are no errors.""" now = datetime.now() - payload = { + expected = { "actor": self.ctx.author.id, "hidden": True, "reason": "Test reason", @@ -323,14 +323,12 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.ctx.bot.api_client.post.return_value = "foo" actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) - self.assertEqual(actual, "foo") self.ctx.bot.api_client.post.assert_awaited_once() - await_args = str(self.ctx.bot.api_client.post.await_args) - # Check existing keys present, allow for additional keys (e.g. `last_applied`) - for key, value in payload.items(): - self.assertTrue(key in await_args) - self.assertTrue(str(value) in await_args) + + # Since `last_applied` is based on current time, just check if expected is a subset of payload + payload: dict = self.ctx.bot.api_client.post.await_args_list[0].kwargs["json"] + self.assertEqual(payload, payload | expected) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" -- cgit v1.2.3 From e4d00ffd7f747097cb07baf54aa8b2ad72bbb010 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 13 Aug 2022 15:45:52 -0400 Subject: Corrected test use of utcnow Corrected test case to use `datetime.utcnow()` to be consistent with target --- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 6c9af2555..29dadf372 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -309,7 +309,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): async def test_normal_post_infraction(self): """Should return response from POST request if there are no errors.""" - now = datetime.now() + now = datetime.utcnow() expected = { "actor": self.ctx.author.id, "hidden": True, -- cgit v1.2.3 From 88a981e211c688a5b0dda4eb39181c505c8d14b5 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 13 Aug 2022 15:48:54 -0400 Subject: Updated infractions display for updates - Added new infraction delta calculations to updated infractions. - Updates of infraction durations now also update the `last_applied` field. - `inserted_at` is now sent by the bot client to denote the original unmodified infraction application time --- bot/exts/moderation/infraction/_utils.py | 9 +++-- bot/exts/moderation/infraction/management.py | 24 ++++++++++---- bot/utils/time.py | 49 +++++++++++++++++++++------- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 407c97251..5e708d7fe 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -12,6 +12,7 @@ from bot.converters import DurationOrExpiry, MemberOrUser from bot.errors import InvalidInfractedUserError from bot.log import get_logger from bot.utils import time +from bot.utils.time import unpack_duration log = get_logger(__name__) @@ -102,15 +103,13 @@ async def post_infraction( "user": user.id, "active": active, "dm_sent": dm_sent, + "inserted_at": current_time.isoformat(), "last_applied": current_time.isoformat(), } - # Parse duration or expiry if duration_or_expiry is not None: - if isinstance(duration_or_expiry, datetime): - payload['expires_at'] = duration_or_expiry.isoformat() - else: # is relativedelta - payload['expires_at'] = (current_time + duration_or_expiry).isoformat() + _, expiry = unpack_duration(duration_or_expiry, current_time) + payload["expires_at"] = expiry.isoformat() # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. for should_post_user in (True, False): diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index a7d7a844a..6ef382119 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -2,6 +2,7 @@ import re import textwrap import typing as t +import arrow import discord from discord.ext import commands from discord.ext.commands import Context @@ -9,7 +10,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser +from bot.converters import DurationOrExpiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser from bot.decorators import ensure_future_timestamp from bot.errors import InvalidInfraction from bot.exts.moderation.infraction import _utils @@ -20,6 +21,7 @@ from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel from bot.utils.members import get_or_fetch_member +from bot.utils.time import unpack_duration log = get_logger(__name__) @@ -89,7 +91,7 @@ class ModManagement(commands.Cog): self, ctx: Context, infraction: Infraction, - duration: t.Union[Expiry, t.Literal["p", "permanent"], None], + duration: t.Union[DurationOrExpiry, t.Literal["p", "permanent"], None], *, reason: str = None ) -> None: @@ -129,7 +131,7 @@ class ModManagement(commands.Cog): self, ctx: Context, infraction: Infraction, - duration: t.Union[Expiry, t.Literal["p", "permanent"], None], + duration: t.Union[DurationOrExpiry, t.Literal["p", "permanent"], None], *, reason: str = None ) -> None: @@ -172,8 +174,11 @@ class ModManagement(commands.Cog): request_data['expires_at'] = None confirm_messages.append("marked as permanent") elif duration is not None: - request_data['expires_at'] = duration.isoformat() - expiry = time.format_with_duration(duration) + origin, expiry = unpack_duration(duration) + # Update `last_applied` if expiry changes. + request_data['last_applied'] = origin.isoformat() + request_data['expires_at'] = expiry.isoformat() + expiry = time.format_with_duration(expiry, origin) confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") @@ -380,7 +385,10 @@ class ModManagement(commands.Cog): user = infraction["user"] expires_at = infraction["expires_at"] inserted_at = infraction["inserted_at"] + last_applied = infraction["last_applied"] created = time.discord_timestamp(inserted_at) + applied = time.discord_timestamp(last_applied) + duration_edited = arrow.get(last_applied) > arrow.get(inserted_at) dm_sent = infraction["dm_sent"] # Format the user string. @@ -400,7 +408,11 @@ class ModManagement(commands.Cog): if expires_at is None: duration = "*Permanent*" else: - duration = time.humanize_delta(inserted_at, expires_at) + duration = time.humanize_delta(last_applied, expires_at) + + # Notice if infraction expiry was edited. + if duration_edited: + duration += f" (edited {applied})" # Format `dm_sent` if dm_sent is None: diff --git a/bot/utils/time.py b/bot/utils/time.py index 104ea026d..820ac2929 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,12 +1,18 @@ +from __future__ import annotations + import datetime import re +from copy import copy from enum import Enum from time import struct_time -from typing import Literal, Optional, Union, overload +from typing import Literal, Optional, TYPE_CHECKING, Union, overload import arrow from dateutil.relativedelta import relativedelta +if TYPE_CHECKING: + from bot.converters import DurationOrExpiry + _DURATION_REGEX = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" r"((?P\d+?) ?(months|month|m) ?)?" @@ -194,12 +200,8 @@ def humanize_delta( elif len(args) <= 2: end = arrow.get(args[0]) start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow() + delta = round_delta(relativedelta(end.datetime, start.datetime)) - # Round microseconds - end = round_datetime(end.datetime) - start = round_datetime(start.datetime) - - delta = relativedelta(end, start) if absolute: delta = abs(delta) else: @@ -332,12 +334,35 @@ def until_expiration(expiry: Optional[Timestamp]) -> str: return format_relative(expiry) -def round_datetime(dt: datetime.datetime) -> datetime.datetime: +def unpack_duration( + duration_or_expiry: DurationOrExpiry, + origin: Optional[Union[datetime.datetime, arrow.Arrow]] = None +) -> tuple[datetime.datetime, datetime.datetime]: + """ + Unpacks a DurationOrExpiry into a tuple of (origin, expiry). + + The `origin` defaults to the current UTC time at function call. + """ + if origin is None: + origin = datetime.datetime.now(tz=datetime.timezone.utc) + + if isinstance(origin, arrow.Arrow): + origin = origin.datetime + + if isinstance(duration_or_expiry, relativedelta): + return origin, origin + duration_or_expiry + else: + return origin, duration_or_expiry + + +def round_delta(delta: relativedelta) -> relativedelta: """ - Round a datetime object to the nearest second. + Rounds `delta` to the nearest second. - Resulting datetime objects will have microsecond values of 0, useful for delta comparisons. + Returns a copy with microsecond values of 0. """ - if dt.microsecond >= 500000: - dt += datetime.timedelta(seconds=1) - return dt.replace(microsecond=0) + delta = copy(delta) + if delta.microseconds >= 500000: + delta += relativedelta(seconds=1) + delta.microseconds = 0 + return delta -- cgit v1.2.3 From fb028d19ccfa768ad7accc9fbddc8c3b1696e0a0 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 13 Aug 2022 17:00:41 -0400 Subject: Removed unused datetime import --- bot/exts/moderation/infraction/_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 5e708d7fe..5f2529c6b 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,5 +1,4 @@ import typing as t -from datetime import datetime import arrow import discord -- cgit v1.2.3 From 16e17f2155c0cf3241e2d5604e53b1e51e8ea386 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 03:42:31 -0400 Subject: Added `DurationOrExpiry` type union --- bot/converters.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/converters.py b/bot/converters.py index 5800ea044..e97a25bdd 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -574,5 +574,6 @@ if t.TYPE_CHECKING: Infraction = t.Optional[dict] # noqa: F811 Expiry = t.Union[Duration, ISODateTime] +DurationOrExpiry = t.Union[DurationDelta, ISODateTime] MemberOrUser = t.Union[discord.Member, discord.User] UnambiguousMemberOrUser = t.Union[UnambiguousMember, UnambiguousUser] -- cgit v1.2.3 From 3ca6f7adf8126971ccd05ab7bffa30ad89257b1f Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 04:31:05 -0400 Subject: Refactoring for DurationOrExpiry --- bot/exts/moderation/infraction/_utils.py | 20 ++++++++++----- bot/exts/moderation/infraction/infractions.py | 34 +++++++++++++------------- bot/exts/moderation/infraction/superstarify.py | 4 +-- 3 files changed, 33 insertions(+), 25 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 3a2485ec2..368a637c2 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,5 +1,5 @@ import typing as t -from datetime import datetime +from datetime import datetime, timezone import arrow import discord @@ -8,7 +8,7 @@ from discord.ext.commands import Context import bot from bot.constants import Colours, Icons -from bot.converters import MemberOrUser +from bot.converters import MemberOrUser, DurationOrExpiry from bot.errors import InvalidInfractedUserError from bot.log import get_logger from bot.utils import time @@ -80,7 +80,7 @@ async def post_infraction( user: MemberOrUser, infr_type: str, reason: str, - expires_at: datetime = None, + duration_or_expiry: t.Optional[DurationOrExpiry] = None, hidden: bool = False, active: bool = True, dm_sent: bool = False, @@ -92,6 +92,8 @@ async def post_infraction( log.trace(f"Posting {infr_type} infraction for {user} to the API.") + current_time = datetime.now(tz=timezone.utc) + payload = { "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. "hidden": hidden, @@ -99,10 +101,16 @@ async def post_infraction( "type": infr_type, "user": user.id, "active": active, - "dm_sent": dm_sent + "dm_sent": dm_sent, + "last_applied": current_time, } - if expires_at: - payload['expires_at'] = expires_at.isoformat() + + # Parse duration or expiry + if duration_or_expiry is not None: + if isinstance(duration_or_expiry, datetime): + payload['expires_at'] = duration_or_expiry.isoformat() + else: # is relativedelta + payload['expires_at'] = (current_time + duration_or_expiry).isoformat() # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. for should_post_user in (True, False): diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 46fd3381c..1818997bb 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser +from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser, DurationOrExpiry from bot.decorators import ensure_future_timestamp, respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -86,7 +86,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[Expiry] = None, + duration: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] = None ) -> None: @@ -95,7 +95,7 @@ class Infractions(InfractionScheduler, commands.Cog): If duration is specified, it temporarily bans that user for the given duration. """ - await self.apply_ban(ctx, user, reason, expires_at=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) @command(aliases=("cban", "purgeban", "pban")) @ensure_future_timestamp(timestamp_arg=3) @@ -103,7 +103,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[Expiry] = None, + duration: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] = None ) -> None: @@ -115,10 +115,10 @@ class Infractions(InfractionScheduler, commands.Cog): clean_cog: t.Optional[Clean] = self.bot.get_cog("Clean") if clean_cog is None: # If we can't get the clean cog, fall back to native purgeban. - await self.apply_ban(ctx, user, reason, purge_days=1, expires_at=duration) + await self.apply_ban(ctx, user, reason, purge_days=1, duration_or_expiry=duration) return - infraction = await self.apply_ban(ctx, user, reason, expires_at=duration) + infraction = await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) if not infraction or not infraction.get("id"): # Ban was unsuccessful, quit early. await ctx.send(":x: Failed to apply ban.") @@ -168,7 +168,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[Expiry] = None, + duration: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] ) -> None: @@ -177,7 +177,7 @@ class Infractions(InfractionScheduler, commands.Cog): If duration is specified, it temporarily voice mutes that user for the given duration. """ - await self.apply_voice_mute(ctx, user, reason, expires_at=duration) + await self.apply_voice_mute(ctx, user, reason, duration_or_expiry=duration) # endregion # region: Temporary infractions @@ -187,7 +187,7 @@ class Infractions(InfractionScheduler, commands.Cog): async def tempmute( self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[Expiry] = None, + duration: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] = None ) -> None: @@ -214,7 +214,7 @@ class Infractions(InfractionScheduler, commands.Cog): if duration is None: duration = await Duration().convert(ctx, "1h") - await self.apply_mute(ctx, user, reason, expires_at=duration) + await self.apply_mute(ctx, user, reason, duration_or_expiry=duration) @command(aliases=("tban",)) @ensure_future_timestamp(timestamp_arg=3) @@ -241,7 +241,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_ban(ctx, user, reason, expires_at=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) @command(aliases=("tempvban", "tvban")) async def tempvoiceban(self, ctx: Context) -> None: @@ -258,7 +258,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: Expiry, + duration: DurationOrExpiry, *, reason: t.Optional[str] ) -> None: @@ -277,7 +277,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_voice_mute(ctx, user, reason, expires_at=duration) + await self.apply_voice_mute(ctx, user, reason, duration_or_expiry=duration) # endregion # region: Permanent shadow infractions @@ -305,7 +305,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: Expiry, + duration: DurationOrExpiry, *, reason: t.Optional[str] = None ) -> None: @@ -324,7 +324,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_ban(ctx, user, reason, expires_at=duration, hidden=True) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration, hidden=True) # endregion # region: Remove infractions (un- commands) @@ -428,7 +428,7 @@ class Infractions(InfractionScheduler, commands.Cog): return None # In the case of a permanent ban, we don't need get_active_infractions to tell us if one is active - is_temporary = kwargs.get("expires_at") is not None + is_temporary = kwargs.get("duration_or_expiry") is not None active_infraction = await _utils.get_active_infraction(ctx, user, "ban", is_temporary) if active_infraction: @@ -436,7 +436,7 @@ class Infractions(InfractionScheduler, commands.Cog): log.trace("Tempban ignored as it cannot overwrite an active ban.") return None - if active_infraction.get('expires_at') is None: + if active_infraction.get("duration_or_expiry") is None: log.trace("Permaban already exists, notify.") await ctx.send(f":x: User is already permanently banned (#{active_infraction['id']}).") return None diff --git a/bot/exts/moderation/infraction/superstarify.py b/bot/exts/moderation/infraction/superstarify.py index 0e6aaa1e7..f2aab7a92 100644 --- a/bot/exts/moderation/infraction/superstarify.py +++ b/bot/exts/moderation/infraction/superstarify.py @@ -10,7 +10,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Duration, Expiry +from bot.converters import Duration, DurationOrExpiry from bot.decorators import ensure_future_timestamp from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -109,7 +109,7 @@ class Superstarify(InfractionScheduler, Cog): self, ctx: Context, member: Member, - duration: t.Optional[Expiry], + duration: t.Optional[DurationOrExpiry], *, reason: str = '', ) -> None: -- cgit v1.2.3 From 22ec794d1b2f65da5d737f0a06fb2680834308f6 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 04:38:18 -0400 Subject: Ran isort on imports --- bot/exts/moderation/infraction/_utils.py | 2 +- bot/exts/moderation/infraction/infractions.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 368a637c2..80b65dae8 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -8,7 +8,7 @@ from discord.ext.commands import Context import bot from bot.constants import Colours, Icons -from bot.converters import MemberOrUser, DurationOrExpiry +from bot.converters import DurationOrExpiry, MemberOrUser from bot.errors import InvalidInfractedUserError from bot.log import get_logger from bot.utils import time diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 1818997bb..aff5e392f 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Age, Duration, Expiry, MemberOrUser, UnambiguousMemberOrUser, DurationOrExpiry +from bot.converters import Age, Duration, DurationOrExpiry, Expiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import ensure_future_timestamp, respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler -- cgit v1.2.3 From 0e6242f7f4c41329e9270724a9780511f7165240 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 04:54:26 -0400 Subject: Updated tests - Refactored tests for new time duration arguments --- .../exts/moderation/infraction/test_infractions.py | 11 ++++---- tests/bot/exts/moderation/infraction/test_utils.py | 29 +++++++++++++--------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 052048053..a18a4d23b 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -79,13 +79,13 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): """Should call voice mute applying function without expiry.""" self.cog.apply_voice_mute = AsyncMock() self.assertIsNone(await self.cog.voicemute(self.cog, self.ctx, self.user, reason="foobar")) - self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at=None) + self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", duration_or_expiry=None) async def test_temporary_voice_mute(self): """Should call voice mute applying function with expiry.""" self.cog.apply_voice_mute = AsyncMock() self.assertIsNone(await self.cog.tempvoicemute(self.cog, self.ctx, self.user, "baz", reason="foobar")) - self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + self.cog.apply_voice_mute.assert_awaited_once_with(self.ctx, self.user, "foobar", duration_or_expiry="baz") async def test_voice_unmute(self): """Should call infraction pardoning function.""" @@ -189,7 +189,8 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): user = MockUser() await self.cog.voicemute(self.cog, self.ctx, user, reason=None) - post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, expires_at=None) + post_infraction_mock.assert_called_once_with(self.ctx, user, "voice_mute", None, active=True, + duration_or_expiry=None) apply_infraction_mock.assert_called_once_with(self.cog, self.ctx, infraction, user, ANY) # Test action @@ -273,7 +274,7 @@ class CleanBanTests(unittest.IsolatedAsyncioTestCase): self.user, "FooBar", purge_days=1, - expires_at=None, + duration_or_expiry=None, ) async def test_cleanban_doesnt_purge_messages_if_clean_cog_available(self): @@ -285,7 +286,7 @@ class CleanBanTests(unittest.IsolatedAsyncioTestCase): self.ctx, self.user, "FooBar", - expires_at=None, + duration_or_expiry=None, ) @patch("bot.exts.moderation.infraction.infractions.Age") diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 5cf02033d..4c78c0bd8 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -318,14 +318,17 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "user": self.member.id, "active": False, "expires_at": now.isoformat(), - "dm_sent": False + "dm_sent": False, + "last_applied": datetime(2020, 1, 1).isoformat(), } - self.ctx.bot.api_client.post.return_value = "foo" - actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) + # Patch the time.now(tz=timezone.utc) function to return a specific time + with patch("bot.exts.moderation.infraction._utils.datetime.now", return_value=datetime(2020, 1, 1)): + self.ctx.bot.api_client.post.return_value = "foo" + actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) - self.assertEqual(actual, "foo") - self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.assertEqual(actual, "foo") + self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" @@ -356,12 +359,14 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "type": "mute", "user": self.user.id, "active": True, - "dm_sent": False + "dm_sent": False, + "last_applied": datetime(2020, 1, 1), } - self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] - - actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") - self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) - post_user_mock.assert_awaited_once_with(self.ctx, self.user) + # Patch the time.now(tz=timezone.utc) function to return a specific time + with patch("bot.exts.moderation.infraction._utils.datetime.now", return_value=datetime(2020, 1, 1)): + self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] + actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") + self.assertEqual(actual, "foo") + self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From 035b5accf78f8623b40e0612e3c057f0ef2b93a7 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 05:04:38 -0400 Subject: Fixed test patches --- tests/bot/exts/moderation/infraction/test_utils.py | 32 ++++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 4c78c0bd8..def06932b 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -307,7 +307,8 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) - async def test_normal_post_infraction(self): + @patch("bot.exts.moderation.infraction._utils.datetime", wraps=datetime) + async def test_normal_post_infraction(self, mock_datetime): """Should return response from POST request if there are no errors.""" now = datetime.now() payload = { @@ -322,13 +323,13 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "last_applied": datetime(2020, 1, 1).isoformat(), } - # Patch the time.now(tz=timezone.utc) function to return a specific time - with patch("bot.exts.moderation.infraction._utils.datetime.now", return_value=datetime(2020, 1, 1)): - self.ctx.bot.api_client.post.return_value = "foo" - actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) + # Patch the datetime.now function to return a specific time + mock_datetime.now.return_value = datetime(2020, 1, 1) + self.ctx.bot.api_client.post.return_value = "foo" + actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) - self.assertEqual(actual, "foo") - self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.assertEqual(actual, "foo") + self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" @@ -349,8 +350,9 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.assertIsNone(actual) post_user_mock.assert_awaited_once_with(self.ctx, self.user) + @patch("bot.exts.moderation.infraction._utils.datetime", wraps=datetime) @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar") - async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): + async def test_first_fail_second_success_user_post_infraction(self, post_user_mock, mock_datetime): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" payload = { "actor": self.ctx.author.id, @@ -363,10 +365,10 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "last_applied": datetime(2020, 1, 1), } - # Patch the time.now(tz=timezone.utc) function to return a specific time - with patch("bot.exts.moderation.infraction._utils.datetime.now", return_value=datetime(2020, 1, 1)): - self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] - actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") - self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) - post_user_mock.assert_awaited_once_with(self.ctx, self.user) + # Patch the datetime.now function to return a specific time + mock_datetime.now.return_value = datetime(2020, 1, 1) + self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] + actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") + self.assertEqual(actual, "foo") + self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From 8db7ef5df087e43804d07dabe2037af18adcb0d6 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 05:19:30 -0400 Subject: Added isoformat for test payload --- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index def06932b..d3a908b28 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -362,7 +362,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "user": self.user.id, "active": True, "dm_sent": False, - "last_applied": datetime(2020, 1, 1), + "last_applied": datetime(2020, 1, 1).isoformat(), } # Patch the datetime.now function to return a specific time -- cgit v1.2.3 From 1ad627adbe9f35d9d8c5bedc52295d4e79c1eb6f Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 05:20:48 -0400 Subject: Updated parameter names - Changed `duration` parameter names to `duration_or_expiry` to more accurately reflect options for help --- bot/exts/moderation/infraction/infractions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index aff5e392f..12bd084df 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -9,7 +9,7 @@ from discord.ext.commands import Context, command from bot import constants from bot.bot import Bot from bot.constants import Event -from bot.converters import Age, Duration, DurationOrExpiry, Expiry, MemberOrUser, UnambiguousMemberOrUser +from bot.converters import Age, Duration, DurationOrExpiry, MemberOrUser, UnambiguousMemberOrUser from bot.decorators import ensure_future_timestamp, respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler @@ -86,7 +86,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: t.Optional[DurationOrExpiry] = None, + duration_or_expiry: t.Optional[DurationOrExpiry] = None, *, reason: t.Optional[str] = None ) -> None: @@ -95,7 +95,7 @@ class Infractions(InfractionScheduler, commands.Cog): If duration is specified, it temporarily bans that user for the given duration. """ - await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) @command(aliases=("cban", "purgeban", "pban")) @ensure_future_timestamp(timestamp_arg=3) @@ -222,7 +222,7 @@ class Infractions(InfractionScheduler, commands.Cog): self, ctx: Context, user: UnambiguousMemberOrUser, - duration: Expiry, + duration_or_expiry: DurationOrExpiry, *, reason: t.Optional[str] = None ) -> None: @@ -241,7 +241,7 @@ class Infractions(InfractionScheduler, commands.Cog): Alternatively, an ISO 8601 timestamp can be provided for the duration. """ - await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) + await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) @command(aliases=("tempvban", "tvban")) async def tempvoiceban(self, ctx: Context) -> None: -- cgit v1.2.3 From 3e9ffa1ea3c7cfa7c583189fcb8675db9ee5c6d5 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 05:36:45 -0400 Subject: Updated ban command docstring - Updated docstring to be more explicit on parameter fields --- bot/exts/moderation/infraction/infractions.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 12bd084df..83a947a19 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -91,9 +91,11 @@ class Infractions(InfractionScheduler, commands.Cog): reason: t.Optional[str] = None ) -> None: """ - Permanently ban a user for the given reason and stop watching them with Big Brother. + Permanently ban a `user` for the given `reason` and stop watching them with Big Brother. - If duration is specified, it temporarily bans that user for the given duration. + If a duration is specified, it temporarily bans the `user` for the given duration. + Alternatively, an ISO 8601 timestamp representing the expiry time can be provided + for `duration_or_expiry`. """ await self.apply_ban(ctx, user, reason, duration_or_expiry=duration_or_expiry) -- cgit v1.2.3 From 3280ac48a9031b727bdde69909729093593bd967 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 06:11:11 -0400 Subject: Fixed tests - Corrected datetime patching --- tests/bot/exts/moderation/infraction/test_utils.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index d3a908b28..b1f23e31c 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -307,8 +307,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.user = MockUser(id=1234) self.ctx = MockContext(bot=self.bot, author=self.member) - @patch("bot.exts.moderation.infraction._utils.datetime", wraps=datetime) - async def test_normal_post_infraction(self, mock_datetime): + async def test_normal_post_infraction(self): """Should return response from POST request if there are no errors.""" now = datetime.now() payload = { @@ -320,16 +319,18 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "active": False, "expires_at": now.isoformat(), "dm_sent": False, - "last_applied": datetime(2020, 1, 1).isoformat(), } - # Patch the datetime.now function to return a specific time - mock_datetime.now.return_value = datetime(2020, 1, 1) self.ctx.bot.api_client.post.return_value = "foo" actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) self.assertEqual(actual, "foo") - self.ctx.bot.api_client.post.assert_awaited_once_with("bot/infractions", json=payload) + self.ctx.bot.api_client.post.assert_awaited_once() + await_args = str(self.ctx.bot.api_client.post.await_args) + # Check existing keys present, allow for additional keys (e.g. `last_applied`) + for key, value in payload.items(): + self.assertTrue(key in await_args) + self.assertTrue(str(value) in await_args) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" -- cgit v1.2.3 From 08e344c8884b612ea0775d3141b33d0f4575d085 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 06:11:23 -0400 Subject: Correct last_applied formatting --- bot/exts/moderation/infraction/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 80b65dae8..cd82f5b2e 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -102,7 +102,7 @@ async def post_infraction( "user": user.id, "active": active, "dm_sent": dm_sent, - "last_applied": current_time, + "last_applied": current_time.isoformat(), } # Parse duration or expiry -- cgit v1.2.3 From f8e3656a74cfc0cae37cab65ed114319762f01e3 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 28 Jul 2022 18:57:01 -0400 Subject: Use `last_applied` to display duration --- bot/exts/moderation/infraction/_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index cd82f5b2e..23ae517e9 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -188,9 +188,10 @@ async def notify_infraction( expires_at = "Never" duration = "Permanent" else: + origin = arrow.get(infraction["last_applied"]) expiry = arrow.get(infraction["expires_at"]) expires_at = time.format_relative(expiry) - duration = time.humanize_delta(infraction["inserted_at"], expiry, max_units=2) + duration = time.humanize_delta(origin, expiry, max_units=2) if infraction["active"]: remaining = time.humanize_delta(expiry, arrow.utcnow(), max_units=2) -- cgit v1.2.3 From 8d788a9fdc53a7af0730dfc925ea649fd5b810c4 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sun, 31 Jul 2022 02:09:35 -0400 Subject: Added new expiry usage to apply - Added new usage of `last_applied` time for duration calculation in `apply_infraction` --- bot/exts/moderation/infraction/_scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index c7f03b2e9..28aafec2a 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -137,7 +137,7 @@ class InfractionScheduler: infr_type = infraction["type"] icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = time.format_with_duration(infraction["expires_at"]) + expiry = time.format_with_duration(infraction["expires_at"], infraction["last_applied"]) id_ = infraction['id'] if user_reason is None: -- cgit v1.2.3 From 3babbf2fa0a6a8d99e796af0cd78a7516b781898 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Mon, 1 Aug 2022 00:55:00 -0400 Subject: Added microsecond rounding for `humanize_delta` --- bot/utils/time.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/bot/utils/time.py b/bot/utils/time.py index a0379c3ef..104ea026d 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -195,7 +195,11 @@ def humanize_delta( end = arrow.get(args[0]) start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow() - delta = relativedelta(end.datetime, start.datetime) + # Round microseconds + end = round_datetime(end.datetime) + start = round_datetime(start.datetime) + + delta = relativedelta(end, start) if absolute: delta = abs(delta) else: @@ -326,3 +330,14 @@ def until_expiration(expiry: Optional[Timestamp]) -> str: return "Expired" return format_relative(expiry) + + +def round_datetime(dt: datetime.datetime) -> datetime.datetime: + """ + Round a datetime object to the nearest second. + + Resulting datetime objects will have microsecond values of 0, useful for delta comparisons. + """ + if dt.microsecond >= 500000: + dt += datetime.timedelta(seconds=1) + return dt.replace(microsecond=0) -- cgit v1.2.3 From 011cfddbbb29a4555c93995ba61ecf6919a4a68e Mon Sep 17 00:00:00 2001 From: ionite34 Date: Mon, 1 Aug 2022 01:00:00 -0400 Subject: Infraction duration fallback if no `last_applied` field --- bot/exts/moderation/infraction/_scheduler.py | 8 +++++++- bot/exts/moderation/infraction/_utils.py | 7 ++++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 28aafec2a..cfb585bf5 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -137,9 +137,15 @@ class InfractionScheduler: infr_type = infraction["type"] icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] - expiry = time.format_with_duration(infraction["expires_at"], infraction["last_applied"]) id_ = infraction['id'] + if "last_applied" in infraction: + origin = infraction["last_applied"] + else: # Fallback for previous API versions without `last_applied` + log.trace(f"No last_applied for infraction {id_}, using inserted_at time.") + origin = infraction["inserted_at"] + expiry = time.format_with_duration(infraction["expires_at"], origin) + if user_reason is None: user_reason = reason diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 23ae517e9..470ce05e1 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -188,7 +188,12 @@ async def notify_infraction( expires_at = "Never" duration = "Permanent" else: - origin = arrow.get(infraction["last_applied"]) + if "last_applied" in infraction: + origin = infraction["last_applied"] + else: # Fallback for previous API versions without `last_applied` + log.trace(f"No last_applied for infraction {infraction}, using inserted_at time.") + origin = infraction["inserted_at"] + origin = arrow.get(origin) expiry = arrow.get(infraction["expires_at"]) expires_at = time.format_relative(expiry) duration = time.humanize_delta(origin, expiry, max_units=2) -- cgit v1.2.3 From 9fc8b5e72b0531dc0a05035b9e1f23dacaa404e9 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 6 Aug 2022 13:10:59 -0400 Subject: Changed datetime.now to arrow.utcnow - Used arrow.utcnow to reduce complexity and import --- bot/exts/moderation/infraction/_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 470ce05e1..eda56b97c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,5 +1,5 @@ import typing as t -from datetime import datetime, timezone +from datetime import datetime import arrow import discord @@ -92,7 +92,7 @@ async def post_infraction( log.trace(f"Posting {infr_type} infraction for {user} to the API.") - current_time = datetime.now(tz=timezone.utc) + current_time = arrow.utcnow() payload = { "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. -- cgit v1.2.3 From 1212ac808380280c7b7625eb2a43c75c92e17d5f Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 6 Aug 2022 13:11:36 -0400 Subject: Removed `inserted_at` fallback Given API updates, the fallback is not needed --- bot/exts/moderation/infraction/_scheduler.py | 11 ++++------- bot/exts/moderation/infraction/_utils.py | 7 +------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index cfb585bf5..cb3f7149f 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -138,13 +138,10 @@ class InfractionScheduler: icon = _utils.INFRACTION_ICONS[infr_type][0] reason = infraction["reason"] id_ = infraction['id'] - - if "last_applied" in infraction: - origin = infraction["last_applied"] - else: # Fallback for previous API versions without `last_applied` - log.trace(f"No last_applied for infraction {id_}, using inserted_at time.") - origin = infraction["inserted_at"] - expiry = time.format_with_duration(infraction["expires_at"], origin) + expiry = time.format_with_duration( + infraction["expires_at"], + infraction["last_applied"] + ) if user_reason is None: user_reason = reason diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index eda56b97c..407c97251 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -188,12 +188,7 @@ async def notify_infraction( expires_at = "Never" duration = "Permanent" else: - if "last_applied" in infraction: - origin = infraction["last_applied"] - else: # Fallback for previous API versions without `last_applied` - log.trace(f"No last_applied for infraction {infraction}, using inserted_at time.") - origin = infraction["inserted_at"] - origin = arrow.get(origin) + origin = arrow.get(infraction["last_applied"]) expiry = arrow.get(infraction["expires_at"]) expires_at = time.format_relative(expiry) duration = time.humanize_delta(origin, expiry, max_units=2) -- cgit v1.2.3 From e74ff62122935da349d38a8c06eef14d1e3ba9aa Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 6 Aug 2022 13:47:49 -0400 Subject: Refactored test to not use datetime patch - Used new method of dict subset comparison instead of datetime patching for better compat. with argument types --- tests/bot/exts/moderation/infraction/test_utils.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index b1f23e31c..5ba0f4273 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -1,7 +1,7 @@ import unittest from collections import namedtuple from datetime import datetime -from unittest.mock import AsyncMock, MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, patch from botcore.site_api import ResponseCodeError from discord import Embed, Forbidden, HTTPException, NotFound @@ -351,11 +351,10 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.assertIsNone(actual) post_user_mock.assert_awaited_once_with(self.ctx, self.user) - @patch("bot.exts.moderation.infraction._utils.datetime", wraps=datetime) @patch("bot.exts.moderation.infraction._utils.post_user", return_value="bar") - async def test_first_fail_second_success_user_post_infraction(self, post_user_mock, mock_datetime): + async def test_first_fail_second_success_user_post_infraction(self, post_user_mock): """Should post the user if they don't exist, POST infraction again, and return the response if successful.""" - payload = { + expected = { "actor": self.ctx.author.id, "hidden": False, "reason": "Test reason", @@ -363,13 +362,17 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): "user": self.user.id, "active": True, "dm_sent": False, - "last_applied": datetime(2020, 1, 1).isoformat(), } - # Patch the datetime.now function to return a specific time - mock_datetime.now.return_value = datetime(2020, 1, 1) self.bot.api_client.post.side_effect = [ResponseCodeError(MagicMock(status=400), {"user": "foo"}), "foo"] actual = await utils.post_infraction(self.ctx, self.user, "mute", "Test reason") self.assertEqual(actual, "foo") - self.bot.api_client.post.assert_has_awaits([call("bot/infractions", json=payload)] * 2) + await_args = self.bot.api_client.post.await_args_list + self.assertEqual(len(await_args), 2, "Expected 2 awaits") + + # Since `last_applied` is based on current time, just check if expected is a subset of payload + for args in await_args: + payload: dict = args.kwargs["json"] + self.assertEqual(payload, payload | expected) + post_user_mock.assert_awaited_once_with(self.ctx, self.user) -- cgit v1.2.3 From d93afa3dbe16404a60e23acaf394affcda5aff89 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 6 Aug 2022 13:57:51 -0400 Subject: Updated previous tests to use subset method --- tests/bot/exts/moderation/infraction/test_utils.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 5ba0f4273..6c9af2555 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -310,7 +310,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): async def test_normal_post_infraction(self): """Should return response from POST request if there are no errors.""" now = datetime.now() - payload = { + expected = { "actor": self.ctx.author.id, "hidden": True, "reason": "Test reason", @@ -323,14 +323,12 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): self.ctx.bot.api_client.post.return_value = "foo" actual = await utils.post_infraction(self.ctx, self.member, "ban", "Test reason", now, True, False) - self.assertEqual(actual, "foo") self.ctx.bot.api_client.post.assert_awaited_once() - await_args = str(self.ctx.bot.api_client.post.await_args) - # Check existing keys present, allow for additional keys (e.g. `last_applied`) - for key, value in payload.items(): - self.assertTrue(key in await_args) - self.assertTrue(str(value) in await_args) + + # Since `last_applied` is based on current time, just check if expected is a subset of payload + payload: dict = self.ctx.bot.api_client.post.await_args_list[0].kwargs["json"] + self.assertEqual(payload, payload | expected) async def test_unknown_error_post_infraction(self): """Should send an error message to chat when a non-400 error occurs.""" -- cgit v1.2.3 From 08a6bbc306d1ec54998b690eeeb258548e8e08fa Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 13 Aug 2022 15:45:52 -0400 Subject: Corrected test use of utcnow Corrected test case to use `datetime.utcnow()` to be consistent with target --- tests/bot/exts/moderation/infraction/test_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_utils.py b/tests/bot/exts/moderation/infraction/test_utils.py index 6c9af2555..29dadf372 100644 --- a/tests/bot/exts/moderation/infraction/test_utils.py +++ b/tests/bot/exts/moderation/infraction/test_utils.py @@ -309,7 +309,7 @@ class TestPostInfraction(unittest.IsolatedAsyncioTestCase): async def test_normal_post_infraction(self): """Should return response from POST request if there are no errors.""" - now = datetime.now() + now = datetime.utcnow() expected = { "actor": self.ctx.author.id, "hidden": True, -- cgit v1.2.3 From 1946ad455408c26916d3c5f6a68d886c25ddb93b Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 13 Aug 2022 15:48:54 -0400 Subject: Updated infractions display for updates - Added new infraction delta calculations to updated infractions. - Updates of infraction durations now also update the `last_applied` field. - `inserted_at` is now sent by the bot client to denote the original unmodified infraction application time --- bot/exts/moderation/infraction/_utils.py | 9 +++-- bot/exts/moderation/infraction/management.py | 24 ++++++++++---- bot/utils/time.py | 49 +++++++++++++++++++++------- 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 407c97251..5e708d7fe 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -12,6 +12,7 @@ from bot.converters import DurationOrExpiry, MemberOrUser from bot.errors import InvalidInfractedUserError from bot.log import get_logger from bot.utils import time +from bot.utils.time import unpack_duration log = get_logger(__name__) @@ -102,15 +103,13 @@ async def post_infraction( "user": user.id, "active": active, "dm_sent": dm_sent, + "inserted_at": current_time.isoformat(), "last_applied": current_time.isoformat(), } - # Parse duration or expiry if duration_or_expiry is not None: - if isinstance(duration_or_expiry, datetime): - payload['expires_at'] = duration_or_expiry.isoformat() - else: # is relativedelta - payload['expires_at'] = (current_time + duration_or_expiry).isoformat() + _, expiry = unpack_duration(duration_or_expiry, current_time) + payload["expires_at"] = expiry.isoformat() # Try to apply the infraction. If it fails because the user doesn't exist, try to add it. for should_post_user in (True, False): diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index a7d7a844a..6ef382119 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -2,6 +2,7 @@ import re import textwrap import typing as t +import arrow import discord from discord.ext import commands from discord.ext.commands import Context @@ -9,7 +10,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.converters import Expiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser +from bot.converters import DurationOrExpiry, Infraction, MemberOrUser, Snowflake, UnambiguousUser from bot.decorators import ensure_future_timestamp from bot.errors import InvalidInfraction from bot.exts.moderation.infraction import _utils @@ -20,6 +21,7 @@ from bot.pagination import LinePaginator from bot.utils import messages, time from bot.utils.channel import is_mod_channel from bot.utils.members import get_or_fetch_member +from bot.utils.time import unpack_duration log = get_logger(__name__) @@ -89,7 +91,7 @@ class ModManagement(commands.Cog): self, ctx: Context, infraction: Infraction, - duration: t.Union[Expiry, t.Literal["p", "permanent"], None], + duration: t.Union[DurationOrExpiry, t.Literal["p", "permanent"], None], *, reason: str = None ) -> None: @@ -129,7 +131,7 @@ class ModManagement(commands.Cog): self, ctx: Context, infraction: Infraction, - duration: t.Union[Expiry, t.Literal["p", "permanent"], None], + duration: t.Union[DurationOrExpiry, t.Literal["p", "permanent"], None], *, reason: str = None ) -> None: @@ -172,8 +174,11 @@ class ModManagement(commands.Cog): request_data['expires_at'] = None confirm_messages.append("marked as permanent") elif duration is not None: - request_data['expires_at'] = duration.isoformat() - expiry = time.format_with_duration(duration) + origin, expiry = unpack_duration(duration) + # Update `last_applied` if expiry changes. + request_data['last_applied'] = origin.isoformat() + request_data['expires_at'] = expiry.isoformat() + expiry = time.format_with_duration(expiry, origin) confirm_messages.append(f"set to expire on {expiry}") else: confirm_messages.append("expiry unchanged") @@ -380,7 +385,10 @@ class ModManagement(commands.Cog): user = infraction["user"] expires_at = infraction["expires_at"] inserted_at = infraction["inserted_at"] + last_applied = infraction["last_applied"] created = time.discord_timestamp(inserted_at) + applied = time.discord_timestamp(last_applied) + duration_edited = arrow.get(last_applied) > arrow.get(inserted_at) dm_sent = infraction["dm_sent"] # Format the user string. @@ -400,7 +408,11 @@ class ModManagement(commands.Cog): if expires_at is None: duration = "*Permanent*" else: - duration = time.humanize_delta(inserted_at, expires_at) + duration = time.humanize_delta(last_applied, expires_at) + + # Notice if infraction expiry was edited. + if duration_edited: + duration += f" (edited {applied})" # Format `dm_sent` if dm_sent is None: diff --git a/bot/utils/time.py b/bot/utils/time.py index 104ea026d..820ac2929 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -1,12 +1,18 @@ +from __future__ import annotations + import datetime import re +from copy import copy from enum import Enum from time import struct_time -from typing import Literal, Optional, Union, overload +from typing import Literal, Optional, TYPE_CHECKING, Union, overload import arrow from dateutil.relativedelta import relativedelta +if TYPE_CHECKING: + from bot.converters import DurationOrExpiry + _DURATION_REGEX = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" r"((?P\d+?) ?(months|month|m) ?)?" @@ -194,12 +200,8 @@ def humanize_delta( elif len(args) <= 2: end = arrow.get(args[0]) start = arrow.get(args[1]) if len(args) == 2 else arrow.utcnow() + delta = round_delta(relativedelta(end.datetime, start.datetime)) - # Round microseconds - end = round_datetime(end.datetime) - start = round_datetime(start.datetime) - - delta = relativedelta(end, start) if absolute: delta = abs(delta) else: @@ -332,12 +334,35 @@ def until_expiration(expiry: Optional[Timestamp]) -> str: return format_relative(expiry) -def round_datetime(dt: datetime.datetime) -> datetime.datetime: +def unpack_duration( + duration_or_expiry: DurationOrExpiry, + origin: Optional[Union[datetime.datetime, arrow.Arrow]] = None +) -> tuple[datetime.datetime, datetime.datetime]: + """ + Unpacks a DurationOrExpiry into a tuple of (origin, expiry). + + The `origin` defaults to the current UTC time at function call. + """ + if origin is None: + origin = datetime.datetime.now(tz=datetime.timezone.utc) + + if isinstance(origin, arrow.Arrow): + origin = origin.datetime + + if isinstance(duration_or_expiry, relativedelta): + return origin, origin + duration_or_expiry + else: + return origin, duration_or_expiry + + +def round_delta(delta: relativedelta) -> relativedelta: """ - Round a datetime object to the nearest second. + Rounds `delta` to the nearest second. - Resulting datetime objects will have microsecond values of 0, useful for delta comparisons. + Returns a copy with microsecond values of 0. """ - if dt.microsecond >= 500000: - dt += datetime.timedelta(seconds=1) - return dt.replace(microsecond=0) + delta = copy(delta) + if delta.microseconds >= 500000: + delta += relativedelta(seconds=1) + delta.microseconds = 0 + return delta -- cgit v1.2.3 From 6a87a38d4e44b1afe617cdfa0cc5027aad8a3933 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Sat, 13 Aug 2022 17:00:41 -0400 Subject: Removed unused datetime import --- bot/exts/moderation/infraction/_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 5e708d7fe..5f2529c6b 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -1,5 +1,4 @@ import typing as t -from datetime import datetime import arrow import discord -- cgit v1.2.3 From 85d8ae441c0f4e34fac8fd4323ca7da9dd507582 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 17 Aug 2022 17:07:05 -0400 Subject: feat: add reason argument to pardon commands --- bot/exts/moderation/infraction/_scheduler.py | 22 ++++++++++++++++++---- bot/exts/moderation/infraction/infractions.py | 12 ++++++------ 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index c7f03b2e9..d733c3936 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -271,6 +271,7 @@ class InfractionScheduler: ctx: Context, infr_type: str, user: MemberOrUser, + pardon_reason: t.Optional[str] = None, *, send_msg: bool = True, notify: bool = True @@ -278,6 +279,9 @@ class InfractionScheduler: """ Prematurely end an infraction for a user and log the action in the mod log. + If `pardon_reason` is None, then the infraction object will not receive + appended text explaining why the infraction was pardoned. + If `send_msg` is True, then a pardoning confirmation message will be sent to the context channel. Otherwise, no such message will be sent. @@ -302,7 +306,7 @@ class InfractionScheduler: return # Deactivate the infraction and cancel its scheduled expiration task. - log_text = await self.deactivate_infraction(response[0], send_log=False, notify=notify) + log_text = await self.deactivate_infraction(response[0], pardon_reason, send_log=False, notify=notify) log_text["Member"] = messages.format_user(user) log_text["Actor"] = ctx.author.mention @@ -355,6 +359,7 @@ class InfractionScheduler: async def deactivate_infraction( self, infraction: _utils.Infraction, + pardon_reason: t.Optional[str] = None, *, send_log: bool = True, notify: bool = True @@ -362,6 +367,9 @@ class InfractionScheduler: """ Deactivate an active infraction and return a dictionary of lines to send in a mod log. + If `pardon_reason` is None, then the infraction object will not receive + appended text explaining why the infraction was pardoned. + The infraction is removed from Discord, marked as inactive in the database, and has its expiration task cancelled. If `send_log` is True, a mod log is sent for the deactivation of the infraction. @@ -386,7 +394,7 @@ class InfractionScheduler: "Reason": infraction["reason"], "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]), } - + try: log.trace("Awaiting the pardon action coroutine.") returned_log = await self._pardon_action(infraction, notify) @@ -430,13 +438,19 @@ class InfractionScheduler: except ResponseCodeError: log.exception(f"Failed to fetch watch status for user {user_id}") log_text["Watching"] = "Unknown - failed to fetch watch status." - + try: # Mark infraction as inactive in the database. log.trace(f"Marking infraction #{id_} as inactive in the database.") + + data = {"active": False} + + if pardon_reason is not None: + data["reason"] = infraction["reason"] + f" | Pardoned: {pardon_reason}" + await self.bot.api_client.patch( f"bot/infractions/{id_}", - json={"active": False} + json=data ) except ResponseCodeError as e: log.exception(f"Failed to deactivate infraction #{id_} ({type_})") diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 46fd3381c..a94da0d7c 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -330,14 +330,14 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: + async def unmute(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: t.Optional[str] = None) -> None: """Prematurely end the active mute infraction for the user.""" - await self.pardon_infraction(ctx, "mute", user) + await self.pardon_infraction(ctx, "mute", user, pardon_reason) @command() - async def unban(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: + async def unban(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: t.Optional[str] = None) -> None: """Prematurely end the active ban infraction for the user.""" - await self.pardon_infraction(ctx, "ban", user) + await self.pardon_infraction(ctx, "ban", user, pardon_reason) @command(aliases=("uvban",)) async def unvoiceban(self, ctx: Context) -> None: @@ -349,9 +349,9 @@ class Infractions(InfractionScheduler, commands.Cog): await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `unvoicemute`?") @command(aliases=("uvmute",)) - async def unvoicemute(self, ctx: Context, user: UnambiguousMemberOrUser) -> None: + async def unvoicemute(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: t.Optional[str] = None) -> None: """Prematurely end the active voice mute infraction for the user.""" - await self.pardon_infraction(ctx, "voice_mute", user) + await self.pardon_infraction(ctx, "voice_mute", user, pardon_reason) # endregion # region: Base apply functions -- cgit v1.2.3 From 6ca8e4822665832609208bd02f2201d5e584479c Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 17 Aug 2022 17:11:35 -0400 Subject: fix: lint --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- bot/exts/moderation/infraction/infractions.py | 24 +++++++++++++++++++++--- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index d733c3936..a6846e239 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -394,7 +394,7 @@ class InfractionScheduler: "Reason": infraction["reason"], "Created": time.format_with_duration(infraction["inserted_at"], infraction["expires_at"]), } - + try: log.trace("Awaiting the pardon action coroutine.") returned_log = await self._pardon_action(infraction, notify) @@ -438,7 +438,7 @@ class InfractionScheduler: except ResponseCodeError: log.exception(f"Failed to fetch watch status for user {user_id}") log_text["Watching"] = "Unknown - failed to fetch watch status." - + try: # Mark infraction as inactive in the database. log.trace(f"Marking infraction #{id_} as inactive in the database.") diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index a94da0d7c..8e5ee8200 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -330,12 +330,24 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Remove infractions (un- commands) @command() - async def unmute(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: t.Optional[str] = None) -> None: + async def unmute( + self, + ctx: Context, + user: UnambiguousMemberOrUser, + *, + pardon_reason: t.Optional[str] = None + ) -> None: """Prematurely end the active mute infraction for the user.""" await self.pardon_infraction(ctx, "mute", user, pardon_reason) @command() - async def unban(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: t.Optional[str] = None) -> None: + async def unban( + self, + ctx: Context, + user: UnambiguousMemberOrUser, + *, + pardon_reason: t.Optional[str] = None + ) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user, pardon_reason) @@ -349,7 +361,13 @@ class Infractions(InfractionScheduler, commands.Cog): await ctx.send(":x: This command is not yet implemented. Maybe you meant to use `unvoicemute`?") @command(aliases=("uvmute",)) - async def unvoicemute(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: t.Optional[str] = None) -> None: + async def unvoicemute( + self, + ctx: Context, + user: UnambiguousMemberOrUser, + *, + pardon_reason: t.Optional[str] = None + ) -> None: """Prematurely end the active voice mute infraction for the user.""" await self.pardon_infraction(ctx, "voice_mute", user, pardon_reason) -- cgit v1.2.3 From e7188bcead99c74207b61d4f1e05f061c843c423 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 17 Aug 2022 17:17:55 -0400 Subject: fix: docstrings --- bot/exts/moderation/infraction/_scheduler.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index a6846e239..b0a2e3359 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -279,7 +279,7 @@ class InfractionScheduler: """ Prematurely end an infraction for a user and log the action in the mod log. - If `pardon_reason` is None, then the infraction object will not receive + If `pardon_reason` is None, then the database will not receive appended text explaining why the infraction was pardoned. If `send_msg` is True, then a pardoning confirmation message will be sent to @@ -367,12 +367,13 @@ class InfractionScheduler: """ Deactivate an active infraction and return a dictionary of lines to send in a mod log. - If `pardon_reason` is None, then the infraction object will not receive - appended text explaining why the infraction was pardoned. - The infraction is removed from Discord, marked as inactive in the database, and has its - expiration task cancelled. If `send_log` is True, a mod log is sent for the - deactivation of the infraction. + expiration task cancelled. + + If `pardon_reason` is None, then the database will not receive + appended text explaining why the infraction was pardoned. + + If `send_log` is True, a mod log is sent for the deactivation of the infraction. If `notify` is True, notify the user of the pardon via DM where applicable. -- cgit v1.2.3 From 1928ab280257d375d5da53f626d25f8a0c84ea18 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 17 Aug 2022 17:43:41 -0400 Subject: fix: trailing whitespace --- bot/exts/moderation/infraction/_scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index b0a2e3359..557fda25a 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -372,7 +372,7 @@ class InfractionScheduler: If `pardon_reason` is None, then the database will not receive appended text explaining why the infraction was pardoned. - + If `send_log` is True, a mod log is sent for the deactivation of the infraction. If `notify` is True, notify the user of the pardon via DM where applicable. -- cgit v1.2.3 From 9434646723e3ab38543576194f2b5fc0f2e680e0 Mon Sep 17 00:00:00 2001 From: Luna Date: Wed, 17 Aug 2022 18:02:21 -0400 Subject: add: test for reasoned and reasonless pardons --- tests/bot/exts/moderation/infraction/test_infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 052048053..c9006588c 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -90,8 +90,14 @@ class VoiceMuteTests(unittest.IsolatedAsyncioTestCase): async def test_voice_unmute(self): """Should call infraction pardoning function.""" self.cog.pardon_infraction = AsyncMock() + self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user, pardon_reason="foobar")) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user, "foobar") + + async def test_voice_unmute_reasonless(self): + """Should call infraction pardoning function without a pardon reason.""" + self.cog.pardon_infraction = AsyncMock() self.assertIsNone(await self.cog.unvoicemute(self.cog, self.ctx, self.user)) - self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_mute", self.user, None) @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") -- cgit v1.2.3 From ab4c5e9ec2d65df0c3035ef7ca75bec8a518322c Mon Sep 17 00:00:00 2001 From: Luna Date: Thu, 18 Aug 2022 11:16:22 -0400 Subject: change: make unban require pardon reason --- bot/exts/moderation/infraction/infractions.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 8e5ee8200..59d8519b7 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -341,13 +341,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.pardon_infraction(ctx, "mute", user, pardon_reason) @command() - async def unban( - self, - ctx: Context, - user: UnambiguousMemberOrUser, - *, - pardon_reason: t.Optional[str] = None - ) -> None: + async def unban(self, ctx: Context, user: UnambiguousMemberOrUser, *, pardon_reason: str) -> None: """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user, pardon_reason) -- cgit v1.2.3 From 06f9f48d2ec947f447824cda05d30f3fef9a3ef1 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Thu, 18 Aug 2022 11:24:54 -0400 Subject: Made DM duration remaining optional with resend --- bot/exts/moderation/infraction/_utils.py | 12 +++++++----- bot/exts/moderation/infraction/management.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 5f2529c6b..02e3ae1aa 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -170,13 +170,16 @@ async def send_active_infraction_message(ctx: Context, infraction: Infraction) - async def notify_infraction( infraction: Infraction, user: MemberOrUser, - reason: t.Optional[str] = None + reason: t.Optional[str] = None, + resend: bool = False ) -> bool: """ DM a user about their new infraction and return True if the DM is successful. `reason` can be used to override what is in `infraction`. Otherwise, this data will be retrieved from `infraction`. + + If `resend` is True, the DM will include both the duration and remaining time. """ infr_id = infraction["id"] infr_type = infraction["type"].replace("_", " ").title() @@ -191,11 +194,10 @@ async def notify_infraction( expires_at = time.format_relative(expiry) duration = time.humanize_delta(origin, expiry, max_units=2) - if infraction["active"]: + if infraction["active"] and resend: remaining = time.humanize_delta(expiry, arrow.utcnow(), max_units=2) - if duration != remaining: - duration += f" ({remaining} remaining)" - else: + duration += f" ({remaining} remaining)" + elif not infraction["active"]: expires_at += " (Inactive)" log.trace(f"Sending {user} a DM about their {infr_type} infraction.") diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 6ef382119..090fbcdbd 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -79,7 +79,7 @@ class ModManagement(commands.Cog): reason = infraction["reason"] or "No reason provided." reason += "\n\n**This is a re-sent message for a previously applied infraction which may have been edited.**" - if await _utils.notify_infraction(infraction, member, reason): + if await _utils.notify_infraction(infraction, member, reason, resend=True): await ctx.send(f":incoming_envelope: Resent DM for infraction `{id_}`.") else: await ctx.send(f"{constants.Emojis.failmail} Failed to resend DM for infraction `{id_}`.") -- cgit v1.2.3 From 2e34dbd385b592b76f69c87154d412ba91c00901 Mon Sep 17 00:00:00 2001 From: Luna Date: Thu, 18 Aug 2022 15:14:02 -0400 Subject: fix: add check to prevent NoneType from passing into str concatenation --- bot/exts/moderation/infraction/_scheduler.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 557fda25a..864aff9b2 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -443,11 +443,15 @@ class InfractionScheduler: try: # Mark infraction as inactive in the database. log.trace(f"Marking infraction #{id_} as inactive in the database.") - - data = {"active": False} - + + data = {"active": False, "reason": ""} + if pardon_reason is not None: - data["reason"] = infraction["reason"] + f" | Pardoned: {pardon_reason}" + # Append pardon reason to infraction in database. + if (punish_reason := infraction["reason"]) is not None: + data["reason"] = punish_reason + " | " + + data["reason"] += f"Pardoned: {pardon_reason}" await self.bot.api_client.patch( f"bot/infractions/{id_}", -- cgit v1.2.3 From e64572fcfa91b7785b1ac37d5fd54b9e5f479401 Mon Sep 17 00:00:00 2001 From: Luna Date: Thu, 18 Aug 2022 15:15:36 -0400 Subject: fix: lint (again) --- bot/exts/moderation/infraction/_scheduler.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 864aff9b2..4d06c6b16 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -443,14 +443,14 @@ class InfractionScheduler: try: # Mark infraction as inactive in the database. log.trace(f"Marking infraction #{id_} as inactive in the database.") - + data = {"active": False, "reason": ""} - + if pardon_reason is not None: # Append pardon reason to infraction in database. if (punish_reason := infraction["reason"]) is not None: data["reason"] = punish_reason + " | " - + data["reason"] += f"Pardoned: {pardon_reason}" await self.bot.api_client.patch( -- cgit v1.2.3 From ea0f1af039e3fafea8456abd50d60de93ab8fca2 Mon Sep 17 00:00:00 2001 From: Luna Date: Thu, 18 Aug 2022 21:12:13 -0400 Subject: fix: remove chance of empty string overriding database infraction reason --- bot/exts/moderation/infraction/_scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 4d06c6b16..bc944aa8e 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -444,7 +444,7 @@ class InfractionScheduler: # Mark infraction as inactive in the database. log.trace(f"Marking infraction #{id_} as inactive in the database.") - data = {"active": False, "reason": ""} + data = {"active": False} if pardon_reason is not None: # Append pardon reason to infraction in database. -- cgit v1.2.3 From 79ee2b0d574090e7f2c6006325742f1d7f9bb469 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 19 Aug 2022 15:10:13 +0100 Subject: fix "isistance" typo --- tests/test_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f3040b305..b2686b1d0 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -14,7 +14,7 @@ class DiscordMocksTests(unittest.TestCase): """Test if the default initialization of MockRole results in the correct object.""" role = helpers.MockRole() - # The `spec` argument makes sure `isistance` checks with `discord.Role` pass + # The `spec` argument makes sure `isinstance` checks with `discord.Role` pass self.assertIsInstance(role, discord.Role) self.assertEqual(role.name, "role") -- cgit v1.2.3 From ddd829fc235b0484064fce0c4924e980659ec2bb Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 19 Aug 2022 15:12:59 +0100 Subject: add support for keywords when using the "rules" command. This doesn't change the way the rules command originally worked and keeps the priority to rule numbers. But in case a number (or numbers) is not supplied, it will try to find a rule that maps to a the supplied keyword. --- bot/exts/info/information.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index e7d17c971..d53f4e323 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -518,12 +518,12 @@ class Information(Cog): await self.send_raw_content(ctx, message, json=True) @command(aliases=("rule",)) - async def rules(self, ctx: Context, rules: Greedy[int]) -> None: + async def rules(self, ctx: Context, rules: Greedy[int], keyword: Optional[str]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") - if not rules: - # Rules were not submitted. Return the default description. + if not rules and not keyword: + # Neither rules nor keywords were submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" @@ -547,7 +547,21 @@ class Information(Cog): for rule in rules: self.bot.stats.incr(f"rule_uses.{rule}") - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) + if rules: + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1][0]}" for pick in rules) + else: + final_rules = tuple(f"**{pick + 1}** {full_rules[pick][0]}" for pick, rule in enumerate(full_rules) + if keyword in rule[1]) + + if not rules and not final_rules: + # This would mean that we didn't find a match for the used keyword + rules_embed.description = ( + f"There are currently no rules that correspond to keyword: {keyword}." + " If you think it should be added, please ask our admins and they'll take care of the rest." + ) + + await ctx.send(embed=rules_embed) + return await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From bf0a5172857cb4a9027d68cd8d0b735565952ce4 Mon Sep 17 00:00:00 2001 From: Luna Date: Fri, 19 Aug 2022 13:17:04 -0400 Subject: fix: data dictionary guarantees reason key existence if pardon reason exists --- bot/exts/moderation/infraction/_scheduler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index bc944aa8e..db1204a8d 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -447,6 +447,7 @@ class InfractionScheduler: data = {"active": False} if pardon_reason is not None: + data["reason"] = "" # Append pardon reason to infraction in database. if (punish_reason := infraction["reason"]) is not None: data["reason"] = punish_reason + " | " -- cgit v1.2.3 From dcd946b32c199cba04b9d0399c1d26d4d3acff51 Mon Sep 17 00:00:00 2001 From: ionite34 Date: Fri, 19 Aug 2022 14:16:37 -0400 Subject: Duration for DM changed to Edited flag --- bot/exts/moderation/infraction/_utils.py | 15 ++++++--------- bot/exts/moderation/infraction/management.py | 2 +- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 02e3ae1aa..c03081b07 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -44,8 +44,8 @@ LONGEST_EXTRAS = max(len(INFRACTION_APPEAL_SERVER_FOOTER), len(INFRACTION_APPEAL INFRACTION_DESCRIPTION_TEMPLATE = ( "**Type:** {type}\n" - "**Expires:** {expires}\n" "**Duration:** {duration}\n" + "**Expires:** {expires}\n" "**Reason:** {reason}\n" ) @@ -170,16 +170,13 @@ async def send_active_infraction_message(ctx: Context, infraction: Infraction) - async def notify_infraction( infraction: Infraction, user: MemberOrUser, - reason: t.Optional[str] = None, - resend: bool = False + reason: t.Optional[str] = None ) -> bool: """ DM a user about their new infraction and return True if the DM is successful. `reason` can be used to override what is in `infraction`. Otherwise, this data will be retrieved from `infraction`. - - If `resend` is True, the DM will include both the duration and remaining time. """ infr_id = infraction["id"] infr_type = infraction["type"].replace("_", " ").title() @@ -194,12 +191,12 @@ async def notify_infraction( expires_at = time.format_relative(expiry) duration = time.humanize_delta(origin, expiry, max_units=2) - if infraction["active"] and resend: - remaining = time.humanize_delta(expiry, arrow.utcnow(), max_units=2) - duration += f" ({remaining} remaining)" - elif not infraction["active"]: + if not infraction["active"]: expires_at += " (Inactive)" + if infraction["inserted_at"] != infraction["last_applied"]: + duration += " (Edited)" + log.trace(f"Sending {user} a DM about their {infr_type} infraction.") if reason is None: diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index 090fbcdbd..6ef382119 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -79,7 +79,7 @@ class ModManagement(commands.Cog): reason = infraction["reason"] or "No reason provided." reason += "\n\n**This is a re-sent message for a previously applied infraction which may have been edited.**" - if await _utils.notify_infraction(infraction, member, reason, resend=True): + if await _utils.notify_infraction(infraction, member, reason): await ctx.send(f":incoming_envelope: Resent DM for infraction `{id_}`.") else: await ctx.send(f"{constants.Emojis.failmail} Failed to resend DM for infraction `{id_}`.") -- cgit v1.2.3 From 0c64708ed660024f80c7f615ff95421fe353aec5 Mon Sep 17 00:00:00 2001 From: Juan Ignacio Battiston Date: Sun, 28 Aug 2022 19:15:13 -0300 Subject: Remove unnecessary loggin --- bot/exts/moderation/infraction/infractions.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 05cc74a03..2a325da77 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -125,8 +125,6 @@ class Infractions(InfractionScheduler, commands.Cog): infraction = await self.apply_ban(ctx, user, reason, duration_or_expiry=duration) if not infraction or not infraction.get("id"): # Ban was unsuccessful, quit early. - await ctx.send(":x: Failed to apply ban.") - log.error("Failed to apply ban to user %d", user.id) return # Calling commands directly skips discord.py's convertors, so we need to convert args manually. -- cgit v1.2.3 From 357ed80e2a0f35172ca91ceda7a353a4a8f765dd Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Thu, 8 Sep 2022 23:11:41 +0100 Subject: send the list of pre-defined keywords along with each invoked rule --- bot/exts/info/information.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index d53f4e323..54fa62d31 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -521,6 +521,7 @@ class Information(Cog): async def rules(self, ctx: Context, rules: Greedy[int], keyword: Optional[str]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") + keyword = keyword.lower() if not rules and not keyword: # Neither rules nor keywords were submitted. Return the default description. @@ -548,9 +549,13 @@ class Information(Cog): self.bot.stats.incr(f"rule_uses.{rule}") if rules: - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1][0]}" for pick in rules) + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1][0]}\n\n" + f"You can also invoke this rule with the following keywords: " + f"{', '.join(full_rules[pick -1][1])}" for pick in rules) else: - final_rules = tuple(f"**{pick + 1}** {full_rules[pick][0]}" for pick, rule in enumerate(full_rules) + final_rules = tuple(f"**{pick + 1}** {full_rules[pick][0]}\n\n" + f"You can also invoke this rule with the following keywords: " + f"{', '.join(full_rules[pick][1])}" for pick, rule in enumerate(full_rules) if keyword in rule[1]) if not rules and not final_rules: -- cgit v1.2.3 From f924dd24a6846dcc00f96c7158111cfffc092014 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Thu, 8 Sep 2022 23:17:25 +0100 Subject: send the "no-match" error message in pure text format --- bot/exts/info/information.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 54fa62d31..9dd0a6d45 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -560,12 +560,9 @@ class Information(Cog): if not rules and not final_rules: # This would mean that we didn't find a match for the used keyword - rules_embed.description = ( + await ctx.send( f"There are currently no rules that correspond to keyword: {keyword}." - " If you think it should be added, please ask our admins and they'll take care of the rest." - ) - - await ctx.send(embed=rules_embed) + "If you think it should be added, please ask our admins and they'll take care of the rest.") return await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 3fa905b14a8ac8e23159d487b2046d7ca62c7afb Mon Sep 17 00:00:00 2001 From: wookie184 Date: Sat, 10 Sep 2022 14:43:42 +0100 Subject: Fix error in help channels cog which assumed an embed would have a description --- bot/exts/help_channels/_channel.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/help_channels/_channel.py b/bot/exts/help_channels/_channel.py index d9cebf215..cfe774f4c 100644 --- a/bot/exts/help_channels/_channel.py +++ b/bot/exts/help_channels/_channel.py @@ -183,7 +183,8 @@ async def ensure_cached_claimant(channel: discord.TextChannel) -> None: log.info("Hit the dormant message embed before finding a claimant in %s (%d).", channel, channel.id) break # Only set the claimant if the first embed matches the claimed channel embed regex - if match := CLAIMED_BY_RE.match(message.embeds[0].description): + description = message.embeds[0].description + if (description is not None) and (match := CLAIMED_BY_RE.match(description)): await _caches.claimants.set(channel.id, int(match.group("user_id"))) return -- cgit v1.2.3 From 350df44731875c4e8b28fc509d83b431cfefbc5c Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 1 Sep 2022 21:31:33 +0100 Subject: Use venvs with poetry in Dockerfile This is required due to a regression in poetry, see https://github.com/HassanAbouelela/actions/pull/7 --- Dockerfile | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/Dockerfile b/Dockerfile index 5bb400658..20b099a0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,17 +1,26 @@ FROM --platform=linux/amd64 python:3.10-slim # Set pip to have no saved cache -ENV PIP_NO_CACHE_DIR=false \ - POETRY_VIRTUALENVS_CREATE=false +ENV PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + POETRY_VERSION=1.1.15 \ + POETRY_HOME="/opt/poetry" \ + POETRY_VIRTUALENVS_IN_PROJECT=true \ + POETRY_NO_INTERACTION=1 \ + INSTALL_DIR="/opt/dependencies" \ + APP_DIR="/bot" +ENV PATH="$POETRY_HOME/bin:/$INSTALL_DIR/.venv/bin:$PATH" -# Install poetry -RUN pip install -U poetry +RUN apt-get update \ + && apt-get -y upgrade \ + && apt-get install --no-install-recommends -y curl \ + && apt-get clean && rm -rf /var/lib/apt/lists/* -# Create the working directory -WORKDIR /bot +RUN curl -sSL https://install.python-poetry.org | python # Install project dependencies +WORKDIR $INSTALL_DIR COPY pyproject.toml poetry.lock ./ RUN poetry install --no-dev @@ -22,6 +31,7 @@ ARG git_sha="development" ENV GIT_SHA=$git_sha # Copy the source code in last to optimize rebuilding the image +WORKDIR $APP_DIR COPY . . ENTRYPOINT ["python3"] -- cgit v1.2.3 From e12e5911f971beae0c07c12f9cf632bbaf4e6629 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 1 Sep 2022 21:31:59 +0100 Subject: Use HassanAbouelela/setup-python for CI --- .github/workflows/lint-test.yml | 62 ++++------------------------------------- 1 file changed, 5 insertions(+), 57 deletions(-) diff --git a/.github/workflows/lint-test.yml b/.github/workflows/lint-test.yml index 2b3dd5b4f..a331659e6 100644 --- a/.github/workflows/lint-test.yml +++ b/.github/workflows/lint-test.yml @@ -33,57 +33,16 @@ jobs: REDDIT_SECRET: ham REDIS_PASSWORD: '' - # Configure pip to cache dependencies and do a user install - PIP_NO_CACHE_DIR: false - PIP_USER: 1 - - # Make sure package manager does not use virtualenv - POETRY_VIRTUALENVS_CREATE: false - - # Specify explicit paths for python dependencies and the pre-commit - # environment so we know which directories to cache - POETRY_CACHE_DIR: ${{ github.workspace }}/.cache/py-user-base - PYTHONUSERBASE: ${{ github.workspace }}/.cache/py-user-base - PRE_COMMIT_HOME: ${{ github.workspace }}/.cache/pre-commit-cache - - # See https://github.com/pre-commit/pre-commit/issues/2178#issuecomment-1002163763 - # for why we set this. - SETUPTOOLS_USE_DISTUTILS: stdlib - steps: - - name: Add custom PYTHONUSERBASE to PATH - run: echo '${{ env.PYTHONUSERBASE }}/bin/' >> $GITHUB_PATH - - name: Checkout repository uses: actions/checkout@v2 - - name: Setup python - id: python - uses: actions/setup-python@v2 - with: - python-version: '3.10' - - # This step caches our Python dependencies. To make sure we - # only restore a cache when the dependencies, the python version, - # the runner operating system, and the dependency location haven't - # changed, we create a cache key that is a composite of those states. - # - # Only when the context is exactly the same, we will restore the cache. - - name: Python Dependency Caching - uses: actions/cache@v2 - id: python_cache + - name: Install Python Dependencies + uses: HassanAbouelela/actions/setup-python@setup-python_v1.3.1 with: - path: ${{ env.PYTHONUSERBASE }} - key: "python-0-${{ runner.os }}-${{ env.PYTHONUSERBASE }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./pyproject.toml', './poetry.lock') }}" - - # Install our dependencies if we did not restore a dependency cache - - name: Install dependencies using poetry - if: steps.python_cache.outputs.cache-hit != 'true' - run: | - pip install poetry - poetry install + # Set dev=true to install flake8 extensions, which are dev dependencies + dev: true + python_version: '3.10' # Check all of our non-dev dependencies are compatible with the MIT license. # If you added a new dependencies that is being rejected, @@ -94,17 +53,6 @@ jobs: pip-licenses --allow-only="$ALLOWED_LICENSE" \ --package $(poetry export -f requirements.txt --without-hashes | sed "s/==.*//g" | tr "\n" " ") - # This step caches our pre-commit environment. To make sure we - # do create a new environment when our pre-commit setup changes, - # we create a cache key based on relevant factors. - - name: Pre-commit Environment Caching - uses: actions/cache@v2 - with: - path: ${{ env.PRE_COMMIT_HOME }} - key: "precommit-0-${{ runner.os }}-${{ env.PRE_COMMIT_HOME }}-\ - ${{ steps.python.outputs.python-version }}-\ - ${{ hashFiles('./.pre-commit-config.yaml') }}" - # We will not run `flake8` here, as we will use a separate flake8 # action. As pre-commit does not support user installs, we set # PIP_USER=0 to not do a user install. -- cgit v1.2.3 From 0b495e757c909d6a5802ffa729cdc9d3e4069978 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sat, 10 Sep 2022 19:57:16 +0100 Subject: Bump poetry in Docker and lint to 1.2.0 --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 20b099a0b..d0687475e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM --platform=linux/amd64 python:3.10-slim # Set pip to have no saved cache ENV PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=on \ - POETRY_VERSION=1.1.15 \ + POETRY_VERSION=1.2.0 \ POETRY_HOME="/opt/poetry" \ POETRY_VIRTUALENVS_IN_PROJECT=true \ POETRY_NO_INTERACTION=1 \ -- cgit v1.2.3 From c1ce6839ae4a944ee4207d8d283a738f64937b3a Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 11 Sep 2022 09:30:31 +0100 Subject: accept keywords and rule numbers in any order --- bot/exts/info/information.py | 58 +++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 9dd0a6d45..3dd12c005 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -8,7 +8,7 @@ from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union import rapidfuzz from botcore.site_api import ResponseCodeError from discord import AllowedMentions, Colour, Embed, Guild, Message, Role -from discord.ext.commands import BucketType, Cog, Context, Greedy, Paginator, command, group, has_any_role +from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from discord.utils import escape_markdown from bot import constants @@ -518,12 +518,23 @@ class Information(Cog): await self.send_raw_content(ctx, message, json=True) @command(aliases=("rule",)) - async def rules(self, ctx: Context, rules: Greedy[int], keyword: Optional[str]) -> None: - """Provides a link to all rules or, if specified, displays specific rule(s).""" + async def rules(self, ctx: Context, *args: Optional[str]) -> None: + """ + Provides a link to all rules or, if specified, displays specific rules(s). + + It accepts either rule numbers or particular keywords that map to a particular rule. + Rule numbers and keywords can be sent in any order. + """ rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") - keyword = keyword.lower() + keywords, rules = [], [] + + for word in args: + if word.isdigit(): + rules.append(int(word)) + else: + keywords.append(word.lower()) - if not rules and not keyword: + if not rules and not keywords: # Neither rules nor keywords were submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" @@ -538,30 +549,39 @@ class Information(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) + # Remove duplicate keywords + keywords = set(keywords) invalid = ", ".join(str(index) for index in rules if index < 1 or index > len(full_rules)) - if invalid: + if invalid and not keywords: await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) return - for rule in rules: - self.bot.stats.incr(f"rule_uses.{rule}") + final_rules = set() - if rules: - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1][0]}\n\n" - f"You can also invoke this rule with the following keywords: " - f"{', '.join(full_rules[pick -1][1])}" for pick in rules) - else: - final_rules = tuple(f"**{pick + 1}** {full_rules[pick][0]}\n\n" - f"You can also invoke this rule with the following keywords: " - f"{', '.join(full_rules[pick][1])}" for pick, rule in enumerate(full_rules) - if keyword in rule[1]) + for pick in rules: + self.bot.stats.incr(f"rule_uses.{pick}") + final_rules.add( + f"**{pick}.** {full_rules[pick - 1][0]}\n\n" + f"You can also invoke this rule with the following keywords: " + f"{', '.join(full_rules[pick -1][1])}") + + for keyword in keywords: + for pick, rule in enumerate(full_rules): + if keyword not in rule[1]: + continue + + self.bot.stats.incr(f"rule_uses.{pick + 1}") + final_rules.add( + f"**{pick + 1}.** {full_rules[pick][0]}\n\n" + f"You can also invoke this rule with the following keywords: " + f"{', '.join(full_rules[pick][1])}") if not rules and not final_rules: - # This would mean that we didn't find a match for the used keyword + # This would mean that only keywords where used and no match for them was found await ctx.send( - f"There are currently no rules that correspond to keyword: {keyword}." + f"There are currently no rules that correspond to keywords: {[', '.join(keywords)]}." "If you think it should be added, please ask our admins and they'll take care of the rest.") return -- cgit v1.2.3 From 6b3b18078d8f8a636c5c5908f41f9075ab1ebfac Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 11 Sep 2022 10:29:35 +0100 Subject: add missing blank line --- bot/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/constants.py b/bot/constants.py index db98e6f47..68a96876f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -397,6 +397,7 @@ class Categories(metaclass=YAMLGetter): # 2021 Summer Code Jam summer_code_jam: int + class Channels(metaclass=YAMLGetter): section = "guild" subsection = "channels" -- cgit v1.2.3 From 8350f3641ff13da8f4d4849a2e44627f485e6a93 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 11 Sep 2022 10:35:41 +0100 Subject: suggest adding a keyword in dev & meta channels --- bot/exts/info/information.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 3dd12c005..5f996508f 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -19,7 +19,7 @@ from bot.errors import NonExistentRoleError from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import time -from bot.utils.channel import is_mod_channel, is_staff_channel +from bot.utils.channel import get_or_fetch_channel, is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.members import get_or_fetch_member @@ -580,9 +580,12 @@ class Information(Cog): if not rules and not final_rules: # This would mean that only keywords where used and no match for them was found + dev_contrib_channel = await get_or_fetch_channel(constants.Channels.dev_contrib) + meta_channel = await get_or_fetch_channel(constants.Channels.meta) await ctx.send( - f"There are currently no rules that correspond to keywords: {[', '.join(keywords)]}." - "If you think it should be added, please ask our admins and they'll take care of the rest.") + f"There are currently no rules that correspond to keywords: **{', '.join(keywords)}**.\n" + f"If you think it should be added, please suggest it in either " + f"{meta_channel.mention} or {dev_contrib_channel.mention}") return await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 03a3a03d39099b09ed62337b075a9ada43353144 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Mon, 12 Sep 2022 21:37:04 +0100 Subject: Don't use fake in-project venvs for poetry Instead let poetry install the venv for the project in the right place, leading to a more 'traditional' poetry setup. --- Dockerfile | 34 +++++++++++++--------------------- 1 file changed, 13 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index d0687475e..9cf9c7b27 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,15 @@ FROM --platform=linux/amd64 python:3.10-slim -# Set pip to have no saved cache -ENV PIP_NO_CACHE_DIR=1 \ - PIP_DISABLE_PIP_VERSION_CHECK=on \ - POETRY_VERSION=1.2.0 \ - POETRY_HOME="/opt/poetry" \ - POETRY_VIRTUALENVS_IN_PROJECT=true \ - POETRY_NO_INTERACTION=1 \ - INSTALL_DIR="/opt/dependencies" \ - APP_DIR="/bot" - -ENV PATH="$POETRY_HOME/bin:/$INSTALL_DIR/.venv/bin:$PATH" +# Define Git SHA build argument for sentry +ARG git_sha="development" + +ENV POETRY_VERSION=1.2.0 \ + POETRY_HOME="/opt/poetry" \ + POETRY_NO_INTERACTION=1 \ + APP_DIR="/bot" \ + GIT_SHA=$git_sha + +ENV PATH="$POETRY_HOME/bin:$PATH" RUN apt-get update \ && apt-get -y upgrade \ @@ -20,19 +19,12 @@ RUN apt-get update \ RUN curl -sSL https://install.python-poetry.org | python # Install project dependencies -WORKDIR $INSTALL_DIR +WORKDIR $APP_DIR COPY pyproject.toml poetry.lock ./ RUN poetry install --no-dev -# Define Git SHA build argument -ARG git_sha="development" - -# Set Git SHA environment variable for Sentry -ENV GIT_SHA=$git_sha - # Copy the source code in last to optimize rebuilding the image -WORKDIR $APP_DIR COPY . . -ENTRYPOINT ["python3"] -CMD ["-m", "bot"] +ENTRYPOINT ["poetry"] +CMD ["run", "python", "-m", "bot"] -- cgit v1.2.3 From 71b1802b0f91b855443fa5ed01d9134944fd6f38 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 13 Sep 2022 00:25:36 +0100 Subject: Ignore mounted in-project venvs on startup Poetry's virtualenvs.in-project config deafults to None, meaning it will use in-project venvs if it finds one, otherwise it will use the cache dir. In dev we mount the entire root project directory to /bot. This means if the host's venv in in the project dir, this will get mounted and prioritised by poetry run. If the host is on a non-linux OS this will cause poetry to fail to boot. --- Dockerfile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 9cf9c7b27..da65bbf3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,9 +3,12 @@ FROM --platform=linux/amd64 python:3.10-slim # Define Git SHA build argument for sentry ARG git_sha="development" +# POETRY_VIRTUALENVS_IN_PROJECT is required to ensure in-projects venvs mounted from the host in dev +# don't get prioritised by `poetry run` ENV POETRY_VERSION=1.2.0 \ POETRY_HOME="/opt/poetry" \ POETRY_NO_INTERACTION=1 \ + POETRY_VIRTUALENVS_IN_PROJECT=false \ APP_DIR="/bot" \ GIT_SHA=$git_sha @@ -21,7 +24,7 @@ RUN curl -sSL https://install.python-poetry.org | python # Install project dependencies WORKDIR $APP_DIR COPY pyproject.toml poetry.lock ./ -RUN poetry install --no-dev +RUN poetry install --without dev # Copy the source code in last to optimize rebuilding the image COPY . . -- cgit v1.2.3 From 2db6a241bde75e6cd36f3fe0f32a3530187a827a Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Tue, 13 Sep 2022 00:27:53 +0100 Subject: Specify the path for poetry venvs Without this the venv would be created in /root/.cache and the nonn-root user that prod runs under would not have access to it. --- Dockerfile | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index da65bbf3b..65ca8ce51 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,7 +6,8 @@ ARG git_sha="development" # POETRY_VIRTUALENVS_IN_PROJECT is required to ensure in-projects venvs mounted from the host in dev # don't get prioritised by `poetry run` ENV POETRY_VERSION=1.2.0 \ - POETRY_HOME="/opt/poetry" \ + POETRY_HOME="/opt/poetry/home" \ + POETRY_CACHE_DIR="/opt/poetry/cache" \ POETRY_NO_INTERACTION=1 \ POETRY_VIRTUALENVS_IN_PROJECT=false \ APP_DIR="/bot" \ -- cgit v1.2.3 From 9c85342f4449f2116fb36ee1500ad06a94c1e14c Mon Sep 17 00:00:00 2001 From: Izan Date: Wed, 14 Sep 2022 20:56:30 +0100 Subject: Update docstrings & comment. --- bot/exts/utils/reminders.py | 25 ++++++++----------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index f0da37f3b..c65785314 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -214,7 +214,7 @@ class Reminders(Cog): """ Attempts to get content from the referenced message, if applicable. - Differs from botcore.utils.commands.clean_text_or_reply as allows for embeds. + Differs from botcore.utils.commands.clean_text_or_reply as allows for messages with no content. """ content = None if reference := ctx.message.reference: @@ -398,20 +398,7 @@ class Reminders(Cog): @remind_group.group(name="edit", aliases=("change", "modify"), invoke_without_command=True) async def edit_reminder_group(self, ctx: Context) -> None: - """ - Commands for modifying your current reminders. - - The `expiration` duration supports the following symbols for each unit of time: - - years: `Y`, `y`, `year`, `years` - - months: `m`, `month`, `months` - - weeks: `w`, `W`, `week`, `weeks` - - days: `d`, `D`, `day`, `days` - - hours: `H`, `h`, `hour`, `hours` - - minutes: `M`, `minute`, `minutes` - - seconds: `S`, `s`, `second`, `seconds` - - For example, to edit a reminder to expire in 3 days and 1 minute, you can do `!remind edit duration 1234 3d1M`. - """ + """Commands for modifying your current reminders.""" await ctx.send_help(ctx.command) @edit_reminder_group.command(name="duration", aliases=("time",)) @@ -434,11 +421,15 @@ class Reminders(Cog): @edit_reminder_group.command(name="content", aliases=("reason",)) async def edit_reminder_content(self, ctx: Context, id_: int, *, content: t.Optional[str] = None) -> None: - """Edit one of your reminder's content.""" + """ + Edit one of your reminder's content. + + You can either supply the new content yourself, or reply to a message to use its content. + """ if not content: content = await self.try_get_content_from_reply(ctx) if not content: - # Couldn't get content from reply + # Message doesn't have a reply to get content from return await self.edit_reminder(ctx, id_, {"content": content}) -- cgit v1.2.3 From 74d2b2153e5f6e53f26c9386898868b2e87aa0fb Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 19 Aug 2022 15:10:13 +0100 Subject: fix "isistance" typo --- tests/test_helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f3040b305..b2686b1d0 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -14,7 +14,7 @@ class DiscordMocksTests(unittest.TestCase): """Test if the default initialization of MockRole results in the correct object.""" role = helpers.MockRole() - # The `spec` argument makes sure `isistance` checks with `discord.Role` pass + # The `spec` argument makes sure `isinstance` checks with `discord.Role` pass self.assertIsInstance(role, discord.Role) self.assertEqual(role.name, "role") -- cgit v1.2.3 From b1fcf6c9ac1b71658a8a8484029352f1e7d59d3d Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 19 Aug 2022 15:12:59 +0100 Subject: add support for keywords when using the "rules" command. This doesn't change the way the rules command originally worked and keeps the priority to rule numbers. But in case a number (or numbers) is not supplied, it will try to find a rule that maps to a the supplied keyword. --- bot/exts/info/information.py | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index e7d17c971..d53f4e323 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -518,12 +518,12 @@ class Information(Cog): await self.send_raw_content(ctx, message, json=True) @command(aliases=("rule",)) - async def rules(self, ctx: Context, rules: Greedy[int]) -> None: + async def rules(self, ctx: Context, rules: Greedy[int], keyword: Optional[str]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") - if not rules: - # Rules were not submitted. Return the default description. + if not rules and not keyword: + # Neither rules nor keywords were submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" @@ -547,7 +547,21 @@ class Information(Cog): for rule in rules: self.bot.stats.incr(f"rule_uses.{rule}") - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1]}" for pick in rules) + if rules: + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1][0]}" for pick in rules) + else: + final_rules = tuple(f"**{pick + 1}** {full_rules[pick][0]}" for pick, rule in enumerate(full_rules) + if keyword in rule[1]) + + if not rules and not final_rules: + # This would mean that we didn't find a match for the used keyword + rules_embed.description = ( + f"There are currently no rules that correspond to keyword: {keyword}." + " If you think it should be added, please ask our admins and they'll take care of the rest." + ) + + await ctx.send(embed=rules_embed) + return await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 276dff8d08c8dc1e434a18a0a42966e3b1089186 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Thu, 8 Sep 2022 23:11:41 +0100 Subject: send the list of pre-defined keywords along with each invoked rule --- bot/exts/info/information.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index d53f4e323..54fa62d31 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -521,6 +521,7 @@ class Information(Cog): async def rules(self, ctx: Context, rules: Greedy[int], keyword: Optional[str]) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") + keyword = keyword.lower() if not rules and not keyword: # Neither rules nor keywords were submitted. Return the default description. @@ -548,9 +549,13 @@ class Information(Cog): self.bot.stats.incr(f"rule_uses.{rule}") if rules: - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1][0]}" for pick in rules) + final_rules = tuple(f"**{pick}.** {full_rules[pick - 1][0]}\n\n" + f"You can also invoke this rule with the following keywords: " + f"{', '.join(full_rules[pick -1][1])}" for pick in rules) else: - final_rules = tuple(f"**{pick + 1}** {full_rules[pick][0]}" for pick, rule in enumerate(full_rules) + final_rules = tuple(f"**{pick + 1}** {full_rules[pick][0]}\n\n" + f"You can also invoke this rule with the following keywords: " + f"{', '.join(full_rules[pick][1])}" for pick, rule in enumerate(full_rules) if keyword in rule[1]) if not rules and not final_rules: -- cgit v1.2.3 From 1ba79145834dc5363d1aed95e7728562353ef755 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Thu, 8 Sep 2022 23:17:25 +0100 Subject: send the "no-match" error message in pure text format --- bot/exts/info/information.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 54fa62d31..9dd0a6d45 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -560,12 +560,9 @@ class Information(Cog): if not rules and not final_rules: # This would mean that we didn't find a match for the used keyword - rules_embed.description = ( + await ctx.send( f"There are currently no rules that correspond to keyword: {keyword}." - " If you think it should be added, please ask our admins and they'll take care of the rest." - ) - - await ctx.send(embed=rules_embed) + "If you think it should be added, please ask our admins and they'll take care of the rest.") return await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 1e286207fdfbfc0124845fc3620068db3f5657e6 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 11 Sep 2022 09:30:31 +0100 Subject: accept keywords and rule numbers in any order --- bot/exts/info/information.py | 58 +++++++++++++++++++++++++++++--------------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 9dd0a6d45..3dd12c005 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -8,7 +8,7 @@ from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union import rapidfuzz from botcore.site_api import ResponseCodeError from discord import AllowedMentions, Colour, Embed, Guild, Message, Role -from discord.ext.commands import BucketType, Cog, Context, Greedy, Paginator, command, group, has_any_role +from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group, has_any_role from discord.utils import escape_markdown from bot import constants @@ -518,12 +518,23 @@ class Information(Cog): await self.send_raw_content(ctx, message, json=True) @command(aliases=("rule",)) - async def rules(self, ctx: Context, rules: Greedy[int], keyword: Optional[str]) -> None: - """Provides a link to all rules or, if specified, displays specific rule(s).""" + async def rules(self, ctx: Context, *args: Optional[str]) -> None: + """ + Provides a link to all rules or, if specified, displays specific rules(s). + + It accepts either rule numbers or particular keywords that map to a particular rule. + Rule numbers and keywords can be sent in any order. + """ rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") - keyword = keyword.lower() + keywords, rules = [], [] + + for word in args: + if word.isdigit(): + rules.append(int(word)) + else: + keywords.append(word.lower()) - if not rules and not keyword: + if not rules and not keywords: # Neither rules nor keywords were submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" @@ -538,30 +549,39 @@ class Information(Cog): # Remove duplicates and sort the rule indices rules = sorted(set(rules)) + # Remove duplicate keywords + keywords = set(keywords) invalid = ", ".join(str(index) for index in rules if index < 1 or index > len(full_rules)) - if invalid: + if invalid and not keywords: await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) return - for rule in rules: - self.bot.stats.incr(f"rule_uses.{rule}") + final_rules = set() - if rules: - final_rules = tuple(f"**{pick}.** {full_rules[pick - 1][0]}\n\n" - f"You can also invoke this rule with the following keywords: " - f"{', '.join(full_rules[pick -1][1])}" for pick in rules) - else: - final_rules = tuple(f"**{pick + 1}** {full_rules[pick][0]}\n\n" - f"You can also invoke this rule with the following keywords: " - f"{', '.join(full_rules[pick][1])}" for pick, rule in enumerate(full_rules) - if keyword in rule[1]) + for pick in rules: + self.bot.stats.incr(f"rule_uses.{pick}") + final_rules.add( + f"**{pick}.** {full_rules[pick - 1][0]}\n\n" + f"You can also invoke this rule with the following keywords: " + f"{', '.join(full_rules[pick -1][1])}") + + for keyword in keywords: + for pick, rule in enumerate(full_rules): + if keyword not in rule[1]: + continue + + self.bot.stats.incr(f"rule_uses.{pick + 1}") + final_rules.add( + f"**{pick + 1}.** {full_rules[pick][0]}\n\n" + f"You can also invoke this rule with the following keywords: " + f"{', '.join(full_rules[pick][1])}") if not rules and not final_rules: - # This would mean that we didn't find a match for the used keyword + # This would mean that only keywords where used and no match for them was found await ctx.send( - f"There are currently no rules that correspond to keyword: {keyword}." + f"There are currently no rules that correspond to keywords: {[', '.join(keywords)]}." "If you think it should be added, please ask our admins and they'll take care of the rest.") return -- cgit v1.2.3 From 1fef952458f1d5282db3c707f203739f3698802c Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 11 Sep 2022 10:29:35 +0100 Subject: add missing blank line --- bot/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/constants.py b/bot/constants.py index db98e6f47..68a96876f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -397,6 +397,7 @@ class Categories(metaclass=YAMLGetter): # 2021 Summer Code Jam summer_code_jam: int + class Channels(metaclass=YAMLGetter): section = "guild" subsection = "channels" -- cgit v1.2.3 From ae0146c6437afca5b97ac0de909efab000977b0f Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 11 Sep 2022 10:35:41 +0100 Subject: suggest adding a keyword in dev & meta channels --- bot/exts/info/information.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 3dd12c005..5f996508f 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -19,7 +19,7 @@ from bot.errors import NonExistentRoleError from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import time -from bot.utils.channel import is_mod_channel, is_staff_channel +from bot.utils.channel import get_or_fetch_channel, is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.members import get_or_fetch_member @@ -580,9 +580,12 @@ class Information(Cog): if not rules and not final_rules: # This would mean that only keywords where used and no match for them was found + dev_contrib_channel = await get_or_fetch_channel(constants.Channels.dev_contrib) + meta_channel = await get_or_fetch_channel(constants.Channels.meta) await ctx.send( - f"There are currently no rules that correspond to keywords: {[', '.join(keywords)]}." - "If you think it should be added, please ask our admins and they'll take care of the rest.") + f"There are currently no rules that correspond to keywords: **{', '.join(keywords)}**.\n" + f"If you think it should be added, please suggest it in either " + f"{meta_channel.mention} or {dev_contrib_channel.mention}") return await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 3dbb53fd11cf688289d6294210dbca3eef70a538 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 16 Sep 2022 17:04:49 +0100 Subject: use the cached channels instead of fetching them with each command execution --- bot/exts/info/information.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 5f996508f..67515bc57 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -19,7 +19,7 @@ from bot.errors import NonExistentRoleError from bot.log import get_logger from bot.pagination import LinePaginator from bot.utils import time -from bot.utils.channel import get_or_fetch_channel, is_mod_channel, is_staff_channel +from bot.utils.channel import is_mod_channel, is_staff_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.members import get_or_fetch_member @@ -580,12 +580,10 @@ class Information(Cog): if not rules and not final_rules: # This would mean that only keywords where used and no match for them was found - dev_contrib_channel = await get_or_fetch_channel(constants.Channels.dev_contrib) - meta_channel = await get_or_fetch_channel(constants.Channels.meta) await ctx.send( f"There are currently no rules that correspond to keywords: **{', '.join(keywords)}**.\n" f"If you think it should be added, please suggest it in either " - f"{meta_channel.mention} or {dev_contrib_channel.mention}") + f"<#{constants.Channels.meta}> or <#{constants.Channels.dev_contrib}>") return await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 4a8a0f63531585353c27a54539a9d5349799ef8a Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 16 Sep 2022 17:40:32 +0100 Subject: fix typo in docstrings --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 67515bc57..27a7b501d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -520,7 +520,7 @@ class Information(Cog): @command(aliases=("rule",)) async def rules(self, ctx: Context, *args: Optional[str]) -> None: """ - Provides a link to all rules or, if specified, displays specific rules(s). + Provides a link to all rules or, if specified, displays specific rule(s). It accepts either rule numbers or particular keywords that map to a particular rule. Rule numbers and keywords can be sent in any order. -- cgit v1.2.3 From 32913523c0e34a91b621f6304ae95f8ba0728e14 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 16 Sep 2022 17:42:10 +0100 Subject: replace .isdigit predicate with a try except block --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 27a7b501d..b66c68f87 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -529,9 +529,9 @@ class Information(Cog): keywords, rules = [], [] for word in args: - if word.isdigit(): + try: rules.append(int(word)) - else: + except ValueError: keywords.append(word.lower()) if not rules and not keywords: -- cgit v1.2.3 From 95124cafd0b23c7e4bb2c0c19b3a043f17184ef2 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 16 Sep 2022 17:44:56 +0100 Subject: rename rules to rule_numbers --- bot/exts/info/information.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index b66c68f87..c706fa4cd 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -526,15 +526,15 @@ class Information(Cog): Rule numbers and keywords can be sent in any order. """ rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") - keywords, rules = [], [] + keywords, rule_numbers = [], [] for word in args: try: - rules.append(int(word)) + rule_numbers.append(int(word)) except ValueError: keywords.append(word.lower()) - if not rules and not keywords: + if not rule_numbers and not keywords: # Neither rules nor keywords were submitted. Return the default description. rules_embed.description = ( "The rules and guidelines that apply to this community can be found on" @@ -548,11 +548,11 @@ class Information(Cog): full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) # Remove duplicates and sort the rule indices - rules = sorted(set(rules)) + rule_numbers = sorted(set(rule_numbers)) # Remove duplicate keywords keywords = set(keywords) - invalid = ", ".join(str(index) for index in rules if index < 1 or index > len(full_rules)) + invalid = ", ".join(str(index) for index in rule_numbers if index < 1 or index > len(full_rules)) if invalid and not keywords: await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) @@ -560,7 +560,7 @@ class Information(Cog): final_rules = set() - for pick in rules: + for pick in rule_numbers: self.bot.stats.incr(f"rule_uses.{pick}") final_rules.add( f"**{pick}.** {full_rules[pick - 1][0]}\n\n" @@ -578,7 +578,7 @@ class Information(Cog): f"You can also invoke this rule with the following keywords: " f"{', '.join(full_rules[pick][1])}") - if not rules and not final_rules: + if not rule_numbers and not final_rules: # This would mean that only keywords where used and no match for them was found await ctx.send( f"There are currently no rules that correspond to keywords: **{', '.join(keywords)}**.\n" -- cgit v1.2.3 From 8028690d4eae27e57dfe1429b8067eabaa94eef9 Mon Sep 17 00:00:00 2001 From: Aleksey Zasorin Date: Fri, 16 Sep 2022 10:42:16 -0700 Subject: Removed "redis_ready" from additional_spec_asyncs in MockBot (#2275) The attribute was removed from Bot in fc05849 --- tests/helpers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/helpers.py b/tests/helpers.py index 687e15b96..a4b919dcb 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -317,7 +317,7 @@ class MockBot(CustomMockMixin, unittest.mock.MagicMock): guild_id=1, intents=discord.Intents.all(), ) - additional_spec_asyncs = ("wait_for", "redis_ready") + additional_spec_asyncs = ("wait_for",) def __init__(self, **kwargs) -> None: super().__init__(**kwargs) -- cgit v1.2.3 From 1c1650001f58e31721c79096642033fc005f8c82 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Fri, 16 Sep 2022 21:08:44 +0100 Subject: remove help message that displays the available keywords per rule --- bot/exts/info/information.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c706fa4cd..42385edfb 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -562,10 +562,7 @@ class Information(Cog): for pick in rule_numbers: self.bot.stats.incr(f"rule_uses.{pick}") - final_rules.add( - f"**{pick}.** {full_rules[pick - 1][0]}\n\n" - f"You can also invoke this rule with the following keywords: " - f"{', '.join(full_rules[pick -1][1])}") + final_rules.add(f"**{pick}.** {full_rules[pick - 1][0]}") for keyword in keywords: for pick, rule in enumerate(full_rules): @@ -573,10 +570,7 @@ class Information(Cog): continue self.bot.stats.incr(f"rule_uses.{pick + 1}") - final_rules.add( - f"**{pick + 1}.** {full_rules[pick][0]}\n\n" - f"You can also invoke this rule with the following keywords: " - f"{', '.join(full_rules[pick][1])}") + final_rules.add(f"**{pick + 1}.** {full_rules[pick][0]}") if not rule_numbers and not final_rules: # This would mean that only keywords where used and no match for them was found -- cgit v1.2.3 From 343858f55ab8cbb482c70b8ffd3c8e48ab35d3b1 Mon Sep 17 00:00:00 2001 From: Mohammad Ibrahim <74553450+Ibrahim2750mi@users.noreply.github.com> Date: Sat, 17 Sep 2022 18:28:48 +0530 Subject: Removed italics from the help command (#2272) * removed asterisk from embed description * removed italics from line 334, 375 and 415 * pagination.py, L239 added italics --- bot/exts/info/help.py | 8 ++++---- bot/pagination.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 282f8c97a..48f840e51 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -307,7 +307,7 @@ class CustomHelpCommand(HelpCommand): # Remove line breaks from docstrings, if not used to separate paragraphs. # Allow overriding this behaviour via putting \u2003 at the start of a line. formatted_doc = re.sub("(? Date: Sat, 17 Sep 2022 19:24:37 +0100 Subject: Address Review - Convert `ids` to a set to remove duplicates - Limit the amount of reminders you can delete at once to 5 in order to prevent API spam --- bot/exts/utils/reminders.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 51fdd7873..4f7e1c255 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -465,9 +465,13 @@ class Reminders(Cog): @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, ids: Greedy[int]) -> None: - """Delete one of your active reminders.""" + """Delete up to (and including) 5 of your active reminders.""" + if len(ids) > 5: + await send_denial(ctx, "You can only delete a maximum of 5 reminders at once.") + return + deleted_ids = [] - for id_ in ids: + for id_ in set(ids): try: reminder_deleted = await self._delete_reminder(ctx, id_) except LockedResourceError: -- cgit v1.2.3 From 4dc9a952a71d03959e302434e8e9941e9bd3b577 Mon Sep 17 00:00:00 2001 From: Hassan Abouelela Date: Sun, 18 Sep 2022 02:07:12 +0400 Subject: Use Python Poetry Base Action (#2277) --- Dockerfile | 24 +++--------------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index 65ca8ce51..205b66209 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,29 +1,11 @@ -FROM --platform=linux/amd64 python:3.10-slim +FROM --platform=linux/amd64 ghcr.io/chrislovering/python-poetry-base:3.10-slim # Define Git SHA build argument for sentry ARG git_sha="development" - -# POETRY_VIRTUALENVS_IN_PROJECT is required to ensure in-projects venvs mounted from the host in dev -# don't get prioritised by `poetry run` -ENV POETRY_VERSION=1.2.0 \ - POETRY_HOME="/opt/poetry/home" \ - POETRY_CACHE_DIR="/opt/poetry/cache" \ - POETRY_NO_INTERACTION=1 \ - POETRY_VIRTUALENVS_IN_PROJECT=false \ - APP_DIR="/bot" \ - GIT_SHA=$git_sha - -ENV PATH="$POETRY_HOME/bin:$PATH" - -RUN apt-get update \ - && apt-get -y upgrade \ - && apt-get install --no-install-recommends -y curl \ - && apt-get clean && rm -rf /var/lib/apt/lists/* - -RUN curl -sSL https://install.python-poetry.org | python +ENV GIT_SHA=$git_sha # Install project dependencies -WORKDIR $APP_DIR +WORKDIR /bot COPY pyproject.toml poetry.lock ./ RUN poetry install --without dev -- cgit v1.2.3 From e883168727b74fe754f51d16d8742262daccddc6 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 15:21:57 +0100 Subject: stop matching as soon as an invalid kw is encountered --- bot/exts/info/information.py | 33 ++++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 42385edfb..1ba8cf87e 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -546,33 +546,37 @@ class Information(Cog): return full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) + available_keywords = set() + + for _, rule_keywords in full_rules: + available_keywords = available_keywords | set(rule_keywords) # Remove duplicates and sort the rule indices rule_numbers = sorted(set(rule_numbers)) - # Remove duplicate keywords - keywords = set(keywords) + # Remove duplicate keywords and preserve the order of initial keywords + keywords = list(dict.fromkeys(keywords)) invalid = ", ".join(str(index) for index in rule_numbers if index < 1 or index > len(full_rules)) - if invalid and not keywords: + if invalid: await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) return - final_rules = set() - - for pick in rule_numbers: - self.bot.stats.incr(f"rule_uses.{pick}") - final_rules.add(f"**{pick}.** {full_rules[pick - 1][0]}") + final_rule_numbers = [] + final_rule_numbers.extend(rule_numbers) for keyword in keywords: + if keyword not in available_keywords: + break + for pick, rule in enumerate(full_rules): if keyword not in rule[1]: continue - self.bot.stats.incr(f"rule_uses.{pick + 1}") - final_rules.add(f"**{pick + 1}.** {full_rules[pick][0]}") + final_rule_numbers.append(pick + 1) + break - if not rule_numbers and not final_rules: + if not rule_numbers and not final_rule_numbers: # This would mean that only keywords where used and no match for them was found await ctx.send( f"There are currently no rules that correspond to keywords: **{', '.join(keywords)}**.\n" @@ -580,6 +584,13 @@ class Information(Cog): f"<#{constants.Channels.meta}> or <#{constants.Channels.dev_contrib}>") return + for rule_number in final_rule_numbers: + self.bot.stats.incr(f"rule_uses.{rule_number}") + + final_rule_numbers.sort() + final_rule_numbers = set(final_rule_numbers) + final_rules = [f"**{rule_number}.** {full_rules[rule_number - 1][0]}" for rule_number in final_rule_numbers] + await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From c17ca61dcbe63b6f458e31c7d113de4a48cc3b45 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 15:41:27 +0100 Subject: stop matching keywords when they're invalid upon triaging the rule numbers & keywords --- bot/exts/info/information.py | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 1ba8cf87e..cd1f0f957 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -528,11 +528,19 @@ class Information(Cog): rules_embed = Embed(title="Rules", color=Colour.og_blurple(), url="https://www.pythondiscord.com/pages/rules") keywords, rule_numbers = [], [] + full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) + available_keywords = set() + + for _, rule_keywords in full_rules: + available_keywords = available_keywords | set(rule_keywords) + for word in args: try: rule_numbers.append(int(word)) except ValueError: - keywords.append(word.lower()) + if (kw := word.lower()) not in available_keywords: + break + keywords.append(kw) if not rule_numbers and not keywords: # Neither rules nor keywords were submitted. Return the default description. @@ -545,12 +553,6 @@ class Information(Cog): await ctx.send(embed=rules_embed) return - full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) - available_keywords = set() - - for _, rule_keywords in full_rules: - available_keywords = available_keywords | set(rule_keywords) - # Remove duplicates and sort the rule indices rule_numbers = sorted(set(rule_numbers)) # Remove duplicate keywords and preserve the order of initial keywords @@ -566,15 +568,10 @@ class Information(Cog): final_rule_numbers.extend(rule_numbers) for keyword in keywords: - if keyword not in available_keywords: - break - for pick, rule in enumerate(full_rules): - if keyword not in rule[1]: - continue - - final_rule_numbers.append(pick + 1) - break + if keyword in rule[1]: + final_rule_numbers.append(pick + 1) + break if not rule_numbers and not final_rule_numbers: # This would mean that only keywords where used and no match for them was found @@ -584,12 +581,13 @@ class Information(Cog): f"<#{constants.Channels.meta}> or <#{constants.Channels.dev_contrib}>") return - for rule_number in final_rule_numbers: - self.bot.stats.incr(f"rule_uses.{rule_number}") - + final_rules = [] final_rule_numbers.sort() final_rule_numbers = set(final_rule_numbers) - final_rules = [f"**{rule_number}.** {full_rules[rule_number - 1][0]}" for rule_number in final_rule_numbers] + + for rule_number in final_rule_numbers: + self.bot.stats.incr(f"rule_uses.{rule_number}") + final_rules.append(f"**{rule_number}.** {full_rules[rule_number - 1][0]}") await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) -- cgit v1.2.3 From 3119585850d01957d074862d25d01b78eb7fc438 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 16:11:10 +0100 Subject: rename index to rule_number --- bot/exts/info/information.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index cd1f0f957..52cc79aa0 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -558,7 +558,9 @@ class Information(Cog): # Remove duplicate keywords and preserve the order of initial keywords keywords = list(dict.fromkeys(keywords)) - invalid = ", ".join(str(index) for index in rule_numbers if index < 1 or index > len(full_rules)) + invalid = ", ".join( + str(rule_number) for rule_number in rule_numbers + if rule_number < 1 or rule_number > len(full_rules)) if invalid: await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) -- cgit v1.2.3 From 144c036b6448cc77940e6c071728191b5c705cbb Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 17:58:23 +0100 Subject: rename pick to rule_number --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 52cc79aa0..c9ea025ed 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -570,9 +570,9 @@ class Information(Cog): final_rule_numbers.extend(rule_numbers) for keyword in keywords: - for pick, rule in enumerate(full_rules): + for rule_number, rule in enumerate(full_rules): if keyword in rule[1]: - final_rule_numbers.append(pick + 1) + final_rule_numbers.append(rule_number + 1) break if not rule_numbers and not final_rule_numbers: -- cgit v1.2.3 From 4d33f8ee7fcac5ffbc6957da5ac103268fcb30e3 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 18:01:44 +0100 Subject: remove unreachable code --- bot/exts/info/information.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index c9ea025ed..a0674f758 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -575,14 +575,6 @@ class Information(Cog): final_rule_numbers.append(rule_number + 1) break - if not rule_numbers and not final_rule_numbers: - # This would mean that only keywords where used and no match for them was found - await ctx.send( - f"There are currently no rules that correspond to keywords: **{', '.join(keywords)}**.\n" - f"If you think it should be added, please suggest it in either " - f"<#{constants.Channels.meta}> or <#{constants.Channels.dev_contrib}>") - return - final_rules = [] final_rule_numbers.sort() final_rule_numbers = set(final_rule_numbers) -- cgit v1.2.3 From d0dcceefd680629a2f5f9ff956ad7241727421a2 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 18:04:16 +0100 Subject: remove duplicate final rule numbers then sort --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a0674f758..cbee1a519 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -576,8 +576,8 @@ class Information(Cog): break final_rules = [] - final_rule_numbers.sort() final_rule_numbers = set(final_rule_numbers) + final_rule_numbers = sorted(list(final_rule_numbers)) for rule_number in final_rule_numbers: self.bot.stats.incr(f"rule_uses.{rule_number}") -- cgit v1.2.3 From 3fd28f44eb8857c6409e739eab9cdc307d1febe9 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 18:07:24 +0100 Subject: remove useless initial sorting of keywords --- bot/exts/info/information.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index cbee1a519..b453a87cb 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -555,8 +555,6 @@ class Information(Cog): # Remove duplicates and sort the rule indices rule_numbers = sorted(set(rule_numbers)) - # Remove duplicate keywords and preserve the order of initial keywords - keywords = list(dict.fromkeys(keywords)) invalid = ", ".join( str(rule_number) for rule_number in rule_numbers -- cgit v1.2.3 From 9a867416c10d1d7543574bcb4642f645c6f98b29 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 18:11:45 +0100 Subject: enumerate full_rules with a start index of 1 --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index b453a87cb..e09083f90 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -568,9 +568,9 @@ class Information(Cog): final_rule_numbers.extend(rule_numbers) for keyword in keywords: - for rule_number, rule in enumerate(full_rules): + for rule_number, rule in enumerate(full_rules, start=1): if keyword in rule[1]: - final_rule_numbers.append(rule_number + 1) + final_rule_numbers.append(rule_number) break final_rules = [] -- cgit v1.2.3 From 44d1e790681f9430836694273f93a30994643a9b Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 18:30:46 +0100 Subject: replace the keywords set with a dict that maps each keyword to its rule number --- bot/exts/info/information.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index e09083f90..315d93f1d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -529,16 +529,17 @@ class Information(Cog): keywords, rule_numbers = [], [] full_rules = await self.bot.api_client.get("rules", params={"link_format": "md"}) - available_keywords = set() + keyword_to_rule_number = dict() - for _, rule_keywords in full_rules: - available_keywords = available_keywords | set(rule_keywords) + for rule_number, (_, rule_keywords) in enumerate(full_rules, start=1): + for rule_keyword in rule_keywords: + keyword_to_rule_number[rule_keyword] = rule_number for word in args: try: rule_numbers.append(int(word)) except ValueError: - if (kw := word.lower()) not in available_keywords: + if (kw := word.lower()) not in keyword_to_rule_number: break keywords.append(kw) @@ -568,10 +569,7 @@ class Information(Cog): final_rule_numbers.extend(rule_numbers) for keyword in keywords: - for rule_number, rule in enumerate(full_rules, start=1): - if keyword in rule[1]: - final_rule_numbers.append(rule_number) - break + final_rule_numbers.append(keyword_to_rule_number.get(keyword)) final_rules = [] final_rule_numbers = set(final_rule_numbers) -- cgit v1.2.3 From f65b49497fb402a732b6d723b5f2c1f8fd2f983a Mon Sep 17 00:00:00 2001 From: Ionite Date: Sun, 18 Sep 2022 15:36:42 -0400 Subject: Moved `escape_markdown` after Truthy check (#2279) --- bot/exts/info/pypi.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/pypi.py b/bot/exts/info/pypi.py index 2d387df3d..bac7d2389 100644 --- a/bot/exts/info/pypi.py +++ b/bot/exts/info/pypi.py @@ -54,11 +54,12 @@ class PyPi(Cog): embed.url = info["package_url"] embed.colour = next(PYPI_COLOURS) - summary = escape_markdown(info["summary"]) + # Summary can be None if not provided by the package + summary: str | None = info["summary"] # Summary could be completely empty, or just whitespace. if summary and not summary.isspace(): - embed.description = summary + embed.description = escape_markdown(summary) else: embed.description = "No summary provided." -- cgit v1.2.3 From f95e4164406c76d2d0290cd05a2811ca875b3e23 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 21:56:46 +0100 Subject: remove redundant use of list transformation when sorting final rule numbers --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 315d93f1d..7034ca596 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -573,7 +573,7 @@ class Information(Cog): final_rules = [] final_rule_numbers = set(final_rule_numbers) - final_rule_numbers = sorted(list(final_rule_numbers)) + final_rule_numbers = sorted(final_rule_numbers) for rule_number in final_rule_numbers: self.bot.stats.incr(f"rule_uses.{rule_number}") -- cgit v1.2.3 From 032ef6512a30bb38193a9c4d01682ce67ac97697 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 22:07:18 +0100 Subject: subscribe directly to the keyword_to_rule_number dict instead of using .get --- bot/exts/info/information.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 7034ca596..ad5230a9c 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -566,10 +566,11 @@ class Information(Cog): return final_rule_numbers = [] + final_rule_numbers.extend(rule_numbers) for keyword in keywords: - final_rule_numbers.append(keyword_to_rule_number.get(keyword)) + final_rule_numbers.append(keyword_to_rule_number[keyword]) final_rules = [] final_rule_numbers = set(final_rule_numbers) -- cgit v1.2.3 From 987871da795bdfaf879f3fca5ce939761791296b Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Sun, 18 Sep 2022 22:11:02 +0100 Subject: determine final_rule_numbers value by subscribing to the keyword_to_rule_number using the matched keywords --- bot/exts/info/information.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index ad5230a9c..29b7b6fa3 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -565,16 +565,9 @@ class Information(Cog): await ctx.send(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ...")) return - final_rule_numbers = [] - - final_rule_numbers.extend(rule_numbers) - - for keyword in keywords: - final_rule_numbers.append(keyword_to_rule_number[keyword]) - final_rules = [] - final_rule_numbers = set(final_rule_numbers) - final_rule_numbers = sorted(final_rule_numbers) + final_rule_numbers = {keyword_to_rule_number[keyword] for keyword in keywords} + final_rule_numbers.update(rule_numbers) for rule_number in final_rule_numbers: self.bot.stats.incr(f"rule_uses.{rule_number}") -- cgit v1.2.3 From ae6b500ec4724a1f7e959b2b8a1eae2074407cee Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 19 Sep 2022 13:03:03 +0100 Subject: sort the list of final rule numbers --- bot/exts/info/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 29b7b6fa3..7e27ed31c 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -569,7 +569,7 @@ class Information(Cog): final_rule_numbers = {keyword_to_rule_number[keyword] for keyword in keywords} final_rule_numbers.update(rule_numbers) - for rule_number in final_rule_numbers: + for rule_number in sorted(final_rule_numbers): self.bot.stats.incr(f"rule_uses.{rule_number}") final_rules.append(f"**{rule_number}.** {full_rules[rule_number - 1][0]}") -- cgit v1.2.3 From a7663c56c360e5f6799edce67265f0d6415c8be4 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 19 Sep 2022 16:46:54 +0100 Subject: return final list of rule numbers to be sent --- bot/exts/info/information.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 7e27ed31c..58f0764d8 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,7 @@ import pprint import textwrap from collections import defaultdict from textwrap import shorten -from typing import Any, DefaultDict, Mapping, Optional, Tuple, Union +from typing import Any, DefaultDict, List, Mapping, Optional, Tuple, Union import rapidfuzz from botcore.site_api import ResponseCodeError @@ -518,7 +518,7 @@ class Information(Cog): await self.send_raw_content(ctx, message, json=True) @command(aliases=("rule",)) - async def rules(self, ctx: Context, *args: Optional[str]) -> None: + async def rules(self, ctx: Context, *args: Optional[str]) -> Optional[List[int]]: """ Provides a link to all rules or, if specified, displays specific rule(s). @@ -575,6 +575,8 @@ class Information(Cog): await LinePaginator.paginate(final_rules, ctx, rules_embed, max_lines=3) + return final_rule_numbers + async def setup(bot: Bot) -> None: """Load the Information cog.""" -- cgit v1.2.3 From 738f2ee0e8e321d01e867a1d3f963d6991e6f22a Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 19 Sep 2022 16:50:30 +0100 Subject: add test that checks for the sent content if one invalid index is present in the input --- tests/bot/exts/info/test_information.py | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index d896b7652..c14453bd8 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -2,6 +2,7 @@ import textwrap import unittest import unittest.mock from datetime import datetime +from textwrap import shorten import discord @@ -573,3 +574,48 @@ class UserCommandTests(unittest.IsolatedAsyncioTestCase): create_embed.assert_called_once_with(ctx, self.target, False) ctx.send.assert_called_once() + + +class RuleCommandTests(unittest.IsolatedAsyncioTestCase): + """Tests for the `!rule` command.""" + + def setUp(self) -> None: + """Set up steps executed before each test is run.""" + self.bot = helpers.MockBot() + self.cog = information.Information(self.bot) + self.ctx = helpers.MockContext(author=helpers.MockMember(id=1, name="Bellaluma")) + self.full_rules = [ + ( + "First rule", + ["first", "number_one"] + ), + ( + "Second rule", + ["second", "number_two"] + ), + ( + "Third rule", + ["third", "number_three"] + ) + ] + self.bot.api_client.get.return_value = self.full_rules + + async def test_return_none_if_one_rule_number_is_invalid(self): + + test_cases = [ + (('1', '6', '7', '8'), (6, 7, 8)), + (('10', "first"), (10, )), + (("first", 10), (10, )) + ] + + for raw_user_input, extracted_rule_numbers in test_cases: + invalid = ", ".join( + str(rule_number) for rule_number in extracted_rule_numbers + if rule_number < 1 or rule_number > len(self.full_rules)) + + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) + + self.assertEqual( + self.ctx.send.call_args, + unittest.mock.call(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ..."))) + self.assertEqual(None, final_rule_numbers) -- cgit v1.2.3 From 511724a704b2ef87dad8c24006cb7250aa964b35 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 19 Sep 2022 17:20:15 +0100 Subject: fix wrong type hint of the rules function return value --- bot/exts/info/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 58f0764d8..d4f0ce008 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -3,7 +3,7 @@ import pprint import textwrap from collections import defaultdict from textwrap import shorten -from typing import Any, DefaultDict, List, Mapping, Optional, Tuple, Union +from typing import Any, DefaultDict, Mapping, Optional, Set, Tuple, Union import rapidfuzz from botcore.site_api import ResponseCodeError @@ -518,7 +518,7 @@ class Information(Cog): await self.send_raw_content(ctx, message, json=True) @command(aliases=("rule",)) - async def rules(self, ctx: Context, *args: Optional[str]) -> Optional[List[int]]: + async def rules(self, ctx: Context, *args: Optional[str]) -> Optional[Set[int]]: """ Provides a link to all rules or, if specified, displays specific rule(s). -- cgit v1.2.3 From 45b27ed9ac6a1ca55547defd1922fafcc7ac1ab9 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 19 Sep 2022 17:32:47 +0100 Subject: test the cases where default rules message is supposed to be sent --- tests/bot/exts/info/test_information.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index c14453bd8..61e8895b9 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -619,3 +619,34 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.call_args, unittest.mock.call(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ..."))) self.assertEqual(None, final_rule_numbers) + + async def test_return_correct_rule_numberstest_return_correct_rule_numbers(self): + + test_cases = [ + (("1", "2", "first"), {1, 2}), + (("1", "hello", "2", "second"), {1}), + (("second", "third", "unknown", "999"), {2, 3}) + ] + + for raw_user_input, expected_matched_rule_numbers in test_cases: + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) + self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) + + async def test_return_default_rules_when_no_input_or_no_match_are_found(self): + test_cases = [ + ((), None), + (("hello", "2", "second"), None), + (("hello", "999"), None), + ] + + description = ( + "The rules and guidelines that apply to this community can be found on" + " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" + " all members of the community to have read and understood these." + ) + + for raw_user_input, expected_matched_rule_numbers in test_cases: + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) + embed = self.ctx.send.call_args.kwargs['embed'] + self.assertEqual(description, embed.description) + self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) -- cgit v1.2.3 From 2e25073f807e02a72fe4cf2bbfa5cefe5024c8d7 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 19 Sep 2022 20:23:33 +0100 Subject: fix redundant test name --- tests/bot/exts/info/test_information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 61e8895b9..626c12c86 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -620,7 +620,7 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase): unittest.mock.call(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ..."))) self.assertEqual(None, final_rule_numbers) - async def test_return_correct_rule_numberstest_return_correct_rule_numbers(self): + async def test_return_correct_rule_numbers(self): test_cases = [ (("1", "2", "first"), {1, 2}), -- cgit v1.2.3 From ae0c94b1d4dc676519301fe67a21425e5649effe Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 19 Sep 2022 22:57:54 +0100 Subject: add DEFAULT_RULES_DESCRIPTION to avoid duplication --- bot/constants.py | 6 ++++++ bot/exts/info/information.py | 8 ++------ tests/bot/exts/info/test_information.py | 9 ++------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 68a96876f..b1d392822 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -774,3 +774,9 @@ ERROR_REPLIES = [ "Noooooo!!", "I can't believe you've done this", ] + +DEFAULT_RULES_DESCRIPTION = ( + "The rules and guidelines that apply to this community can be found on" + " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" + " all members of the community to have read and understood these." +) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index d4f0ce008..a4816450f 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -13,6 +13,7 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot +from bot.constants import DEFAULT_RULES_DESCRIPTION from bot.converters import MemberOrUser from bot.decorators import in_whitelist from bot.errors import NonExistentRoleError @@ -545,12 +546,7 @@ class Information(Cog): if not rule_numbers and not keywords: # Neither rules nor keywords were submitted. Return the default description. - rules_embed.description = ( - "The rules and guidelines that apply to this community can be found on" - " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" - " all members of the community to have read and understood these." - ) - + rules_embed.description = DEFAULT_RULES_DESCRIPTION await ctx.send(embed=rules_embed) return diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 626c12c86..c0cfac430 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -7,6 +7,7 @@ from textwrap import shorten import discord from bot import constants +from bot.constants import DEFAULT_RULES_DESCRIPTION from bot.exts.info import information from bot.utils.checks import InWhitelistCheckFailure from tests import helpers @@ -639,14 +640,8 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase): (("hello", "999"), None), ] - description = ( - "The rules and guidelines that apply to this community can be found on" - " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" - " all members of the community to have read and understood these." - ) - for raw_user_input, expected_matched_rule_numbers in test_cases: final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) embed = self.ctx.send.call_args.kwargs['embed'] - self.assertEqual(description, embed.description) + self.assertEqual(DEFAULT_RULES_DESCRIPTION, embed.description) self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) -- cgit v1.2.3 From 06b2cde5e4cf7f5a9b914e4f4d1599d949773ecc Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 19 Sep 2022 23:01:01 +0100 Subject: use subTest to isolate assertions --- tests/bot/exts/info/test_information.py | 31 +++++++++++++++++-------------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index c0cfac430..157c7141b 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -610,16 +610,17 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase): ] for raw_user_input, extracted_rule_numbers in test_cases: - invalid = ", ".join( - str(rule_number) for rule_number in extracted_rule_numbers - if rule_number < 1 or rule_number > len(self.full_rules)) + with self.subTest(identifier=raw_user_input): + invalid = ", ".join( + str(rule_number) for rule_number in extracted_rule_numbers + if rule_number < 1 or rule_number > len(self.full_rules)) - final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) - self.assertEqual( - self.ctx.send.call_args, - unittest.mock.call(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ..."))) - self.assertEqual(None, final_rule_numbers) + self.assertEqual( + self.ctx.send.call_args, + unittest.mock.call(shorten(":x: Invalid rule indices: " + invalid, 75, placeholder=" ..."))) + self.assertEqual(None, final_rule_numbers) async def test_return_correct_rule_numbers(self): @@ -630,8 +631,9 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase): ] for raw_user_input, expected_matched_rule_numbers in test_cases: - final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) - self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) + with self.subTest(identifier=raw_user_input): + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) + self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) async def test_return_default_rules_when_no_input_or_no_match_are_found(self): test_cases = [ @@ -641,7 +643,8 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase): ] for raw_user_input, expected_matched_rule_numbers in test_cases: - final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) - embed = self.ctx.send.call_args.kwargs['embed'] - self.assertEqual(DEFAULT_RULES_DESCRIPTION, embed.description) - self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) + with self.subTest(identifier=raw_user_input): + final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) + embed = self.ctx.send.call_args.kwargs['embed'] + self.assertEqual(DEFAULT_RULES_DESCRIPTION, embed.description) + self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) -- cgit v1.2.3 From edc9c4089d2f7f7cb70b813b3cfdcf52ab834714 Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Tue, 20 Sep 2022 08:36:29 +0100 Subject: move DEFAULT_RULES_DESCRIPTION under information.py --- bot/constants.py | 6 ------ bot/exts/info/information.py | 7 ++++++- tests/bot/exts/info/test_information.py | 3 +-- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index b1d392822..68a96876f 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -774,9 +774,3 @@ ERROR_REPLIES = [ "Noooooo!!", "I can't believe you've done this", ] - -DEFAULT_RULES_DESCRIPTION = ( - "The rules and guidelines that apply to this community can be found on" - " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" - " all members of the community to have read and understood these." -) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index a4816450f..2592e093d 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -13,7 +13,6 @@ from discord.utils import escape_markdown from bot import constants from bot.bot import Bot -from bot.constants import DEFAULT_RULES_DESCRIPTION from bot.converters import MemberOrUser from bot.decorators import in_whitelist from bot.errors import NonExistentRoleError @@ -26,6 +25,12 @@ from bot.utils.members import get_or_fetch_member log = get_logger(__name__) +DEFAULT_RULES_DESCRIPTION = ( + "The rules and guidelines that apply to this community can be found on" + " our [rules page](https://www.pythondiscord.com/pages/rules). We expect" + " all members of the community to have read and understood these." +) + class Information(Cog): """A cog with commands for generating embeds with server info, such as server stats and user info.""" diff --git a/tests/bot/exts/info/test_information.py b/tests/bot/exts/info/test_information.py index 157c7141b..9f5143c01 100644 --- a/tests/bot/exts/info/test_information.py +++ b/tests/bot/exts/info/test_information.py @@ -7,7 +7,6 @@ from textwrap import shorten import discord from bot import constants -from bot.constants import DEFAULT_RULES_DESCRIPTION from bot.exts.info import information from bot.utils.checks import InWhitelistCheckFailure from tests import helpers @@ -646,5 +645,5 @@ class RuleCommandTests(unittest.IsolatedAsyncioTestCase): with self.subTest(identifier=raw_user_input): final_rule_numbers = await self.cog.rules(self.cog, self.ctx, *raw_user_input) embed = self.ctx.send.call_args.kwargs['embed'] - self.assertEqual(DEFAULT_RULES_DESCRIPTION, embed.description) + self.assertEqual(information.DEFAULT_RULES_DESCRIPTION, embed.description) self.assertEqual(expected_matched_rule_numbers, final_rule_numbers) -- cgit v1.2.3 From 6fd9bae6da0986cd28e33dc0ef30599295c61835 Mon Sep 17 00:00:00 2001 From: Izan Date: Tue, 20 Sep 2022 22:51:40 +0100 Subject: Display mention & str of the mentionable object in `!remind list`. Added so that the user still gets information on what the mention is, when the mention doesn't render (due to client cache etc.) --- bot/exts/utils/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/reminders.py b/bot/exts/utils/reminders.py index 4f7e1c255..a7bcc7dae 100644 --- a/bot/exts/utils/reminders.py +++ b/bot/exts/utils/reminders.py @@ -353,7 +353,7 @@ class Reminders(Cog): mentions = ", ".join([ # Both Role and User objects have the `mention` attribute - mentionable.mention async for mentionable in self.get_mentionables(mentions) + f"{mentionable.mention} ({mentionable})" async for mentionable in self.get_mentionables(mentions) ]) mention_string = f"\n**Mentions:** {mentions}" if mentions else "" -- cgit v1.2.3 From 4697076a8de1b2ffd554e959414fe9ac9f3af60c Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 1 Sep 2022 18:16:10 +0100 Subject: Send filter alerts to #filter-log instead of #mod-meta This is to reduce the amount of spam in #mod-meta. --- bot/constants.py | 1 + bot/exts/filters/filter_lists.py | 4 ++-- config-default.yml | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 68a96876f..830ba049c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -420,6 +420,7 @@ class Channels(metaclass=YAMLGetter): how_to_get_help: int attachment_log: int + filter_log: int message_log: int mod_log: int nomination_archive: int diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index c643f9a84..8cb83c19f 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -117,9 +117,9 @@ class FilterLists(Cog): ) raise - # If it is an autoban trigger we send a warning in #mod-meta + # If it is an autoban trigger we send a warning in #filter-log if comment and "[autoban]" in comment: - await self.bot.get_channel(Channels.mod_meta).send( + await self.bot.get_channel(Channels.filter_log).send( f":warning: Heads-up! The new `{list_type}` filter " f"`{content}` (`{comment}`) will automatically ban users." ) diff --git a/config-default.yml b/config-default.yml index a12b680e1..e84a6045b 100644 --- a/config-default.yml +++ b/config-default.yml @@ -178,6 +178,7 @@ guild: # Logs attachment_log: &ATTACH_LOG 649243850006855680 + filter_log: &FILTER_LOG 1014943924185473094 message_log: &MESSAGE_LOG 467752170159079424 mod_log: &MOD_LOG 282638479504965634 nomination_archive: 833371042046148738 @@ -257,6 +258,7 @@ guild: - *MESSAGE_LOG - *MOD_LOG - *STAFF_VOICE + - *FILTER_LOG reminder_whitelist: - *BOT_CMD -- cgit v1.2.3 From fe86fb97e2206840927fa2642d5d512e98f4f025 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 1 Sep 2022 20:56:42 +0100 Subject: Namespace discord import in filter_lists --- bot/exts/filters/filter_lists.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index 8cb83c19f..808307e39 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -1,13 +1,13 @@ import re from typing import Optional +import discord from botcore.site_api import ResponseCodeError -from discord import Colour, Embed from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group, has_any_role from bot import constants from bot.bot import Bot -from bot.constants import Channels +from bot.constants import Channels, Colours from bot.converters import ValidDiscordServerInvite, ValidFilterListType from bot.log import get_logger from bot.pagination import LinePaginator @@ -179,9 +179,9 @@ class FilterLists(Cog): # Build the embed list_type_plural = list_type.lower().replace("_", " ").title() + "s" - embed = Embed( + embed = discord.Embed( title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", - colour=Colour.blue() + colour=Colours.blue ) log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") -- cgit v1.2.3 From 4f56106a07039517660e24261352d5cd1ac91481 Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Thu, 1 Sep 2022 20:57:58 +0100 Subject: Report on all autoban filters added in the last 7 days This adds both a command to runt he report, and schedules a report to be automatically once a week. --- bot/exts/filters/filter_lists.py | 56 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index 808307e39..2d8698fcc 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -1,9 +1,13 @@ +import datetime import re +from collections import defaultdict from typing import Optional +import arrow import discord from botcore.site_api import ResponseCodeError -from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group, has_any_role +from discord.ext import tasks +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, command, group, has_any_role from bot import constants from bot.bot import Bot @@ -13,6 +17,7 @@ from bot.log import get_logger from bot.pagination import LinePaginator log = get_logger(__name__) +WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday class FilterLists(Cog): @@ -33,6 +38,7 @@ class FilterLists(Cog): async def cog_load(self) -> None: """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" await self.bot.wait_until_guild_available() + self.weekly_autoban_report_task.start() # Add valid filterlist types to the docstrings valid_types = await ValidFilterListType.get_valid_types(self.bot) @@ -287,10 +293,58 @@ class FilterLists(Cog): """Syncs both allowlists and denylists with the API.""" await self._sync_data(ctx) + @command(name="filter_report") + async def force_send_weekly_report(self, ctx: Context) -> None: + """Send a list of autobans added in the last 7 days to #mod-meta.""" + await self.send_weekly_autoban_report() + + @tasks.loop(time=datetime.time(hour=18)) + async def weekly_autoban_report_task(self) -> None: + """Trigger autoban report to be sent if it is the desired day of the week (WEEKLY_REPORT_ISO_DAY).""" + if arrow.utcnow().isoweekday() != WEEKLY_REPORT_ISO_DAY: + return + + await self.send_weekly_autoban_report() + + async def send_weekly_autoban_report(self, channel: discord.abc.Messageable = None) -> None: + """Send a list of autobans added in the last 7 days to #mod-meta.""" + seven_days_ago = arrow.utcnow().shift(days=-7) + if not channel: + channel = self.bot.get_channel(Channels.mod_meta) + + added_autobans = defaultdict(list) + # Extract all autoban filters added in the past 7 days from each filter type + for filter_list, filters in self.bot.filter_list_cache.items(): + filter_type, allow = filter_list.split(".") + allow_type = "Allow list" if allow.lower() == "true" else "Deny list" + + for filter_content, filter_details in filters.items(): + created_at = arrow.get(filter_details["created_at"]) + updated_at = arrow.get(filter_details["updated_at"]) + # Default to empty string so that the in check below doesn't error on None type + comment = filter_details["comment"] or "" + if max(created_at, updated_at) > seven_days_ago and "[autoban]" in comment: + line = f"`{filter_content}`: {comment}" + added_autobans[f"**{filter_type} {allow_type}**"].append(line) + + # Nicely format the output so each filter list type is grouped + lines = [f"**Autoban filters added since {seven_days_ago.format('YYYY-MM-DD')}**"] + for filter_list, recently_added_autobans in added_autobans.items(): + lines.append("\n".join([filter_list]+recently_added_autobans)) + + if len(lines) == 1: + lines.append("Nothing to show") + + await channel.send("\n\n".join(lines)) + async def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return await has_any_role(*constants.MODERATION_ROLES).predicate(ctx) + async def cog_unload(self) -> None: + """Cancel the weekly autoban filter report on cog unload.""" + self.weekly_autoban_report_task.cancel() + async def setup(bot: Bot) -> None: """Load the FilterLists cog.""" -- cgit v1.2.3 From f2d05d554f012598bcd42b4888400ced6513cb0d Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 11 Sep 2022 01:18:47 +0100 Subject: Send weekly_autoban_report to ctx channel when manually invoked --- bot/exts/filters/filter_lists.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index 2d8698fcc..edc2d09f3 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -295,8 +295,8 @@ class FilterLists(Cog): @command(name="filter_report") async def force_send_weekly_report(self, ctx: Context) -> None: - """Send a list of autobans added in the last 7 days to #mod-meta.""" - await self.send_weekly_autoban_report() + """Respond with a list of autobans added in the last 7 days.""" + await self.send_weekly_autoban_report(ctx.channel) @tasks.loop(time=datetime.time(hour=18)) async def weekly_autoban_report_task(self) -> None: @@ -307,7 +307,11 @@ class FilterLists(Cog): await self.send_weekly_autoban_report() async def send_weekly_autoban_report(self, channel: discord.abc.Messageable = None) -> None: - """Send a list of autobans added in the last 7 days to #mod-meta.""" + """ + Send a list of autobans added in the last 7 days to the specified channel. + + If chanel is not specified, it is sent to #mod-meta. + """ seven_days_ago = arrow.utcnow().shift(days=-7) if not channel: channel = self.bot.get_channel(Channels.mod_meta) -- cgit v1.2.3 From 2142d824272c738c3d4ac9f20dd59cc4f02e345e Mon Sep 17 00:00:00 2001 From: Chris Lovering Date: Sun, 2 Oct 2022 20:19:49 +0100 Subject: Make autoban report silently fail if output will be sent to a non-mod channel --- bot/exts/filters/filter_lists.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/filters/filter_lists.py b/bot/exts/filters/filter_lists.py index edc2d09f3..c429b0eb9 100644 --- a/bot/exts/filters/filter_lists.py +++ b/bot/exts/filters/filter_lists.py @@ -15,6 +15,7 @@ from bot.constants import Channels, Colours from bot.converters import ValidDiscordServerInvite, ValidFilterListType from bot.log import get_logger from bot.pagination import LinePaginator +from bot.utils.channel import is_mod_channel log = get_logger(__name__) WEEKLY_REPORT_ISO_DAY = 3 # 1=Monday, 7=Sunday @@ -315,6 +316,9 @@ class FilterLists(Cog): seven_days_ago = arrow.utcnow().shift(days=-7) if not channel: channel = self.bot.get_channel(Channels.mod_meta) + elif not is_mod_channel(channel): + # Silently fail if output is going to be a non-mod channel. + return added_autobans = defaultdict(list) # Extract all autoban filters added in the past 7 days from each filter type -- cgit v1.2.3 From 452da1a06287d964acb17d43db9bce3f7090eebf Mon Sep 17 00:00:00 2001 From: Amrou Bellalouna Date: Mon, 3 Oct 2022 16:45:50 +0100 Subject: await unawaited remove_cog calls --- bot/exts/filters/antispam.py | 2 +- bot/exts/help_channels/_cog.py | 2 +- bot/exts/moderation/watchchannels/_watchchannel.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 842aab384..b4e7a33f0 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -156,7 +156,7 @@ class AntiSpam(Cog): colour=Colour.red() ) - self.bot.remove_cog(self.__class__.__name__) + await self.bot.remove_cog(self.__class__.__name__) return @Cog.listener() diff --git a/bot/exts/help_channels/_cog.py b/bot/exts/help_channels/_cog.py index f1351013a..31a33f8af 100644 --- a/bot/exts/help_channels/_cog.py +++ b/bot/exts/help_channels/_cog.py @@ -315,7 +315,7 @@ class HelpChannels(commands.Cog): ) except discord.HTTPException: log.exception("Failed to get a category; cog will be removed") - self.bot.remove_cog(self.qualified_name) + await self.bot.remove_cog(self.qualified_name) async def cog_load(self) -> None: """Initialise the help channel system.""" diff --git a/bot/exts/moderation/watchchannels/_watchchannel.py b/bot/exts/moderation/watchchannels/_watchchannel.py index 46f9c296e..6eaedf6b3 100644 --- a/bot/exts/moderation/watchchannels/_watchchannel.py +++ b/bot/exts/moderation/watchchannels/_watchchannel.py @@ -128,7 +128,7 @@ class WatchChannel(metaclass=CogABCMeta): colour=Color.red() ) - self.bot.remove_cog(self.__class__.__name__) + await self.bot.remove_cog(self.__class__.__name__) return if not await self.fetch_user_cache(): -- cgit v1.2.3