From 9b8d688657f7044ad27ff81f7eb7d50f7f593ed6 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Fri, 25 Oct 2019 12:10:26 +0200 Subject: Autodelete offensive messages after one week. If the filter cog filter a message that's considered as offensive (filter["offensive_msg"] is True), the cog create a new offensive message object in the bot db with a delete_date of one week after it was sent. A background task run every day, pull up a list of message to delete, find them back, and delete them. --- bot/cogs/filtering.py | 89 +++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 10 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1d1d74e74..d1d28ac10 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -1,12 +1,15 @@ +import asyncio +import datetime import logging import re from typing import Optional, Union import discord.errors from dateutil.relativedelta import relativedelta -from discord import Colour, DMChannel, Member, Message, TextChannel +from discord import Colour, DMChannel, Member, Message, NotFound, TextChannel from discord.ext.commands import Bot, Cog +from bot.api import ResponseCodeError from bot.cogs.moderation import ModLog from bot.constants import ( Channels, Colours, DEBUG_MODE, @@ -16,13 +19,13 @@ from bot.constants import ( log = logging.getLogger(__name__) INVITE_RE = re.compile( - r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ - r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ + r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ + r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ - r"discord(?:[\.,]|dot)me|" # or discord.me - r"discord(?:[\.,]|dot)io" # or discord.io. - r")(?:[\/]|slash)" # / or 'slash' - r"([a-zA-Z0-9]+)", # the invite code itself + r"discord(?:[\.,]|dot)me|" # or discord.me + r"discord(?:[\.,]|dot)io" # or discord.io. + r")(?:[\/]|slash)" # / or 'slash' + r"([a-zA-Z0-9]+)", # the invite code itself flags=re.IGNORECASE ) @@ -36,6 +39,8 @@ TOKEN_WATCHLIST_PATTERNS = [ re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist ] +OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=7) # Time before an offensive msg is deleted. + class Filtering(Cog): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" @@ -54,7 +59,8 @@ class Filtering(Cog): "notification_msg": ( "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " f"{_staff_mistake_str}" - ) + ), + "offensive_msg": False }, "filter_invites": { "enabled": Filter.filter_invites, @@ -65,7 +71,8 @@ class Filtering(Cog): "notification_msg": ( f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n" r"Our server rules can be found here: " - ) + ), + "offensive_msg": False }, "filter_domains": { "enabled": Filter.filter_domains, @@ -75,28 +82,47 @@ class Filtering(Cog): "user_notification": Filter.notify_user_domains, "notification_msg": ( f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}" - ) + ), + "offensive_msg": False }, "watch_rich_embeds": { "enabled": Filter.watch_rich_embeds, "function": self._has_rich_embed, "type": "watchlist", "content_only": False, + "offensive_msg": False }, "watch_words": { "enabled": Filter.watch_words, "function": self._has_watchlist_words, "type": "watchlist", "content_only": True, + "offensive_msg": True }, "watch_tokens": { "enabled": Filter.watch_tokens, "function": self._has_watchlist_tokens, "type": "watchlist", "content_only": True, + "offensive_msg": True }, } + self.deletion_task = None + self.bot.loop.create_task(self.init_deletion_task()) + + def cog_unload(self) -> None: + """Cancel any running updater tasks on cog unload.""" + if self.deletion_task is not None: + self.deletion_task.cancel() + + async def init_deletion_task(self) -> None: + """Start offensive messages deletion event loop if it hasn't already started.""" + await self.bot.wait_until_ready() + if self.deletion_task is None: + coro = delete_offensive_msg(self.bot) + self.deletion_task = self.bot.loop.create_task(coro) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" @@ -159,6 +185,21 @@ class Filtering(Cog): triggered = await _filter["function"](msg) if triggered: + # If the message is classed as offensive, we store it in the site db and + # it will be deleted it after one week. + if _filter["offensive_msg"]: + delete_date = msg.created_at.date() + OFFENSIVE_MSG_DELETE_TIME + await self.bot.api_client.post( + 'bot/offensive-message', + json={ + 'id': msg.id, + 'channel_id': msg.channel.id, + 'delete_date': delete_date.isoformat() + } + ) + log.trace(f"Offensive message will be deleted on " + f"{delete_date.isoformat()}") + # If this is a filter (not a watchlist), we should delete the message. if _filter["type"] == "filter": try: @@ -360,6 +401,34 @@ class Filtering(Cog): await channel.send(f"{filtered_member.mention} {reason}") +async def delete_offensive_msg(bot: Bot) -> None: + """Background task that pull up a list of offensive messages every day and delete them.""" + while True: + tomorrow = datetime.date.today() + datetime.timedelta(days=1) + time_until_next = datetime.datetime(tomorrow.year, tomorrow.month, tomorrow.day) - datetime.datetime.now() + try: + msg_list = await bot.api_client.get( + 'bot/offensive-message', + params={'delete_date': datetime.date.today().isoformat()} + ) + except ResponseCodeError as e: + log.error(f"Failed to get offending messages to delete (got code {e.response.status}), " + f"retrying in 30 minutes.") + time_until_next = datetime.timedelta(minutes=30) + msg_list = [] + for msg in msg_list: + try: + channel = bot.get_channel(msg['channel_id']) + if channel: + msg_obj = await channel.fetch_message(msg['id']) + await msg_obj.delete() + except NotFound: + log.info(f"Tried to delete message {msg['id']}, but the message can't be found " + f"(it has been probably already deleted).") + log.info(f"Deleted {len(msg_list)} offensive message(s).") + await asyncio.sleep(time_until_next.seconds) + + def setup(bot: Bot) -> None: """Filtering cog load.""" bot.add_cog(Filtering(bot)) -- cgit v1.2.3 From 59914bf3e741d654d999ff5eb7f9c12621628c6b Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Fri, 25 Oct 2019 15:00:20 +0200 Subject: Revert whitespace changes --- bot/cogs/filtering.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index d1d28ac10..4c99be0af 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -19,13 +19,13 @@ from bot.constants import ( log = logging.getLogger(__name__) INVITE_RE = re.compile( - r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ - r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ + r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ + r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ r"discordapp(?:[\.,]|dot)com(?:\/|slash)invite|" # or discordapp.com/invite/ - r"discord(?:[\.,]|dot)me|" # or discord.me - r"discord(?:[\.,]|dot)io" # or discord.io. - r")(?:[\/]|slash)" # / or 'slash' - r"([a-zA-Z0-9]+)", # the invite code itself + r"discord(?:[\.,]|dot)me|" # or discord.me + r"discord(?:[\.,]|dot)io" # or discord.io. + r")(?:[\/]|slash)" # / or 'slash' + r"([a-zA-Z0-9]+)", # the invite code itself flags=re.IGNORECASE ) -- cgit v1.2.3 From 9c78146c79dd7b4c3a633f3eabeef2036eb8ab7f Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Fri, 25 Oct 2019 18:39:58 +0200 Subject: Move offensive message delete time to config file. --- bot/cogs/filtering.py | 2 +- bot/constants.py | 1 + config-default.yml | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4c99be0af..f9aee5a9a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -39,7 +39,7 @@ TOKEN_WATCHLIST_PATTERNS = [ re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist ] -OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=7) # Time before an offensive msg is deleted. +OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=Filter.offensive_msg_delete_time) class Filtering(Cog): diff --git a/bot/constants.py b/bot/constants.py index 4beae84e9..6106d911c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -211,6 +211,7 @@ class Filter(metaclass=YAMLGetter): notify_user_domains: bool ping_everyone: bool + offensive_msg_delete_time: int guild_invite_whitelist: List[int] domain_blacklist: List[str] word_watchlist: List[str] diff --git a/config-default.yml b/config-default.yml index 197743296..fc702e991 100644 --- a/config-default.yml +++ b/config-default.yml @@ -161,7 +161,8 @@ filter: notify_user_domains: false # Filter configuration - ping_everyone: true # Ping @everyone when we send a mod-alert? + ping_everyone: true # Ping @everyone when we send a mod-alert? + offensive_msg_delete_time: 7 # How many days before deleting an offensive message? guild_invite_whitelist: - 280033776820813825 # Functional Programming -- cgit v1.2.3 From f67378c77a45c581c57d1dfdd5a704319e83ba3a Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Fri, 25 Oct 2019 18:43:27 +0200 Subject: Remove the possibility that we send a message to the API that the filter has already deleted. --- bot/cogs/filtering.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index f9aee5a9a..ea6919707 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -185,21 +185,6 @@ class Filtering(Cog): triggered = await _filter["function"](msg) if triggered: - # If the message is classed as offensive, we store it in the site db and - # it will be deleted it after one week. - if _filter["offensive_msg"]: - delete_date = msg.created_at.date() + OFFENSIVE_MSG_DELETE_TIME - await self.bot.api_client.post( - 'bot/offensive-message', - json={ - 'id': msg.id, - 'channel_id': msg.channel.id, - 'delete_date': delete_date.isoformat() - } - ) - log.trace(f"Offensive message will be deleted on " - f"{delete_date.isoformat()}") - # If this is a filter (not a watchlist), we should delete the message. if _filter["type"] == "filter": try: @@ -216,6 +201,21 @@ class Filtering(Cog): except discord.errors.NotFound: return + # If the message is classed as offensive, we store it in the site db and + # it will be deleted it after one week. + if _filter["offensive_msg"]: + delete_date = msg.created_at.date() + OFFENSIVE_MSG_DELETE_TIME + await self.bot.api_client.post( + 'bot/offensive-message', + json={ + 'id': msg.id, + 'channel_id': msg.channel.id, + 'delete_date': delete_date.isoformat() + } + ) + log.trace(f"Offensive message will be deleted on " + f"{delete_date.isoformat()}") + # Notify the user if the filter specifies if _filter["user_notification"]: await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) -- cgit v1.2.3 From 1eb057b229c9dcae15834c55faf7188360b675a2 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Fri, 25 Oct 2019 18:44:50 +0200 Subject: Rename offensive_msg flag to schedule_deletion. --- bot/cogs/filtering.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index ea6919707..1342dade8 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -60,7 +60,7 @@ class Filtering(Cog): "Your post has been removed for abusing Unicode character rendering (aka Zalgo text). " f"{_staff_mistake_str}" ), - "offensive_msg": False + "schedule_deletion": False }, "filter_invites": { "enabled": Filter.filter_invites, @@ -72,7 +72,7 @@ class Filtering(Cog): f"Per Rule 10, your invite link has been removed. {_staff_mistake_str}\n\n" r"Our server rules can be found here: " ), - "offensive_msg": False + "schedule_deletion": False }, "filter_domains": { "enabled": Filter.filter_domains, @@ -83,28 +83,28 @@ class Filtering(Cog): "notification_msg": ( f"Your URL has been removed because it matched a blacklisted domain. {_staff_mistake_str}" ), - "offensive_msg": False + "schedule_deletion": False }, "watch_rich_embeds": { "enabled": Filter.watch_rich_embeds, "function": self._has_rich_embed, "type": "watchlist", "content_only": False, - "offensive_msg": False + "schedule_deletion": False }, "watch_words": { "enabled": Filter.watch_words, "function": self._has_watchlist_words, "type": "watchlist", "content_only": True, - "offensive_msg": True + "schedule_deletion": True }, "watch_tokens": { "enabled": Filter.watch_tokens, "function": self._has_watchlist_tokens, "type": "watchlist", "content_only": True, - "offensive_msg": True + "schedule_deletion": True }, } @@ -203,7 +203,7 @@ class Filtering(Cog): # If the message is classed as offensive, we store it in the site db and # it will be deleted it after one week. - if _filter["offensive_msg"]: + if _filter["schedule_deletion"]: delete_date = msg.created_at.date() + OFFENSIVE_MSG_DELETE_TIME await self.bot.api_client.post( 'bot/offensive-message', -- cgit v1.2.3 From f306c4153a5d4fb969856a2282e7ff3f7a111885 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Fri, 25 Oct 2019 21:16:04 +0200 Subject: Use Scheduler instead of a custom async loop --- bot/cogs/filtering.py | 84 ++++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1342dade8..35c14f101 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -2,19 +2,20 @@ import asyncio import datetime import logging import re -from typing import Optional, Union +from typing import Mapping, Optional, Union import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, NotFound, TextChannel from discord.ext.commands import Bot, Cog -from bot.api import ResponseCodeError from bot.cogs.moderation import ModLog from bot.constants import ( Channels, Colours, DEBUG_MODE, Filter, Icons, URLs ) +from bot.utils.scheduling import Scheduler +from bot.utils.time import wait_until log = logging.getLogger(__name__) @@ -42,11 +43,12 @@ TOKEN_WATCHLIST_PATTERNS = [ OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=Filter.offensive_msg_delete_time) -class Filtering(Cog): +class Filtering(Cog, Scheduler): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" def __init__(self, bot: Bot): self.bot = bot + super().__init__() _staff_mistake_str = "If you believe this was a mistake, please let staff know!" self.filters = { @@ -109,19 +111,7 @@ class Filtering(Cog): } self.deletion_task = None - self.bot.loop.create_task(self.init_deletion_task()) - - def cog_unload(self) -> None: - """Cancel any running updater tasks on cog unload.""" - if self.deletion_task is not None: - self.deletion_task.cancel() - - async def init_deletion_task(self) -> None: - """Start offensive messages deletion event loop if it hasn't already started.""" - await self.bot.wait_until_ready() - if self.deletion_task is None: - coro = delete_offensive_msg(self.bot) - self.deletion_task = self.bot.loop.create_task(coro) + self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) @property def mod_log(self) -> ModLog: @@ -400,33 +390,45 @@ class Filtering(Cog): except discord.errors.Forbidden: await channel.send(f"{filtered_member.mention} {reason}") + async def _scheduled_task(self, msg: dict) -> None: + """A coroutine which delete the offensive message once the delete date is reached.""" + delete_at = datetime.datetime.fromisoformat(msg['delete_date'][:-1]) + + await wait_until(delete_at) + await self.delete_offensive_msg(msg) + + self.cancel_task(msg['id']) + + async def reschedule_offensive_msg_deletion(self) -> None: + """Get all the pending message deletion from the API and reschedule them.""" + await self.bot.wait_until_ready() + response = await self.bot.api_client.get( + 'bot/offensive-message', + ) + + now = datetime.datetime.utcnow() + loop = asyncio.get_event_loop() -async def delete_offensive_msg(bot: Bot) -> None: - """Background task that pull up a list of offensive messages every day and delete them.""" - while True: - tomorrow = datetime.date.today() + datetime.timedelta(days=1) - time_until_next = datetime.datetime(tomorrow.year, tomorrow.month, tomorrow.day) - datetime.datetime.now() + for msg in response: + delete_at = datetime.datetime.fromisoformat(msg['delete_date'][:-1]) + + if delete_at < now: + await self.delete_offensive_msg(msg) + else: + self.schedule_task(loop, msg['id'], msg) + + async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: + """Delete an offensive message, and then delete it from the db.""" try: - msg_list = await bot.api_client.get( - 'bot/offensive-message', - params={'delete_date': datetime.date.today().isoformat()} - ) - except ResponseCodeError as e: - log.error(f"Failed to get offending messages to delete (got code {e.response.status}), " - f"retrying in 30 minutes.") - time_until_next = datetime.timedelta(minutes=30) - msg_list = [] - for msg in msg_list: - try: - channel = bot.get_channel(msg['channel_id']) - if channel: - msg_obj = await channel.fetch_message(msg['id']) - await msg_obj.delete() - except NotFound: - log.info(f"Tried to delete message {msg['id']}, but the message can't be found " - f"(it has been probably already deleted).") - log.info(f"Deleted {len(msg_list)} offensive message(s).") - await asyncio.sleep(time_until_next.seconds) + channel = self.bot.get_channel(msg['channel_id']) + if channel: + msg_obj = await channel.fetch_message(msg['id']) + await msg_obj.delete() + except NotFound: + log.info(f"Tried to delete message {msg['id']}, but the message can't be found " + f"(it has been probably already deleted).") + await self.bot.api_client.delete(f'bot/offensive-message/{msg["id"]}') + log.info(f"Deleted the offensive message with id {msg['id']}.") def setup(bot: Bot) -> None: -- cgit v1.2.3 From 95c6e56891a21ebb2d1555cf850daad375d57afe Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 26 Oct 2019 11:11:15 +0200 Subject: Switch to datetime.datetime --- bot/cogs/filtering.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 5bd72a584..8962a85c1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -187,25 +187,25 @@ class Filtering(Cog, Scheduler): except discord.errors.NotFound: return + # Notify the user if the filter specifies + if _filter["user_notification"]: + await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) + # If the message is classed as offensive, we store it in the site db and # it will be deleted it after one week. if _filter["schedule_deletion"]: - delete_date = msg.created_at.date() + OFFENSIVE_MSG_DELETE_TIME + delete_date = msg.created_at + OFFENSIVE_MSG_DELETE_TIME await self.bot.api_client.post( 'bot/offensive-message', json={ 'id': msg.id, 'channel_id': msg.channel.id, - 'delete_date': delete_date.isoformat() + 'delete_date': delete_date.isoformat()[:-1] } ) log.trace(f"Offensive message will be deleted on " f"{delete_date.isoformat()}") - # Notify the user if the filter specifies - if _filter["user_notification"]: - await self.notify_member(msg.author, _filter["notification_msg"], msg.channel) - if isinstance(msg.channel, DMChannel): channel_str = "via DM" else: -- cgit v1.2.3 From b57811ea12aa41285e4e4585b951dd105be4b275 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 12 Dec 2019 09:25:53 +0100 Subject: Add space for readability Co-Authored-By: Mark --- bot/cogs/filtering.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 61c8f389b..39ba22354 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -436,6 +436,7 @@ class Filtering(Cog, Scheduler): except NotFound: log.info(f"Tried to delete message {msg['id']}, but the message can't be found " f"(it has been probably already deleted).") + await self.bot.api_client.delete(f'bot/offensive-message/{msg["id"]}') log.info(f"Deleted the offensive message with id {msg['id']}.") -- cgit v1.2.3 From bc9b335454d0183013b4f89f2923b638dcc127ba Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Thu, 12 Dec 2019 09:38:26 +0100 Subject: Make use of the Bot subclass --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index ca84e6240..9d88d9153 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -7,7 +7,7 @@ from typing import Mapping, Optional, Union import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, NotFound, TextChannel -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from bot.bot import Bot from bot.cogs.moderation import ModLog -- cgit v1.2.3 From 7eea7ad353239a0be1dfd744486e3f46a99cd661 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 11:21:44 +0100 Subject: Filtering cog clean up --- bot/cogs/filtering.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 9d88d9153..172c5fa7e 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -111,7 +111,6 @@ class Filtering(Cog, Scheduler): }, } - self.deletion_task = None self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) @property @@ -201,11 +200,13 @@ class Filtering(Cog, Scheduler): json={ 'id': msg.id, 'channel_id': msg.channel.id, - 'delete_date': delete_date.isoformat()[:-1] + 'delete_date': delete_date.isoformat() } ) - log.trace(f"Offensive message will be deleted on " - f"{delete_date.isoformat()}") + log.trace( + f"Offensive message will be deleted on " + f"{delete_date.isoformat()}" + ) if isinstance(msg.channel, DMChannel): channel_str = "via DM" @@ -412,9 +413,7 @@ class Filtering(Cog, Scheduler): async def reschedule_offensive_msg_deletion(self) -> None: """Get all the pending message deletion from the API and reschedule them.""" await self.bot.wait_until_ready() - response = await self.bot.api_client.get( - 'bot/offensive-message', - ) + response = await self.bot.api_client.get('bot/offensive-message',) now = datetime.datetime.utcnow() loop = asyncio.get_event_loop() @@ -435,8 +434,10 @@ class Filtering(Cog, Scheduler): msg_obj = await channel.fetch_message(msg['id']) await msg_obj.delete() except NotFound: - log.info(f"Tried to delete message {msg['id']}, but the message can't be found " - f"(it has been probably already deleted).") + log.info( + f"Tried to delete message {msg['id']}, but the message can't be found " + f"(it has been probably already deleted)." + ) await self.bot.api_client.delete(f'bot/offensive-message/{msg["id"]}') log.info(f"Deleted the offensive message with id {msg['id']}.") -- cgit v1.2.3 From ba5af375a3acfc160de1ffefa063a915318e6bdd Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 11:25:59 +0100 Subject: Make use of dateutil.parser.isoparse --- bot/cogs/filtering.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 172c5fa7e..4388b29ad 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -4,6 +4,7 @@ import logging import re from typing import Mapping, Optional, Union +import dateutil import discord.errors from dateutil.relativedelta import relativedelta from discord import Colour, DMChannel, Member, Message, NotFound, TextChannel @@ -403,7 +404,7 @@ class Filtering(Cog, Scheduler): async def _scheduled_task(self, msg: dict) -> None: """A coroutine which delete the offensive message once the delete date is reached.""" - delete_at = datetime.datetime.fromisoformat(msg['delete_date'][:-1]) + delete_at = dateutil.parser.isoparse(msg['delete_date']) await wait_until(delete_at) await self.delete_offensive_msg(msg) @@ -419,7 +420,7 @@ class Filtering(Cog, Scheduler): loop = asyncio.get_event_loop() for msg in response: - delete_at = datetime.datetime.fromisoformat(msg['delete_date'][:-1]) + delete_at = dateutil.parser.isoparse(msg['delete_date']) if delete_at < now: await self.delete_offensive_msg(msg) -- cgit v1.2.3 From e6940b938882aaeda18baa9fcc23cc297c6cfcd2 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 11:31:32 +0100 Subject: Rename config entry to offensive_msg_delete_days --- bot/cogs/filtering.py | 2 +- bot/constants.py | 2 +- config-default.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4388b29ad..c0e115a8f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -42,7 +42,7 @@ TOKEN_WATCHLIST_PATTERNS = [ re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist ] -OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=Filter.offensive_msg_delete_time) +OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=Filter.offensive_msg_delete_day) class Filtering(Cog, Scheduler): diff --git a/bot/constants.py b/bot/constants.py index 075722a01..e6f23ff61 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -211,7 +211,7 @@ class Filter(metaclass=YAMLGetter): notify_user_domains: bool ping_everyone: bool - offensive_msg_delete_time: int + offensive_msg_delete_day: int guild_invite_whitelist: List[int] domain_blacklist: List[str] word_watchlist: List[str] diff --git a/config-default.yml b/config-default.yml index 0765407af..33072790b 100644 --- a/config-default.yml +++ b/config-default.yml @@ -180,7 +180,7 @@ filter: # Filter configuration ping_everyone: true # Ping @everyone when we send a mod-alert? - offensive_msg_delete_time: 7 # How many days before deleting an offensive message? + offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: - 280033776820813825 # Functional Programming -- cgit v1.2.3 From b2a5ab90622e986ace37be6204d6a59823390cce Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 11:36:37 +0100 Subject: Catch all HTTPExecption --- bot/cogs/filtering.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index c0e115a8f..c0a2c7d3b 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -7,7 +7,7 @@ from typing import Mapping, Optional, Union import dateutil import discord.errors from dateutil.relativedelta import relativedelta -from discord import Colour, DMChannel, Member, Message, NotFound, TextChannel +from discord import Colour, DMChannel, HTTPException, Member, Message, NotFound, TextChannel from discord.ext.commands import Cog from bot.bot import Bot @@ -439,6 +439,10 @@ class Filtering(Cog, Scheduler): f"Tried to delete message {msg['id']}, but the message can't be found " f"(it has been probably already deleted)." ) + except HTTPException: + log.warning( + f"Failed to delete message {msg['id']}." + ) await self.bot.api_client.delete(f'bot/offensive-message/{msg["id"]}') log.info(f"Deleted the offensive message with id {msg['id']}.") -- cgit v1.2.3 From ab6b0032ceb6ad638cee6f174778bc38254ab038 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 11:41:30 +0100 Subject: Actually schedule message for deletion --- bot/cogs/filtering.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index c0a2c7d3b..0879bfee6 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -204,6 +204,8 @@ class Filtering(Cog, Scheduler): 'delete_date': delete_date.isoformat() } ) + loop = asyncio.get_event_loop() + self.schedule_task(loop, msg.id, {'id': msg.id, 'channel_id': msg.channel.id}) log.trace( f"Offensive message will be deleted on " f"{delete_date.isoformat()}" -- cgit v1.2.3 From a81ad8f1f7d548f20d1f2428a808e14dbbfe22bc Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 11:42:10 +0100 Subject: Fix docstring typo --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 0879bfee6..2c3f41c05 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -405,7 +405,7 @@ class Filtering(Cog, Scheduler): await channel.send(f"{filtered_member.mention} {reason}") async def _scheduled_task(self, msg: dict) -> None: - """A coroutine which delete the offensive message once the delete date is reached.""" + """A coroutine that delete the offensive message once the delete date is reached.""" delete_at = dateutil.parser.isoparse(msg['delete_date']) await wait_until(delete_at) -- cgit v1.2.3 From 15958986ae6bcb508c4e2dd23c51624d4ee26cb5 Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 11:47:08 +0100 Subject: Rename route /bot/offensive-message to /bot/offensive-messages --- bot/cogs/filtering.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 2c3f41c05..63f8685c9 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -197,7 +197,7 @@ class Filtering(Cog, Scheduler): if _filter["schedule_deletion"]: delete_date = msg.created_at + OFFENSIVE_MSG_DELETE_TIME await self.bot.api_client.post( - 'bot/offensive-message', + 'bot/offensive-messages', json={ 'id': msg.id, 'channel_id': msg.channel.id, @@ -416,7 +416,7 @@ class Filtering(Cog, Scheduler): async def reschedule_offensive_msg_deletion(self) -> None: """Get all the pending message deletion from the API and reschedule them.""" await self.bot.wait_until_ready() - response = await self.bot.api_client.get('bot/offensive-message',) + response = await self.bot.api_client.get('bot/offensive-messages',) now = datetime.datetime.utcnow() loop = asyncio.get_event_loop() @@ -446,7 +446,7 @@ class Filtering(Cog, Scheduler): f"Failed to delete message {msg['id']}." ) - await self.bot.api_client.delete(f'bot/offensive-message/{msg["id"]}') + await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') log.info(f"Deleted the offensive message with id {msg['id']}.") -- cgit v1.2.3 From 2f5b9fbd7bc2a11452a982f61c7603d589ded95a Mon Sep 17 00:00:00 2001 From: Akarys42 Date: Sat, 14 Dec 2019 11:52:54 +0100 Subject: Make setting filter.offensive_msg_delete_days plural --- bot/cogs/filtering.py | 2 +- bot/constants.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 63f8685c9..a709fe7cd 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -42,7 +42,7 @@ TOKEN_WATCHLIST_PATTERNS = [ re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist ] -OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=Filter.offensive_msg_delete_day) +OFFENSIVE_MSG_DELETE_TIME = datetime.timedelta(days=Filter.offensive_msg_delete_days) class Filtering(Cog, Scheduler): diff --git a/bot/constants.py b/bot/constants.py index e6f23ff61..f47688185 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -211,7 +211,7 @@ class Filter(metaclass=YAMLGetter): notify_user_domains: bool ping_everyone: bool - offensive_msg_delete_day: int + offensive_msg_delete_days: int guild_invite_whitelist: List[int] domain_blacklist: List[str] word_watchlist: List[str] -- cgit v1.2.3 From 4cf2166d6dee6a04a055db7e14cf4ece0656213a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Dec 2019 09:39:58 -0800 Subject: Filtering: log the status code of caught HTTPException --- bot/cogs/filtering.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index a709fe7cd..4d91432e7 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -441,10 +441,8 @@ class Filtering(Cog, Scheduler): f"Tried to delete message {msg['id']}, but the message can't be found " f"(it has been probably already deleted)." ) - except HTTPException: - log.warning( - f"Failed to delete message {msg['id']}." - ) + except HTTPException as e: + log.warning(f"Failed to delete message {msg['id']}: status {e.status}") await self.bot.api_client.delete(f'bot/offensive-messages/{msg["id"]}') log.info(f"Deleted the offensive message with id {msg['id']}.") -- cgit v1.2.3 From 7e4a43546490d90c619d08837da69466768fae80 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Dec 2019 09:39:00 -0800 Subject: Filtering: refactor scheduling of deletion task --- bot/cogs/filtering.py | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4d91432e7..4c94b73a5 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -1,4 +1,3 @@ -import asyncio import datetime import logging import re @@ -195,21 +194,19 @@ class Filtering(Cog, Scheduler): # If the message is classed as offensive, we store it in the site db and # it will be deleted it after one week. if _filter["schedule_deletion"]: - delete_date = msg.created_at + OFFENSIVE_MSG_DELETE_TIME + delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() await self.bot.api_client.post( 'bot/offensive-messages', json={ 'id': msg.id, 'channel_id': msg.channel.id, - 'delete_date': delete_date.isoformat() + 'delete_date': delete_date } ) - loop = asyncio.get_event_loop() - self.schedule_task(loop, msg.id, {'id': msg.id, 'channel_id': msg.channel.id}) - log.trace( - f"Offensive message will be deleted on " - f"{delete_date.isoformat()}" - ) + + task_data = {'id': msg.id, 'channel_id': msg.channel.id} + self.schedule_task(self.bot.loop, msg.id, task_data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if isinstance(msg.channel, DMChannel): channel_str = "via DM" @@ -335,7 +332,7 @@ class Filtering(Cog, Scheduler): Attempts to catch some of common ways to try to cheat the system. """ - # Remove backslashes to prevent escape character aroundfuckery like + # Remove backslashes to prevent escape character around fuckery like # discord\.gg/gdudes-pony-farm text = text.replace("\\", "") @@ -405,7 +402,7 @@ class Filtering(Cog, Scheduler): await channel.send(f"{filtered_member.mention} {reason}") async def _scheduled_task(self, msg: dict) -> None: - """A coroutine that delete the offensive message once the delete date is reached.""" + """Delete an offensive message once its deletion date is reached.""" delete_at = dateutil.parser.isoparse(msg['delete_date']) await wait_until(delete_at) @@ -419,7 +416,6 @@ class Filtering(Cog, Scheduler): response = await self.bot.api_client.get('bot/offensive-messages',) now = datetime.datetime.utcnow() - loop = asyncio.get_event_loop() for msg in response: delete_at = dateutil.parser.isoparse(msg['delete_date']) @@ -427,7 +423,7 @@ class Filtering(Cog, Scheduler): if delete_at < now: await self.delete_offensive_msg(msg) else: - self.schedule_task(loop, msg['id'], msg) + self.schedule_task(self.bot.loop, msg['id'], msg) async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: """Delete an offensive message, and then delete it from the db.""" -- cgit v1.2.3 From 832762add664186665904817e9ffc25f79ffb20a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Dec 2019 10:03:19 -0800 Subject: Filtering: fix comparison between tz naïve and aware datetimes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4c94b73a5..02e3011ab 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -418,7 +418,7 @@ class Filtering(Cog, Scheduler): now = datetime.datetime.utcnow() for msg in response: - delete_at = dateutil.parser.isoparse(msg['delete_date']) + delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) if delete_at < now: await self.delete_offensive_msg(msg) -- cgit v1.2.3 From b36206e8c62459f22685c285fb1a7e299f08b1bd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 15 Dec 2019 11:12:56 -0800 Subject: Filtering: fix missing deletion date in scheduled task data --- bot/cogs/filtering.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 02e3011ab..83e706a26 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -195,17 +195,14 @@ class Filtering(Cog, Scheduler): # it will be deleted it after one week. if _filter["schedule_deletion"]: delete_date = (msg.created_at + OFFENSIVE_MSG_DELETE_TIME).isoformat() - await self.bot.api_client.post( - 'bot/offensive-messages', - json={ - 'id': msg.id, - 'channel_id': msg.channel.id, - 'delete_date': delete_date - } - ) - - task_data = {'id': msg.id, 'channel_id': msg.channel.id} - self.schedule_task(self.bot.loop, msg.id, task_data) + data = { + 'id': msg.id, + 'channel_id': msg.channel.id, + 'delete_date': delete_date + } + + await self.bot.api_client.post('bot/offensive-messages', json=data) + self.schedule_task(self.bot.loop, msg.id, data) log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if isinstance(msg.channel, DMChannel): -- cgit v1.2.3 From 0323919b08342fd650ff32e1f3a2fc2d9eee9c59 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 29 Feb 2020 08:30:14 +0100 Subject: Make sure that the offensive message deletion date returned by the API is naive It could have caused an issue later with a mix of naive and aware datetime Co-Authored-By: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 83e706a26..2d91695f3 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -400,7 +400,7 @@ class Filtering(Cog, Scheduler): async def _scheduled_task(self, msg: dict) -> None: """Delete an offensive message once its deletion date is reached.""" - delete_at = dateutil.parser.isoparse(msg['delete_date']) + delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) await wait_until(delete_at) await self.delete_offensive_msg(msg) -- cgit v1.2.3 From ae14de8745c40ebc9e09d785c55f0fd472dc50ae Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 2 Mar 2020 17:42:34 +0100 Subject: Remove task self cancel. Recent changes to the scheduler requires this line to be removed. --- bot/cogs/filtering.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 2d91695f3..6c9e9a4b7 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -405,8 +405,6 @@ class Filtering(Cog, Scheduler): await wait_until(delete_at) await self.delete_offensive_msg(msg) - self.cancel_task(msg['id']) - async def reschedule_offensive_msg_deletion(self) -> None: """Get all the pending message deletion from the API and reschedule them.""" await self.bot.wait_until_ready() -- cgit v1.2.3 From 0fc2ebbfeb6f6296f8eb74a7191785bfaa551f90 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Mon, 2 Mar 2020 17:48:40 +0100 Subject: Delete the loop argument from schedule_task calls The function doesn't take the loop as an argument anymore --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 6c9e9a4b7..347554ae2 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -202,7 +202,7 @@ class Filtering(Cog, Scheduler): } await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_task(self.bot.loop, msg.id, data) + self.schedule_task(msg.id, data) log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if isinstance(msg.channel, DMChannel): @@ -418,7 +418,7 @@ class Filtering(Cog, Scheduler): if delete_at < now: await self.delete_offensive_msg(msg) else: - self.schedule_task(self.bot.loop, msg['id'], msg) + self.schedule_task(msg['id'], msg) async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: """Delete an offensive message, and then delete it from the db.""" -- cgit v1.2.3 From 4890cc5ba43ad73229ce4d2fe240acaf39194edb Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 14 Apr 2020 08:30:18 +0300 Subject: Created tests for `bot.cogs.logging` connected message. --- tests/bot/cogs/test_logging.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/bot/cogs/test_logging.py diff --git a/tests/bot/cogs/test_logging.py b/tests/bot/cogs/test_logging.py new file mode 100644 index 000000000..ba98a5a56 --- /dev/null +++ b/tests/bot/cogs/test_logging.py @@ -0,0 +1,42 @@ +import unittest +from unittest.mock import patch + +from bot import constants +from bot.cogs.logging import Logging +from tests.helpers import MockBot, MockTextChannel + + +class LoggingTests(unittest.IsolatedAsyncioTestCase): + """Test cases for connected login.""" + + def setUp(self): + self.bot = MockBot() + self.cog = Logging(self.bot) + self.dev_log = MockTextChannel(id=1234, name="dev-log") + + @patch("bot.cogs.logging.DEBUG_MODE", False) + async def test_debug_mode_false(self): + """Should send connected message to dev-log.""" + self.bot.get_channel.return_value = self.dev_log + + await self.cog.startup_greeting() + self.bot.wait_until_guild_available.assert_awaited_once_with() + self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) + + embed = self.dev_log.send.call_args[1]['embed'] + self.dev_log.send.assert_awaited_once_with(embed=embed) + + self.assertEqual(embed.description, "Connected!") + self.assertEqual(embed.author.name, "Python Bot") + self.assertEqual(embed.author.url, "https://github.com/python-discord/bot") + self.assertEqual( + embed.author.icon_url, + "https://raw.githubusercontent.com/python-discord/branding/master/logos/logo_circle/logo_circle_large.png" + ) + + @patch("bot.cogs.logging.DEBUG_MODE", True) + async def test_debug_mode_true(self): + """Should not send anything to dev-log.""" + await self.cog.startup_greeting() + self.bot.wait_until_guild_available.assert_awaited_once_with() + self.bot.get_channel.assert_not_called() -- cgit v1.2.3 From b366d655af0e0f5a9ff3e053a693838d49884ea2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 13:28:32 -0700 Subject: Token remover: catch ValueError when non-ASCII chars are present The token uses base64 and base64 only allows ASCII characters. Thus, if a match has non-ASCII characters, it's not a valid token. Catching the ValueError is simpler than trying to adjust the regex to only match valid base64. Fixes #928 Fixes BOT-3X --- bot/cogs/token_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 6721f0e02..860ae9f3a 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -135,7 +135,7 @@ class TokenRemover(Cog): try: content: bytes = base64.b64decode(b64_content) return content.decode('utf-8').isnumeric() - except (binascii.Error, UnicodeDecodeError): + except (binascii.Error, ValueError): return False @staticmethod @@ -150,7 +150,7 @@ class TokenRemover(Cog): try: content = base64.urlsafe_b64decode(b64_content) snowflake = struct.unpack('i', content)[0] - except (binascii.Error, struct.error): + except (binascii.Error, struct.error, ValueError): return False return snowflake_time(snowflake + TOKEN_EPOCH) < DISCORD_EPOCH_TIMESTAMP -- cgit v1.2.3 From f03ae8e49bb3d62776528e6339d6c713c93b7674 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 14:08:02 -0700 Subject: Token remover: reduce duplicated code in `on_message_edit` --- bot/cogs/token_remover.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 860ae9f3a..e90d5ab8b 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -65,9 +65,7 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ - found_token = self.find_token_in_message(after) - if found_token: - await self.take_action(after, found_token) + await self.on_message(after) async def take_action(self, msg: Message, found_token: str) -> None: """Remove the `msg` containing a token an send a mod_log message.""" -- cgit v1.2.3 From d193a93828582965eb361dc6f3185291fff649a7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 14:11:39 -0700 Subject: Test on_message_edit of token remover uses on_message --- tests/bot/cogs/test_token_remover.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 33d1ec170..e7b5a9bea 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -1,6 +1,7 @@ import asyncio import logging import unittest +from unittest import mock from unittest.mock import AsyncMock, MagicMock from discord import Colour @@ -14,7 +15,7 @@ from bot.constants import Channels, Colours, Event, Icons from tests.helpers import MockBot, MockMessage -class TokenRemoverTests(unittest.TestCase): +class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): """Tests the `TokenRemover` cog.""" def setUp(self): @@ -58,6 +59,13 @@ class TokenRemoverTests(unittest.TestCase): self.assertEqual(self.cog.mod_log, self.bot.get_cog.return_value) self.bot.get_cog.assert_called_once_with('ModLog') + async def test_on_message_edit_uses_on_message(self): + """The edit listener should delegate handling of the message to the normal listener.""" + self.cog.on_message = mock.create_autospec(self.cog.on_message, spec_set=True) + + await self.cog.on_message_edit(MockMessage(), self.msg) + self.cog.on_message.assert_awaited_once_with(self.msg) + def test_ignores_bot_messages(self): """When the message event handler is called with a bot message, nothing is done.""" self.msg.author.bot = True @@ -77,7 +85,7 @@ class TokenRemoverTests(unittest.TestCase): for content in ('foo.bar.baz', 'x.y.'): with self.subTest(content=content): self.msg.content = content - coroutine = self.cog.on_message(self.msg) + coroutine = self.cog.is_maybe_token(self.msg) self.assertIsNone(asyncio.run(coroutine)) def test_censors_valid_tokens(self): -- cgit v1.2.3 From 0bfd003dbfc5919220129f984dc043421e535f8c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 14:38:12 -0700 Subject: Add a test helper function to patch multiple attributes with autospecs This helper reduces redundancy/boilerplate by setting default values. It also has the consequence of shortening the length of the invocation, which makes it faster to use and easier to read. --- tests/helpers.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/helpers.py b/tests/helpers.py index 2b79a6c2a..d444cc49d 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -23,6 +23,15 @@ for logger in logging.Logger.manager.loggerDict.values(): logger.setLevel(logging.CRITICAL) +def autospec(target, *attributes: str, **kwargs) -> unittest.mock._patch: + """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.""" + # Caller's kwargs should take priority and overwrite the defaults. + kwargs = {'spec_set': True, 'autospec': True, **kwargs} + attributes = {attribute: unittest.mock.DEFAULT for attribute in attributes} + + return unittest.mock.patch.multiple(target, **attributes, **kwargs) + + class HashableMixin(discord.mixins.EqualityComparable): """ Mixin that provides similar hashing and equality functionality as discord.py's `Hashable` mixin. -- cgit v1.2.3 From e8bd69a6c556d78eca1a1eb2adfa26248273a1cd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 14:42:07 -0700 Subject: Test token remover takes action if a token is found --- tests/bot/cogs/test_token_remover.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index e7b5a9bea..e0ec67684 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -12,7 +12,7 @@ from bot.cogs.token_remover import ( setup as setup_cog, ) from bot.constants import Channels, Colours, Event, Icons -from tests.helpers import MockBot, MockMessage +from tests.helpers import MockBot, MockMessage, autospec class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @@ -66,6 +66,18 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): await self.cog.on_message_edit(MockMessage(), self.msg) self.cog.on_message.assert_awaited_once_with(self.msg) + @autospec(TokenRemover, "find_token_in_message", "take_action") + async def test_on_message_takes_action(self, find_token_in_message, take_action): + """Should take action if a valid token is found when a message is sent.""" + cog = TokenRemover(self.bot) + found_token = "foobar" + find_token_in_message.return_value = found_token + + await cog.on_message(self.msg) + + find_token_in_message.assert_called_once_with(self.msg) + take_action.assert_awaited_once_with(cog, self.msg, found_token) + def test_ignores_bot_messages(self): """When the message event handler is called with a bot message, nothing is done.""" self.msg.author.bot = True -- cgit v1.2.3 From 4cf7996a1d4630ccb05f57569ca62b1798dc7a93 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 14:44:54 -0700 Subject: Test token remover skips messages without tokens --- tests/bot/cogs/test_token_remover.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index e0ec67684..2b377e221 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -78,6 +78,17 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): find_token_in_message.assert_called_once_with(self.msg) take_action.assert_awaited_once_with(cog, self.msg, found_token) + @autospec(TokenRemover, "find_token_in_message", "take_action") + async def test_on_message_skips_missing_token(self, find_token_in_message, take_action): + """Shouldn't take action if a valid token isn't found when a message is sent.""" + cog = TokenRemover(self.bot) + find_token_in_message.return_value = False + + await cog.on_message(self.msg) + + find_token_in_message.assert_called_once_with(self.msg) + take_action.assert_not_awaited() + def test_ignores_bot_messages(self): """When the message event handler is called with a bot message, nothing is done.""" self.msg.author.bot = True -- cgit v1.2.3 From 593e09299c6e4115d41bfd5b074785a5e304a8d0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 15:41:14 -0700 Subject: Allow using arbitrary parameter names with the autospec decorator This gives the caller more flexibility. Sometimes attribute names are too long or they don't follow a naming scheme accepted by the linter. --- tests/helpers.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index d444cc49d..1ab8b455f 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -24,12 +24,25 @@ for logger in logging.Logger.manager.loggerDict.values(): def autospec(target, *attributes: str, **kwargs) -> unittest.mock._patch: - """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.""" + """ + Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True. + + To allow for arbitrary parameter names to be used by the decorated function, the patchers have + no attribute names associated with them. As a consequence, it will not be possible to retrieve + mocks by their attribute names when using this as a context manager, + """ # Caller's kwargs should take priority and overwrite the defaults. kwargs = {'spec_set': True, 'autospec': True, **kwargs} attributes = {attribute: unittest.mock.DEFAULT for attribute in attributes} - return unittest.mock.patch.multiple(target, **attributes, **kwargs) + patcher = unittest.mock.patch.multiple(target, **attributes, **kwargs) + + # Unset attribute names to allow arbitrary parameter names for the decorator function. + patcher.attribute_name = None + for additional_patcher in patcher.additional_patchers: + additional_patcher.attribute_name = None + + return patcher class HashableMixin(discord.mixins.EqualityComparable): -- cgit v1.2.3 From b0dd290710799c342240d066abaebbe9e6940b54 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 15:09:22 -0700 Subject: Fix test for token remover ignoring bot messages It's not possible to test this via asserting the return value of `on_message` since it never returns anything. Instead, the actual relevant unit, `find_token_in_message,` should be tested. --- tests/bot/cogs/test_token_remover.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 2b377e221..e8b641101 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -89,11 +89,16 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): find_token_in_message.assert_called_once_with(self.msg) take_action.assert_not_awaited() - def test_ignores_bot_messages(self): - """When the message event handler is called with a bot message, nothing is done.""" + @autospec("bot.cogs.token_remover", "TOKEN_RE") + def test_find_token_ignores_bot_messages(self, token_re): + """The token finder should ignore messages authored by bots.""" + cog = TokenRemover(self.bot) self.msg.author.bot = True - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) + + return_value = cog.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.findall.assert_not_called() def test_ignores_messages_without_tokens(self): """Messages without anything looking like a token are ignored.""" -- cgit v1.2.3 From 52f0f8a29d7f239c961beaa81881bf4b09da4749 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 15:53:06 -0700 Subject: Test `find_token_in_message` returns None if no matches found --- tests/bot/cogs/test_token_remover.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index e8b641101..5932cf4f0 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -100,6 +100,20 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.findall.assert_not_called() + @autospec(TokenRemover, "is_maybe_token") + @autospec("bot.cogs.token_remover", "TOKEN_RE") + def test_find_token_no_matches_returns_none(self, token_re, is_maybe_token): + """None should be returned if the regex matches no tokens in a message.""" + cog = TokenRemover(self.bot) + token_re.findall.return_value = () + self.msg.content = "foobar" + + return_value = cog.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.findall.assert_called_once_with(self.msg.content) + is_maybe_token.assert_not_called() + def test_ignores_messages_without_tokens(self): """Messages without anything looking like a token are ignored.""" for content in ('', 'lemon wins'): -- cgit v1.2.3 From cf658bd58559b2683527443f2908257f197ef0bb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 16:06:47 -0700 Subject: Test `find_token_in_message` returns the found token --- tests/bot/cogs/test_token_remover.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 5932cf4f0..2b946778b 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -114,6 +114,30 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): token_re.findall.assert_called_once_with(self.msg.content) is_maybe_token.assert_not_called() + @autospec(TokenRemover, "is_maybe_token") + @autospec("bot.cogs.token_remover", "TOKEN_RE") + def test_find_token_returns_found_token(self, token_re, is_maybe_token): + """The found token should be returned.""" + true_index = 1 + matches = ("foo", "bar", "baz") + side_effects = [False] * len(matches) + side_effects[true_index] = True + + cog = TokenRemover(self.bot) + self.msg.content = "foobar" + token_re.findall.return_value = matches + is_maybe_token.side_effect = side_effects + + return_value = cog.find_token_in_message(self.msg) + + self.assertEqual(return_value, matches[true_index]) + token_re.findall.assert_called_once_with(self.msg.content) + + # assert_has_calls isn't used cause it'd allow for extra calls before or after. + # The function should short-circuit, so nothing past true_index should have been used. + calls = [mock.call(match) for match in matches[:true_index + 1]] + self.assertEqual(is_maybe_token.mock_calls, calls) + def test_ignores_messages_without_tokens(self): """Messages without anything looking like a token are ignored.""" for content in ('', 'lemon wins'): -- cgit v1.2.3 From f92bc80d6bddb5c57c190187adaa528ae44536f6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 16:25:14 -0700 Subject: Test token regex doesn't match invalid tokens --- tests/bot/cogs/test_token_remover.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 2b946778b..b67602eb9 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -8,6 +8,7 @@ from discord import Colour from bot.cogs.token_remover import ( DELETION_MESSAGE_TEMPLATE, + TOKEN_RE, TokenRemover, setup as setup_cog, ) @@ -138,13 +139,30 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): calls = [mock.call(match) for match in matches[:true_index + 1]] self.assertEqual(is_maybe_token.mock_calls, calls) - def test_ignores_messages_without_tokens(self): - """Messages without anything looking like a token are ignored.""" - for content in ('', 'lemon wins'): - with self.subTest(content=content): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - self.assertIsNone(asyncio.run(coroutine)) + def test_regex_invalid_tokens(self): + """Messages without anything looking like a token are not matched.""" + tokens = ( + "", + "lemon wins", + "..", + "x.y", + "x.y.", + ".y.z", + ".y.", + "..z", + "x..z", + " . . ", + "\n.\n.\n", + "'.'.'", + '"."."', + "(.(.(", + ").).)" + ) + + for token in tokens: + with self.subTest(token=token): + results = TOKEN_RE.findall(token) + self.assertEquals(len(results), 0) def test_ignores_messages_with_invalid_tokens(self): """Messages with values that are invalid tokens are ignored.""" -- cgit v1.2.3 From 34b836a8eba0f006c77a7b3f48f7ab14c37d31ee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 17:47:09 -0700 Subject: Fix autospec decorator when used with multiple attributes The original approach of messing with the `attribute_name` didn't work for reasons I won't discuss here (would require knowledge of patcher internals). The new approach doesn't use patch.multiple but mimics it by applying multiple patch decorators to the function. As a consequence, this can no longer be used as a context manager. --- tests/helpers.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index 1ab8b455f..dfbe539ec 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -24,25 +24,21 @@ for logger in logging.Logger.manager.loggerDict.values(): def autospec(target, *attributes: str, **kwargs) -> unittest.mock._patch: - """ - Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True. - - To allow for arbitrary parameter names to be used by the decorated function, the patchers have - no attribute names associated with them. As a consequence, it will not be possible to retrieve - mocks by their attribute names when using this as a context manager, - """ + """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.""" # Caller's kwargs should take priority and overwrite the defaults. kwargs = {'spec_set': True, 'autospec': True, **kwargs} - attributes = {attribute: unittest.mock.DEFAULT for attribute in attributes} - - patcher = unittest.mock.patch.multiple(target, **attributes, **kwargs) - - # Unset attribute names to allow arbitrary parameter names for the decorator function. - patcher.attribute_name = None - for additional_patcher in patcher.additional_patchers: - additional_patcher.attribute_name = None - return patcher + # Import the target if it's a string. + # This is to support both object and string targets like patch.multiple. + if type(target) is str: + target = unittest.mock._importer(target) + + def decorator(func): + for attribute in attributes: + patcher = unittest.mock.patch.object(target, attribute, **kwargs) + func = patcher(func) + return func + return decorator class HashableMixin(discord.mixins.EqualityComparable): -- cgit v1.2.3 From 834bd543d1d301bb853e713560a7447dc75f1ab8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 17:53:40 -0700 Subject: Test `is_maybe_token` returns False for missing parts In practice, this won't ever happen since the regex wouldn't match strings with missing parts. However, the function does check it so may as well test it. It's not necessarily bound to always use inputs from the regex either I suppose. --- tests/bot/cogs/test_token_remover.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index b67602eb9..9e1d96a37 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -164,6 +164,16 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): results = TOKEN_RE.findall(token) self.assertEquals(len(results), 0) + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + def test_is_maybe_token_missing_part_returns_false(self, valid_user, valid_time): + """False should be returned for tokens which do not have all 3 parts.""" + cog = TokenRemover(self.bot) + return_value = cog.is_maybe_token("x.y") + + self.assertFalse(return_value) + valid_user.assert_not_called() + valid_time.assert_not_called() + def test_ignores_messages_with_invalid_tokens(self): """Messages with values that are invalid tokens are ignored.""" for content in ('foo.bar.baz', 'x.y.'): -- cgit v1.2.3 From 4248f88a7407b6e9a5d80800a96f8707003634d3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 18:07:17 -0700 Subject: Token remover: fix `is_maybe_token` returning None instead of False It's annotated as returning a bool and when the split fails it already returns False. To be consistent, it should always return a bool. --- bot/cogs/token_remover.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index e90d5ab8b..543f4c5a7 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -121,6 +121,8 @@ class TokenRemover(Cog): if cls.is_valid_user_id(user_id) and cls.is_valid_timestamp(creation_timestamp): return True + return False + @staticmethod def is_valid_user_id(b64_content: str) -> bool: """ -- cgit v1.2.3 From ab5d194b90a7e068c8ab7171939f471e252ee073 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 18:11:31 -0700 Subject: Test is_maybe_token --- tests/bot/cogs/test_token_remover.py | 31 ++++++++++++++++++++++++------- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 9e1d96a37..85bbbdf6b 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -174,13 +174,30 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): valid_user.assert_not_called() valid_time.assert_not_called() - def test_ignores_messages_with_invalid_tokens(self): - """Messages with values that are invalid tokens are ignored.""" - for content in ('foo.bar.baz', 'x.y.'): - with self.subTest(content=content): - self.msg.content = content - coroutine = self.cog.is_maybe_token(self.msg) - self.assertIsNone(asyncio.run(coroutine)) + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + def test_is_maybe_token(self, valid_user, valid_time): + """Should return True if the user ID and timestamp are valid or return False otherwise.""" + cog = TokenRemover(self.bot) + subtests = ( + (False, True, False), + (True, False, False), + (True, True, True), + ) + + for user_return, time_return, expected in subtests: + valid_user.reset_mock() + valid_time.reset_mock() + + with self.subTest(user_return=user_return, time_return=time_return, expected=expected): + valid_user.return_value = user_return + valid_time.return_value = time_return + + actual = cog.is_maybe_token("x.y.z") + self.assertIs(actual, expected) + + valid_user.assert_called_once_with("x") + if user_return: + valid_time.assert_called_once_with("y") def test_censors_valid_tokens(self): """Valid tokens are censored.""" -- cgit v1.2.3 From 4b6fde69a7e193382701dccf80a5471ea7ccea72 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 18:22:31 -0700 Subject: Test token regex matches valid tokens --- tests/bot/cogs/test_token_remover.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 85bbbdf6b..7310b4637 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -164,6 +164,27 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): results = TOKEN_RE.findall(token) self.assertEquals(len(results), 0) + def test_regex_valid_tokens(self): + """Messages that look like tokens should be matched.""" + # Don't worry, the token's been invalidated. + tokens = ( + "x1.y2.z_3", + "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8" + ) + + for token in tokens: + with self.subTest(token=token): + results = TOKEN_RE.findall(token) + self.assertIn(token, results) + + def test_regex_matches_multiple_valid(self): + """Should support multiple matches in the middle of a string.""" + tokens = ["x.y.z", "a.b.c"] + message = f"garbage {tokens[0]} hello {tokens[1]} world" + + results = TOKEN_RE.findall(message) + self.assertEquals(tokens, results) + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") def test_is_maybe_token_missing_part_returns_false(self, valid_user, valid_time): """False should be returned for tokens which do not have all 3 parts.""" -- cgit v1.2.3 From d8d8e144adfe4c2de15dbbf4346e2eec548a9f67 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 18:28:06 -0700 Subject: Correct the return type annotation for the autospec decorator --- tests/helpers.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/helpers.py b/tests/helpers.py index dfbe539ec..3cd8a63c0 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -4,7 +4,7 @@ import collections import itertools import logging import unittest.mock -from typing import Iterable, Optional +from typing import Callable, Iterable, Optional import discord from discord.ext.commands import Context @@ -23,7 +23,7 @@ for logger in logging.Logger.manager.loggerDict.values(): logger.setLevel(logging.CRITICAL) -def autospec(target, *attributes: str, **kwargs) -> unittest.mock._patch: +def autospec(target, *attributes: str, **kwargs) -> Callable: """Patch multiple `attributes` of a `target` with autospecced mocks and `spec_set` as True.""" # Caller's kwargs should take priority and overwrite the defaults. kwargs = {'spec_set': True, 'autospec': True, **kwargs} -- cgit v1.2.3 From ab860e23a7e6206e68cb350257b63083cfbe1a15 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 18:53:42 -0700 Subject: Token remover: split some of `take_action` into separate functions --- bot/cogs/token_remover.py | 32 +++++++++++++++++++++----------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 543f4c5a7..d6919839e 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -68,31 +68,41 @@ class TokenRemover(Cog): await self.on_message(after) async def take_action(self, msg: Message, found_token: str) -> None: - """Remove the `msg` containing a token an send a mod_log message.""" - user_id, creation_timestamp, hmac = found_token.split('.') + """Remove the `msg` containing the `found_token` and send a mod log message.""" self.mod_log.ignore(Event.message_delete, msg.id) - await msg.delete() - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) + await self.delete_message(msg) - message = ( - "Censored a seemingly valid token sent by " - f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was " - f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`" - ) - log.debug(message) + log_message = self.format_log_message(msg, found_token) + log.debug(log_message) # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( icon_url=Icons.token_removed, colour=Colour(Colours.soft_red), title="Token removed!", - text=message, + text=log_message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, ) self.bot.stats.incr("tokens.removed_tokens") + @staticmethod + async def delete_message(msg: Message) -> None: + """Remove a `msg` containing a token and send an explanatory message in the same channel.""" + await msg.delete() + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) + + @staticmethod + def format_log_message(msg: Message, found_token: str) -> str: + """Return the log message to send for `found_token` being censored in `msg`.""" + user_id, creation_timestamp, hmac = found_token.split('.') + return ( + "Censored a seemingly valid token sent by " + f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was " + f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`" + ) + @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[str]: """Return a seemingly valid token found in `msg` or `None` if no token is found.""" -- cgit v1.2.3 From 09a6c2e211c0f209b258a02d9677240282c4fab3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 10 May 2020 18:55:24 -0700 Subject: Token remover: use a string template for the log message --- bot/cogs/token_remover.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index d6919839e..c576a67d0 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -16,6 +16,10 @@ from bot.constants import Channels, Colours, Event, Icons log = logging.getLogger(__name__) +LOG_MESSAGE = ( + "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}," + "token was `{user_id}.{timestamp}.{hmac}`" +) DELETION_MESSAGE_TEMPLATE = ( "Hey {mention}! I noticed you posted a seemingly valid Discord API " "token in your message and have removed your message. " @@ -97,10 +101,13 @@ class TokenRemover(Cog): def format_log_message(msg: Message, found_token: str) -> str: """Return the log message to send for `found_token` being censored in `msg`.""" user_id, creation_timestamp, hmac = found_token.split('.') - return ( - "Censored a seemingly valid token sent by " - f"{msg.author} (`{msg.author.id}`) in {msg.channel.mention}, token was " - f"`{user_id}.{creation_timestamp}.{'x' * len(hmac)}`" + return LOG_MESSAGE.format( + author=msg.author, + author_id=msg.author.id, + channel=msg.channel.mention, + user_id=user_id, + timestamp=creation_timestamp, + hmac='x' * len(hmac), ) @classmethod -- cgit v1.2.3 From 5b9bf9aba686f570322cb9996dd35d3ab669a162 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 10:26:16 -0700 Subject: Avoid instantiating the cog when testing static/class methods --- tests/bot/cogs/test_token_remover.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 7310b4637..6a8247070 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -93,10 +93,9 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec("bot.cogs.token_remover", "TOKEN_RE") def test_find_token_ignores_bot_messages(self, token_re): """The token finder should ignore messages authored by bots.""" - cog = TokenRemover(self.bot) self.msg.author.bot = True - return_value = cog.find_token_in_message(self.msg) + return_value = TokenRemover.find_token_in_message(self.msg) self.assertIsNone(return_value) token_re.findall.assert_not_called() @@ -105,11 +104,10 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec("bot.cogs.token_remover", "TOKEN_RE") def test_find_token_no_matches_returns_none(self, token_re, is_maybe_token): """None should be returned if the regex matches no tokens in a message.""" - cog = TokenRemover(self.bot) token_re.findall.return_value = () self.msg.content = "foobar" - return_value = cog.find_token_in_message(self.msg) + return_value = TokenRemover.find_token_in_message(self.msg) self.assertIsNone(return_value) token_re.findall.assert_called_once_with(self.msg.content) @@ -124,12 +122,11 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): side_effects = [False] * len(matches) side_effects[true_index] = True - cog = TokenRemover(self.bot) self.msg.content = "foobar" token_re.findall.return_value = matches is_maybe_token.side_effect = side_effects - return_value = cog.find_token_in_message(self.msg) + return_value = TokenRemover.find_token_in_message(self.msg) self.assertEqual(return_value, matches[true_index]) token_re.findall.assert_called_once_with(self.msg.content) @@ -188,8 +185,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") def test_is_maybe_token_missing_part_returns_false(self, valid_user, valid_time): """False should be returned for tokens which do not have all 3 parts.""" - cog = TokenRemover(self.bot) - return_value = cog.is_maybe_token("x.y") + return_value = TokenRemover.is_maybe_token("x.y") self.assertFalse(return_value) valid_user.assert_not_called() @@ -198,7 +194,6 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") def test_is_maybe_token(self, valid_user, valid_time): """Should return True if the user ID and timestamp are valid or return False otherwise.""" - cog = TokenRemover(self.bot) subtests = ( (False, True, False), (True, False, False), @@ -213,7 +208,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): valid_user.return_value = user_return valid_time.return_value = time_return - actual = cog.is_maybe_token("x.y.z") + actual = TokenRemover.is_maybe_token("x.y.z") self.assertIs(actual, expected) valid_user.assert_called_once_with("x") -- cgit v1.2.3 From 2127239840085ba523d411899e0b7a188530df07 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 10:33:05 -0700 Subject: Simplify token remover's message mock * Rely on default values for the author * Set the content to a non-empty string --- tests/bot/cogs/test_token_remover.py | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 6a8247070..5ca863926 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -26,14 +26,10 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.bot.get_cog.return_value.send_log_message = AsyncMock() self.cog = TokenRemover(bot=self.bot) - self.msg = MockMessage(id=555, content='') - self.msg.author.__str__ = MagicMock() - self.msg.author.__str__.return_value = 'lemon' - self.msg.author.bot = False - self.msg.author.avatar_url_as.return_value = 'picture-lemon.png' - self.msg.author.id = 42 - self.msg.author.mention = '@lemon' + self.msg = MockMessage(id=555, content="hello world") self.msg.channel.mention = "#lemonade-stand" + self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) + self.msg.author.avatar_url_as.return_value = "picture-lemon.png" def test_is_valid_user_id_is_true_for_numeric_content(self): """A string decoding to numeric characters is a valid user ID.""" @@ -105,7 +101,6 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): def test_find_token_no_matches_returns_none(self, token_re, is_maybe_token): """None should be returned if the regex matches no tokens in a message.""" token_re.findall.return_value = () - self.msg.content = "foobar" return_value = TokenRemover.find_token_in_message(self.msg) @@ -122,7 +117,6 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): side_effects = [False] * len(matches) side_effects[true_index] = True - self.msg.content = "foobar" token_re.findall.return_value = matches is_maybe_token.side_effect = side_effects -- cgit v1.2.3 From e4790b330da1605573b5d23615bfe62b481e1e04 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 10:37:59 -0700 Subject: Test token remover's message deletion --- tests/bot/cogs/test_token_remover.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 5ca863926..d65ce2ce5 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -209,6 +209,15 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): if user_return: valid_time.assert_called_once_with("y") + async def test_delete_message(self): + """The message should be deleted, and a message should be sent to the same channel.""" + await TokenRemover.delete_message(self.msg) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) + ) + def test_censors_valid_tokens(self): """Valid tokens are censored.""" cases = ( -- cgit v1.2.3 From 567a5f9242912d6a3340c088c0ae1a62977a141e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 10:46:02 -0700 Subject: Test TokenRemover.format_log_message --- tests/bot/cogs/test_token_remover.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index d65ce2ce5..f5412e692 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -218,6 +218,22 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) ) + @autospec("bot.cogs.token_remover", "LOG_MESSAGE") + async def test_format_log_message(self, log_message): + """Should correctly format the log message with info from the message and token.""" + log_message.format.return_value = "Howdy" + return_value = TokenRemover.format_log_message(self.msg, "MTIz.DN9R_A.xyz") + + self.assertEqual(return_value, log_message.format.return_value) + log_message.format.assert_called_once_with( + author=self.msg.author, + author_id=self.msg.author.id, + channel=self.msg.channel.mention, + user_id="MTIz", + timestamp="DN9R_A", + hmac="xxx", + ) + def test_censors_valid_tokens(self): """Valid tokens are censored.""" cases = ( -- cgit v1.2.3 From f47cbef0b47ef11b8c1fd63076105e4cb7d73601 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 11:29:28 -0700 Subject: Test TokenRemover.take_action * Remove `bot.get_cog` mocks in `setUp` * Mock the logger cause it's easier to assert logs * Remove subtests * Assert helper functions were called * Create an autospec for ModLog --- tests/bot/cogs/test_token_remover.py | 73 +++++++++++++++--------------------- 1 file changed, 30 insertions(+), 43 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index f5412e692..3546e7964 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -1,11 +1,10 @@ -import asyncio -import logging import unittest from unittest import mock -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock from discord import Colour +from bot.cogs.moderation import ModLog from bot.cogs.token_remover import ( DELETION_MESSAGE_TEMPLATE, TOKEN_RE, @@ -22,8 +21,6 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): def setUp(self): """Adds the cog, a bot, and a message to the instance for usage in tests.""" self.bot = MockBot() - self.bot.get_cog.return_value = MagicMock() - self.bot.get_cog.return_value.send_log_message = AsyncMock() self.cog = TokenRemover(bot=self.bot) self.msg = MockMessage(id=555, content="hello world") @@ -234,46 +231,36 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): hmac="xxx", ) - def test_censors_valid_tokens(self): - """Valid tokens are censored.""" - cases = ( - # (content, censored_token) - ('MTIz.DN9R_A.xyz', 'MTIz.DN9R_A.xxx'), + @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) + @autospec("bot.cogs.token_remover", "log") + @autospec(TokenRemover, "delete_message", "format_log_message") + async def test_take_action(self, delete_message, format_log_message, logger, mod_log_property): + """Should delete the message and send a mod log.""" + cog = TokenRemover(self.bot) + mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) + token = "MTIz.DN9R_A.xyz" + log_msg = "testing123" + + mod_log_property.return_value = mod_log + format_log_message.return_value = log_msg + + await cog.take_action(self.msg, token) + + delete_message.assert_awaited_once_with(self.msg) + format_log_message.assert_called_once_with(self.msg, token) + logger.debug.assert_called_with(log_msg) + self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") + + mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) + mod_log.send_log_message.assert_called_once_with( + icon_url=Icons.token_removed, + colour=Colour(Colours.soft_red), + title="Token removed!", + text=log_msg, + thumbnail=self.msg.author.avatar_url_as.return_value, + channel_id=Channels.mod_alerts ) - for content, censored_token in cases: - with self.subTest(content=content, censored_token=censored_token): - self.msg.content = content - coroutine = self.cog.on_message(self.msg) - with self.assertLogs(logger='bot.cogs.token_remover', level=logging.DEBUG) as cm: - self.assertIsNone(asyncio.run(coroutine)) # no return value - - [line] = cm.output - log_message = ( - "Censored a seemingly valid token sent by " - "lemon (`42`) in #lemonade-stand, " - f"token was `{censored_token}`" - ) - self.assertIn(log_message, line) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_called_once_with( - DELETION_MESSAGE_TEMPLATE.format(mention='@lemon') - ) - self.bot.get_cog.assert_called_with('ModLog') - self.msg.author.avatar_url_as.assert_called_once_with(static_format='png') - - mod_log = self.bot.get_cog.return_value - mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) - mod_log.send_log_message.assert_called_once_with( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), - title="Token removed!", - text=log_message, - thumbnail='picture-lemon.png', - channel_id=Channels.mod_alerts - ) - class TokenRemoverSetupTests(unittest.TestCase): """Tests setup of the `TokenRemover` cog.""" -- cgit v1.2.3 From 5734a4d84922a9497014dfeb3eba31ad3c57536f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 11:44:08 -0700 Subject: Refactor `TokenRemoverSetupTests` and add a more thorough test The test now ensures the cog is instantiated and that the instance is passed as an argument to `add_cog`. --- tests/bot/cogs/test_token_remover.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 3546e7964..c377de7b2 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -262,11 +262,15 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): ) -class TokenRemoverSetupTests(unittest.TestCase): - """Tests setup of the `TokenRemover` cog.""" +class TokenRemoverExtensionTests(unittest.TestCase): + """Tests for the token_remover extension.""" - def test_setup(self): - """Setup of the extension should call add_cog.""" + @autospec("bot.cogs.token_remover", "TokenRemover") + def test_extension_setup(self, cog): + """The TokenRemover cog should be added.""" bot = MockBot() setup_cog(bot) + + cog.assert_called_once_with(bot) bot.add_cog.assert_called_once() + self.assertTrue(isinstance(bot.add_cog.call_args.args[0], TokenRemover)) -- cgit v1.2.3 From d0303d715d485842a2d5c906099d767d74cf8bd8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 11:45:50 -0700 Subject: Replace deprecated assertion methods --- tests/bot/cogs/test_token_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index c377de7b2..aecb51403 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -150,7 +150,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for token in tokens: with self.subTest(token=token): results = TOKEN_RE.findall(token) - self.assertEquals(len(results), 0) + self.assertEqual(len(results), 0) def test_regex_valid_tokens(self): """Messages that look like tokens should be matched.""" @@ -171,7 +171,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): message = f"garbage {tokens[0]} hello {tokens[1]} world" results = TOKEN_RE.findall(message) - self.assertEquals(tokens, results) + self.assertEqual(tokens, results) @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") def test_is_maybe_token_missing_part_returns_false(self, valid_user, valid_time): -- cgit v1.2.3 From 862153f2e4ab5b1408719fb2c1abc5143cfb15ce Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 11:47:40 -0700 Subject: Clean up token remover test imports --- tests/bot/cogs/test_token_remover.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index aecb51403..5cc8c7ad1 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -4,14 +4,10 @@ from unittest.mock import MagicMock from discord import Colour +from bot import constants +from bot.cogs import token_remover from bot.cogs.moderation import ModLog -from bot.cogs.token_remover import ( - DELETION_MESSAGE_TEMPLATE, - TOKEN_RE, - TokenRemover, - setup as setup_cog, -) -from bot.constants import Channels, Colours, Event, Icons +from bot.cogs.token_remover import TokenRemover from tests.helpers import MockBot, MockMessage, autospec @@ -149,7 +145,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for token in tokens: with self.subTest(token=token): - results = TOKEN_RE.findall(token) + results = token_remover.TOKEN_RE.findall(token) self.assertEqual(len(results), 0) def test_regex_valid_tokens(self): @@ -162,7 +158,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for token in tokens: with self.subTest(token=token): - results = TOKEN_RE.findall(token) + results = token_remover.TOKEN_RE.findall(token) self.assertIn(token, results) def test_regex_matches_multiple_valid(self): @@ -170,7 +166,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): tokens = ["x.y.z", "a.b.c"] message = f"garbage {tokens[0]} hello {tokens[1]} world" - results = TOKEN_RE.findall(message) + results = token_remover.TOKEN_RE.findall(message) self.assertEqual(tokens, results) @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") @@ -212,7 +208,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg.delete.assert_called_once_with() self.msg.channel.send.assert_called_once_with( - DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) + token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) ) @autospec("bot.cogs.token_remover", "LOG_MESSAGE") @@ -251,14 +247,14 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): logger.debug.assert_called_with(log_msg) self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") - mod_log.ignore.assert_called_once_with(Event.message_delete, self.msg.id) + mod_log.ignore.assert_called_once_with(constants.Event.message_delete, self.msg.id) mod_log.send_log_message.assert_called_once_with( - icon_url=Icons.token_removed, - colour=Colour(Colours.soft_red), + icon_url=constants.Icons.token_removed, + colour=Colour(constants.Colours.soft_red), title="Token removed!", text=log_msg, thumbnail=self.msg.author.avatar_url_as.return_value, - channel_id=Channels.mod_alerts + channel_id=constants.Channels.mod_alerts ) @@ -269,7 +265,7 @@ class TokenRemoverExtensionTests(unittest.TestCase): def test_extension_setup(self, cog): """The TokenRemover cog should be added.""" bot = MockBot() - setup_cog(bot) + token_remover.setup(bot) cog.assert_called_once_with(bot) bot.add_cog.assert_called_once() -- cgit v1.2.3 From 4701b0da36c7f42792c0af258b785076237fd661 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 11 May 2020 11:56:15 -0700 Subject: Use subtests for valid ID/timestamp tests and test non-ASCII inputs --- tests/bot/cogs/test_token_remover.py | 43 +++++++++++++++++++++--------------- 1 file changed, 25 insertions(+), 18 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 5cc8c7ad1..f1a56c235 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -24,24 +24,31 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" - def test_is_valid_user_id_is_true_for_numeric_content(self): - """A string decoding to numeric characters is a valid user ID.""" - # MTIz = base64(123) - self.assertTrue(TokenRemover.is_valid_user_id('MTIz')) - - def test_is_valid_user_id_is_false_for_alphabetic_content(self): - """A string decoding to alphabetic characters is not a valid user ID.""" - # YWJj = base64(abc) - self.assertFalse(TokenRemover.is_valid_user_id('YWJj')) - - def test_is_valid_timestamp_is_true_for_valid_timestamps(self): - """A string decoding to a valid timestamp should be recognized as such.""" - self.assertTrue(TokenRemover.is_valid_timestamp('DN9r_A')) - - def test_is_valid_timestamp_is_false_for_invalid_values(self): - """A string not decoding to a valid timestamp should not be recognized as such.""" - # MTIz = base64(123) - self.assertFalse(TokenRemover.is_valid_timestamp('MTIz')) + def test_is_valid_user_id(self): + """Should correctly discern valid user IDs and ignore non-numeric and non-ASCII IDs.""" + subtests = ( + ("MTIz", True), # base64(123) + ("YWJj", False), # base64(abc) + ("λδµ", False), + ) + + for user_id, is_valid in subtests: + with self.subTest(user_id=user_id, is_valid=is_valid): + result = TokenRemover.is_valid_user_id(user_id) + self.assertIs(result, is_valid) + + def test_is_valid_timestamp(self): + """Should correctly discern valid timestamps.""" + subtests = ( + ("DN9r_A", True), + ("MTIz", False), # base64(123) + ("λδµ", False), + ) + + for timestamp, is_valid in subtests: + with self.subTest(timestamp=timestamp, is_valid=is_valid): + result = TokenRemover.is_valid_timestamp(timestamp) + self.assertIs(result, is_valid) def test_mod_log_property(self): """The `mod_log` property should ask the bot to return the `ModLog` cog.""" -- cgit v1.2.3 From 31aff51655d3783bc70f04628f189cf3c3591028 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 13 May 2020 18:58:43 -0700 Subject: Fix a test needlessly being a coroutine --- tests/bot/cogs/test_token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index f1a56c235..8e743a715 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -219,7 +219,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): ) @autospec("bot.cogs.token_remover", "LOG_MESSAGE") - async def test_format_log_message(self, log_message): + def test_format_log_message(self, log_message): """Should correctly format the log message with info from the message and token.""" log_message.format.return_value = "Howdy" return_value = TokenRemover.format_log_message(self.msg, "MTIz.DN9R_A.xyz") -- cgit v1.2.3 From ab44bb38d874dfdec9d7dc61bbf13b06144b9a0e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 13 May 2020 19:18:50 -0700 Subject: Add missing comma to token remover log message --- bot/cogs/token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index c576a67d0..c57e7764e 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -17,7 +17,7 @@ from bot.constants import Channels, Colours, Event, Icons log = logging.getLogger(__name__) LOG_MESSAGE = ( - "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}," + "Censored a seemingly valid token sent by {author} (`{author_id}`) in {channel}, " "token was `{user_id}.{timestamp}.{hmac}`" ) DELETION_MESSAGE_TEMPLATE = ( -- cgit v1.2.3 From 297089cde278ea09a27240f71f41006fab2b2ca4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 13 May 2020 19:36:44 -0700 Subject: Token remover: add logs to clarify why token is invalid --- bot/cogs/token_remover.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index c57e7764e..244d52edb 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -133,12 +133,14 @@ class TokenRemover(Cog): try: user_id, creation_timestamp, hmac = test_str.split('.') except ValueError: + log.debug(f"Invalid token format in '{test_str}': does not have all 3 parts.") return False if cls.is_valid_user_id(user_id) and cls.is_valid_timestamp(creation_timestamp): return True - - return False + else: + log.debug(f"Invalid user ID or timestamp in '{test_str}'.") + return False @staticmethod def is_valid_user_id(b64_content: str) -> bool: -- cgit v1.2.3 From 73bcb2b434a30761494bbedd914508964c6fbbad Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 14 May 2020 10:34:37 -0700 Subject: Token remover: fix timestamp check The timestamp calculation was incorrect. The bytes need to be interpreted as big-endian and the result is just a timestamp rather than a snowflake. --- bot/cogs/token_remover.py | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 244d52edb..957c8a690 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -2,13 +2,10 @@ import base64 import binascii import logging import re -import struct import typing as t -from datetime import datetime from discord import Colour, Message from discord.ext.commands import Cog -from discord.utils import snowflake_time from bot.bot import Bot from bot.cogs.moderation import ModLog @@ -29,7 +26,7 @@ DELETION_MESSAGE_TEMPLATE = ( "Feel free to re-post it with the token removed. " "If you believe this was a mistake, please let us know!" ) -DISCORD_EPOCH_TIMESTAMP = datetime(2017, 1, 1) +DISCORD_EPOCH = 1_420_070_400_000 TOKEN_EPOCH = 1_293_840_000 TOKEN_RE = re.compile( r"[^\s\.()\"']+" # Matches token part 1: The user ID string, encoded as base64 @@ -160,18 +157,27 @@ class TokenRemover(Cog): @staticmethod def is_valid_timestamp(b64_content: str) -> bool: """ - Check potential token to see if it contains a valid timestamp. + Return True if `b64_content` decodes to a valid timestamp. - See: https://discordapp.com/developers/docs/reference#snowflakes + If the timestamp is greater than the Discord epoch, it's probably valid. + See: https://i.imgur.com/7WdehGn.png """ b64_content += '=' * (-len(b64_content) % 4) try: - content = base64.urlsafe_b64decode(b64_content) - snowflake = struct.unpack('i', content)[0] - except (binascii.Error, struct.error, ValueError): + decoded_bytes = base64.urlsafe_b64decode(b64_content) + timestamp = int.from_bytes(decoded_bytes, byteorder="big") + except (binascii.Error, ValueError) as e: + log.debug(f"Failed to decode token timestamp '{b64_content}': {e}") + return False + + # Seems like newer tokens don't need the epoch added, but add anyway since an upper bound + # is not checked. + if timestamp + TOKEN_EPOCH >= DISCORD_EPOCH: + return True + else: + log.debug(f"Invalid token timestamp '{b64_content}': smaller than Discord epoch") return False - return snowflake_time(snowflake + TOKEN_EPOCH) < DISCORD_EPOCH_TIMESTAMP def setup(bot: Bot) -> None: -- cgit v1.2.3 From 4a73c24678d4a893304f0b2f3a5f1e326cae817a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 15 May 2020 08:54:36 -0700 Subject: Token remover: use strict check for digits in token ID `isnumeric` would be true for a wide range of characters in Unicode, but the ID must only consist of the characters 0-9 (ASCII digits). In fact, `isdigit` on its own would also match other Unicode characters too. --- bot/cogs/token_remover.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 957c8a690..43c12c4f7 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -149,8 +149,11 @@ class TokenRemover(Cog): b64_content += '=' * (-len(b64_content) % 4) try: - content: bytes = base64.b64decode(b64_content) - return content.decode('utf-8').isnumeric() + decoded_bytes: bytes = base64.b64decode(b64_content) + string = decoded_bytes.decode('utf-8') + + # isdigit on its own would match a lot of other Unicode characters, hence the isascii. + return string.isascii() and string.isdigit() except (binascii.Error, ValueError): return False -- cgit v1.2.3 From ad154f7f0d7daa3f962433f77d1cdd11cc66bfe0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 16 May 2020 22:43:00 -0700 Subject: Add a utility function to pad base64 data --- bot/cogs/token_remover.py | 5 +++-- bot/utils/__init__.py | 5 +++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 43c12c4f7..cae482e6e 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -7,6 +7,7 @@ import typing as t from discord import Colour, Message from discord.ext.commands import Cog +from bot import utils from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import Channels, Colours, Event, Icons @@ -146,7 +147,7 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ - b64_content += '=' * (-len(b64_content) % 4) + b64_content = utils.pad_base64(b64_content) try: decoded_bytes: bytes = base64.b64decode(b64_content) @@ -165,7 +166,7 @@ class TokenRemover(Cog): If the timestamp is greater than the Discord epoch, it's probably valid. See: https://i.imgur.com/7WdehGn.png """ - b64_content += '=' * (-len(b64_content) % 4) + b64_content = utils.pad_base64(b64_content) try: decoded_bytes = base64.urlsafe_b64decode(b64_content) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 9b32e515d..1dd0636df 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -7,3 +7,8 @@ class CogABCMeta(CogMeta, ABCMeta): """Metaclass for ABCs meant to be implemented as Cogs.""" pass + + +def pad_base64(data: str) -> str: + """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" + return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From 716699dedf6ec7afc76eb61d5402184d5a808dc7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 20:14:06 +0300 Subject: Source: Created initial cog layout + setup function --- bot/cogs/source.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/cogs/source.py diff --git a/bot/cogs/source.py b/bot/cogs/source.py new file mode 100644 index 000000000..4897a16e3 --- /dev/null +++ b/bot/cogs/source.py @@ -0,0 +1,15 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class Source(Cog): + """Cog of Python Discord project source information.""" + + def __init__(self, bot: Bot): + self.bot = bot + + +def setup(bot: Bot) -> None: + """Load `Source` cog.""" + bot.add_cog(Source(bot)) -- cgit v1.2.3 From c71895c99366f5096442420edde10c70e562a4dd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Tue, 19 May 2020 20:21:58 +0300 Subject: Source: Create converter for source object converting --- bot/cogs/source.py | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 4897a16e3..76f75f83b 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,10 +1,39 @@ -from discord.ext.commands import Cog +from typing import Union + +from discord.ext.commands import BadArgument, Cog, Context, Converter, Command, HelpCommand from bot.bot import Bot +class SourceConverted(Converter): + """Convert argument to help command, command or Cog.""" + + async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: + """ + Convert argument into source object. + + Order how arguments is checked: + 1. When argument is `help`, return bot help command + 2. When argument is valid command, return this command + 3. When argument is valid Cog, return this Cog + 4. Otherwise raise `BadArgument` error + """ + if argument.lower() == "help": + return ctx.bot.help_command + + command = ctx.bot.get_command(argument) + if command: + return command + + cog = ctx.bot.get_cog(argument) + if cog: + return cog + + raise BadArgument(f"Unable to convert `{argument}` to help command, command or cog.") + + class Source(Cog): - """Cog of Python Discord project source information.""" + """Cog of Python Discord projects source information.""" def __init__(self, bot: Bot): self.bot = bot -- cgit v1.2.3 From a4ad94904638c94b44006d546861d399eba77ed5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 08:46:27 +0300 Subject: Source: Create `get_source_link` function that build item's GitHub link --- bot/cogs/source.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 76f75f83b..1f9e0e84d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,11 +1,14 @@ +import inspect +import os from typing import Union -from discord.ext.commands import BadArgument, Cog, Context, Converter, Command, HelpCommand +from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand from bot.bot import Bot +from bot.constants import URLs -class SourceConverted(Converter): +class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: @@ -38,6 +41,24 @@ class Source(Cog): def __init__(self, bot: Bot): self.bot = bot + @staticmethod + def get_source_link(source_item: Union[HelpCommand, Command, Cog]) -> str: + """Build GitHub link of source item.""" + if isinstance(source_item, HelpCommand): + src = type(source_item) + filename = inspect.getsourcefile(src) + elif isinstance(source_item, Command): + src = source_item.callback.__code__ + filename = src.co_filename + else: + src = type(source_item) + filename = inspect.getsourcefile(src) + + lines, first_line_no = inspect.getsourcelines(src) + file_location = os.path.relpath(filename) + + return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" + def setup(bot: Bot) -> None: """Load `Source` cog.""" -- cgit v1.2.3 From 6e923cb6386e95b7ad56c9fc8d2374a0feffd49e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 08:46:48 +0300 Subject: Source: Add cog loading to __main__.py --- bot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__main__.py b/bot/__main__.py index aa1d1aee8..d82adc802 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -57,6 +57,7 @@ bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") bot.load_extension("bot.cogs.snekbox") +bot.load_extension("bot.cogs.source") bot.load_extension("bot.cogs.stats") bot.load_extension("bot.cogs.sync") bot.load_extension("bot.cogs.tags") -- cgit v1.2.3 From dfda0abf922d49688774aae8e6f9c0ba8e44b96d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:10:05 +0300 Subject: Source: Create `build_embed` function that build embed of source item --- bot/cogs/source.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 1f9e0e84d..bce51aa80 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -2,11 +2,18 @@ import inspect import os from typing import Union +from discord import Embed from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand from bot.bot import Bot from bot.constants import URLs +CANT_RUN_MESSAGE = "You can't run this command here." +CAN_RUN_MESSAGE = "You are able to run this command." + +COG_CHECK_FAIL = "You can't use commands what is in this Cog here." +COG_CHECK_PASS = "You can use commands from this Cog." + class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" @@ -59,6 +66,26 @@ class Source(Cog): return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" + @staticmethod + async def build_embed(link: str, source_object: Union[HelpCommand, Command, Cog], ctx: Context) -> Embed: + """Build embed based on source object.""" + if isinstance(source_object, HelpCommand): + title = "Help" + description = source_object.__doc__ + else: + title = source_object.qualified_name + description = source_object.help + + embed = Embed(title=title, description=description, url=link) + embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") + + if isinstance(source_object, Command): + embed.set_footer(text=CAN_RUN_MESSAGE if await source_object.can_run(ctx) else CANT_RUN_MESSAGE) + elif isinstance(source_object, Cog): + embed.set_footer(text=COG_CHECK_PASS if source_object.cog_check(ctx) else COG_CHECK_FAIL) + + return embed + def setup(bot: Bot) -> None: """Load `Source` cog.""" -- cgit v1.2.3 From b3742b8050aff7edbaccfa9d1d95843f5bf201a0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:11:50 +0300 Subject: Source: Create `source` command with alias `src` --- bot/cogs/source.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index bce51aa80..af29caaaa 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -3,7 +3,7 @@ import os from typing import Union from discord import Embed -from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand +from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, group from bot.bot import Bot from bot.constants import URLs @@ -48,6 +48,12 @@ class Source(Cog): def __init__(self, bot: Bot): self.bot = bot + @group(name='source', aliases=('src',), invoke_without_command=True) + async def source_command(self, ctx: Context, *, source_item: SourceConverter) -> None: + """Get GitHub link and information about help command, command or Cog.""" + url = self.get_source_link(source_item) + await ctx.send(embed=await self.build_embed(url, source_item, ctx)) + @staticmethod def get_source_link(source_item: Union[HelpCommand, Command, Cog]) -> str: """Build GitHub link of source item.""" -- cgit v1.2.3 From ce34adbc64bacd15eeb1a2bfee7b0d022ec969eb Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:17:26 +0300 Subject: Source: Make `source` command to `command` instead `group` --- bot/cogs/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index af29caaaa..1774d0085 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -3,7 +3,7 @@ import os from typing import Union from discord import Embed -from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, group +from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command from bot.bot import Bot from bot.constants import URLs @@ -48,7 +48,7 @@ class Source(Cog): def __init__(self, bot: Bot): self.bot = bot - @group(name='source', aliases=('src',), invoke_without_command=True) + @command(name="source", aliases=("src",)) async def source_command(self, ctx: Context, *, source_item: SourceConverter) -> None: """Get GitHub link and information about help command, command or Cog.""" url = self.get_source_link(source_item) -- cgit v1.2.3 From 1bb52f815e93c2e3d3fa565c150bbc5effff94f2 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:18:04 +0300 Subject: Source: Remove `command` shadowing on converter --- bot/cogs/source.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 1774d0085..c628c6b29 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -31,9 +31,9 @@ class SourceConverter(Converter): if argument.lower() == "help": return ctx.bot.help_command - command = ctx.bot.get_command(argument) - if command: - return command + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd cog = ctx.bot.get_cog(argument) if cog: -- cgit v1.2.3 From e5239c4b20d2496cf5a96192981f050c87150acd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:37:26 +0300 Subject: Source: Implement no argument GitHub repo response --- bot/cogs/source.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index c628c6b29..22b75e2ee 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,9 +1,9 @@ import inspect import os -from typing import Union +from typing import Optional, Union from discord import Embed -from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command +from discord.ext.commands import Cog, Command, Context, Converter, HelpCommand, command from bot.bot import Bot from bot.constants import URLs @@ -18,7 +18,7 @@ COG_CHECK_PASS = "You can use commands from this Cog." class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" - async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: + async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog, None]: """ Convert argument into source object. @@ -39,7 +39,7 @@ class SourceConverter(Converter): if cog: return cog - raise BadArgument(f"Unable to convert `{argument}` to help command, command or cog.") + return None class Source(Cog): @@ -49,8 +49,14 @@ class Source(Cog): self.bot = bot @command(name="source", aliases=("src",)) - async def source_command(self, ctx: Context, *, source_item: SourceConverter) -> None: + async def source_command(self, ctx: Context, *, source_item: Optional[SourceConverter] = None) -> None: """Get GitHub link and information about help command, command or Cog.""" + if not source_item: + embed = Embed(title="Bot GitHub Repository", url=URLs.github_bot_repo) + embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") + await ctx.send(embed=embed) + return + url = self.get_source_link(source_item) await ctx.send(embed=await self.build_embed(url, source_item, ctx)) -- cgit v1.2.3 From 6d0a1b0c9e3f278f2b660659efd89db3c4a3595a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 09:40:26 +0300 Subject: Source: Fix `Cog` instance of source no `help` attribute --- bot/cogs/source.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 22b75e2ee..1820392f3 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -84,9 +84,12 @@ class Source(Cog): if isinstance(source_object, HelpCommand): title = "Help" description = source_object.__doc__ - else: + elif isinstance(source_object, Command): title = source_object.qualified_name description = source_object.help + else: + title = source_object.qualified_name + description = source_object.description embed = Embed(title=title, description=description, url=link) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") -- cgit v1.2.3 From 00445f54a8593ff14d2f9c9595be86f15ab78072 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:23:05 +0300 Subject: Source: Make converter raising `BadArgument` again --- bot/cogs/source.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 1820392f3..633cb8ccf 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,9 +1,9 @@ import inspect import os -from typing import Optional, Union +from typing import Union from discord import Embed -from discord.ext.commands import Cog, Command, Context, Converter, HelpCommand, command +from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command from bot.bot import Bot from bot.constants import URLs @@ -18,7 +18,7 @@ COG_CHECK_PASS = "You can use commands from this Cog." class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" - async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog, None]: + async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: """ Convert argument into source object. @@ -39,7 +39,7 @@ class SourceConverter(Converter): if cog: return cog - return None + raise BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") class Source(Cog): @@ -49,7 +49,7 @@ class Source(Cog): self.bot = bot @command(name="source", aliases=("src",)) - async def source_command(self, ctx: Context, *, source_item: Optional[SourceConverter] = None) -> None: + async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: """Get GitHub link and information about help command, command or Cog.""" if not source_item: embed = Embed(title="Bot GitHub Repository", url=URLs.github_bot_repo) -- cgit v1.2.3 From dd05246e29fa46ff53456a499244373c23a24d06 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:23:58 +0300 Subject: Source: Remove links from title of embeds --- bot/cogs/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 633cb8ccf..b285b4089 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -52,7 +52,7 @@ class Source(Cog): async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: """Get GitHub link and information about help command, command or Cog.""" if not source_item: - embed = Embed(title="Bot GitHub Repository", url=URLs.github_bot_repo) + embed = Embed(title="Bot GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") await ctx.send(embed=embed) return @@ -91,7 +91,7 @@ class Source(Cog): title = source_object.qualified_name description = source_object.description - embed = Embed(title=title, description=description, url=link) + embed = Embed(title=title, description=description) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") if isinstance(source_object, Command): -- cgit v1.2.3 From 36ef3514674812afab6c94b12b3f9d3768b324f5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:25:26 +0300 Subject: Source: Remove Cog check displaying from command --- bot/cogs/source.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index b285b4089..220da535d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -11,9 +11,6 @@ from bot.constants import URLs CANT_RUN_MESSAGE = "You can't run this command here." CAN_RUN_MESSAGE = "You are able to run this command." -COG_CHECK_FAIL = "You can't use commands what is in this Cog here." -COG_CHECK_PASS = "You can use commands from this Cog." - class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" @@ -96,8 +93,6 @@ class Source(Cog): if isinstance(source_object, Command): embed.set_footer(text=CAN_RUN_MESSAGE if await source_object.can_run(ctx) else CANT_RUN_MESSAGE) - elif isinstance(source_object, Cog): - embed.set_footer(text=COG_CHECK_PASS if source_object.cog_check(ctx) else COG_CHECK_FAIL) return embed -- cgit v1.2.3 From e2ce4ac6f372a92cc8c31c09237521e6b3aeb23b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:35:11 +0300 Subject: Source: Rename cog + move checks status from footer to field - Renamed cog from `Source` to `BotSource` for itself (bot will be unable to get cog, because this always return command). - Moved checks status from footer to field and changed it's content. --- bot/cogs/source.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 220da535d..972507762 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -8,9 +8,6 @@ from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, from bot.bot import Bot from bot.constants import URLs -CANT_RUN_MESSAGE = "You can't run this command here." -CAN_RUN_MESSAGE = "You are able to run this command." - class SourceConverter(Converter): """Convert argument to help command, command or Cog.""" @@ -39,7 +36,7 @@ class SourceConverter(Converter): raise BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") -class Source(Cog): +class BotSource(Cog): """Cog of Python Discord projects source information.""" def __init__(self, bot: Bot): @@ -92,11 +89,11 @@ class Source(Cog): embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") if isinstance(source_object, Command): - embed.set_footer(text=CAN_RUN_MESSAGE if await source_object.can_run(ctx) else CANT_RUN_MESSAGE) + embed.add_field(name="Can be used by you here?", value=await source_object.can_run(ctx)) return embed def setup(bot: Bot) -> None: """Load `Source` cog.""" - bot.add_cog(Source(bot)) + bot.add_cog(BotSource(bot)) -- cgit v1.2.3 From dc96a187cf7af6d4d1d39a325e3ffad67d3549a5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Wed, 20 May 2020 16:38:56 +0300 Subject: Source: Fix description of cog --- bot/cogs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 972507762..5b8d8ded2 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -37,7 +37,7 @@ class SourceConverter(Converter): class BotSource(Cog): - """Cog of Python Discord projects source information.""" + """Cog of Python Discord Python bot project source information.""" def __init__(self, bot: Bot): self.bot = bot -- cgit v1.2.3 From e23aa887959059e17fc21dcab9c83db20dc987f5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 21 May 2020 20:29:44 -0700 Subject: Token remover: decode ID using URL-safe base64 Though I've not seen an ID with neither + and \ nor - and _, given that the timestamp uses URL-safe encoding, the ID probably does too. --- bot/cogs/token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index cae482e6e..5b4598959 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -150,7 +150,7 @@ class TokenRemover(Cog): b64_content = utils.pad_base64(b64_content) try: - decoded_bytes: bytes = base64.b64decode(b64_content) + decoded_bytes = base64.urlsafe_b64decode(b64_content) string = decoded_bytes.decode('utf-8') # isdigit on its own would match a lot of other Unicode characters, hence the isascii. -- cgit v1.2.3 From 95ef2dc01143902289c9aacde7969fb5c9e1a85c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 21 May 2020 21:34:10 -0700 Subject: Token remover: match only base64 in regex Making the regex more accurate reduces false positives at an earlier stage. There's no benefit to matching non-base64 as that would just be weeded out as invalid at a later stage anyway when it tries to decode it. --- bot/cogs/token_remover.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 5b4598959..fa0647828 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -29,13 +29,12 @@ DELETION_MESSAGE_TEMPLATE = ( ) DISCORD_EPOCH = 1_420_070_400_000 TOKEN_EPOCH = 1_293_840_000 -TOKEN_RE = re.compile( - r"[^\s\.()\"']+" # Matches token part 1: The user ID string, encoded as base64 - r"\." # Matches a literal dot between the token parts - r"[^\s\.()\"']+" # Matches token part 2: The creation timestamp, as an integer - r"\." # Matches a literal dot between the token parts - r"[^\s\.()\"']+" # Matches token part 3: The HMAC, unused by us, but check that it isn't empty -) + +# Three parts delimited by dots: user ID, creation timestamp, HMAC. +# The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. +# Each part only matches base64 URL-safe characters. +# Padding has never been observed, but the padding character '=' is matched just in case. +TOKEN_RE = re.compile(r"[\w-=]+\.[\w-=]+\.[\w-=]+", re.ASCII) class TokenRemover(Cog): -- cgit v1.2.3 From ededd1879cfb914445342b202d4c66aed23ee94b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 22 May 2020 08:43:10 +0300 Subject: Logging Tests: Simplify `DEBUG_MODE` `False` test - Remove embed attributes checks - Replace `self.dev_log.assert_awaited_once_with` with `self.dev_log.assert_awaited_once`. --- tests/bot/cogs/test_logging.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/tests/bot/cogs/test_logging.py b/tests/bot/cogs/test_logging.py index ba98a5a56..8a18fdcd6 100644 --- a/tests/bot/cogs/test_logging.py +++ b/tests/bot/cogs/test_logging.py @@ -22,17 +22,7 @@ class LoggingTests(unittest.IsolatedAsyncioTestCase): await self.cog.startup_greeting() self.bot.wait_until_guild_available.assert_awaited_once_with() self.bot.get_channel.assert_called_once_with(constants.Channels.dev_log) - - embed = self.dev_log.send.call_args[1]['embed'] - self.dev_log.send.assert_awaited_once_with(embed=embed) - - self.assertEqual(embed.description, "Connected!") - self.assertEqual(embed.author.name, "Python Bot") - self.assertEqual(embed.author.url, "https://github.com/python-discord/bot") - self.assertEqual( - embed.author.icon_url, - "https://raw.githubusercontent.com/python-discord/branding/master/logos/logo_circle/logo_circle_large.png" - ) + self.dev_log.send.assert_awaited_once() @patch("bot.cogs.logging.DEBUG_MODE", True) async def test_debug_mode_true(self): -- cgit v1.2.3 From 2c7ff94c956691dafa35c92dd0baa95a60aafacf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 23 May 2020 18:02:39 -0700 Subject: Token remover: escape dashes in regex They need to be escaped when they're in a character set. By default, they are interpreted as part of the character range syntax. --- bot/cogs/token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index fa0647828..f23eba89b 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -34,7 +34,7 @@ TOKEN_EPOCH = 1_293_840_000 # The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. # Each part only matches base64 URL-safe characters. # Padding has never been observed, but the padding character '=' is matched just in case. -TOKEN_RE = re.compile(r"[\w-=]+\.[\w-=]+\.[\w-=]+", re.ASCII) +TOKEN_RE = re.compile(r"[\w\-=]+\.[\w\-=]+\.[\w\-=]+", re.ASCII) class TokenRemover(Cog): -- cgit v1.2.3 From 161bf818ed0f1690c63f4f54cc9549e298e3e45c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 May 2020 19:45:04 -0700 Subject: Token remover: use regex groups and pass the token as a NamedTuple It felt redundant to be splitting the token in two different functions when regex could take care of this from the outset. ' A NamedTuple was created to house the token. This is nicer than passing an re.Match object, because it's clearer which attributes are available. Even if the regex used named groups, it wouldn't be as obvious which group names exist. Without the split, `is_maybe_token` is dwindled down to a redundant function. Therefore, it's been removed. --- bot/cogs/token_remover.py | 47 ++++++++++++++++++++--------------------------- 1 file changed, 20 insertions(+), 27 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index f23eba89b..e5d0ae838 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -34,7 +34,15 @@ TOKEN_EPOCH = 1_293_840_000 # The HMAC isn't parsed further, but it's in the regex to ensure it at least exists in the string. # Each part only matches base64 URL-safe characters. # Padding has never been observed, but the padding character '=' is matched just in case. -TOKEN_RE = re.compile(r"[\w\-=]+\.[\w\-=]+\.[\w\-=]+", re.ASCII) +TOKEN_RE = re.compile(r"([\w\-=]+)\.([\w\-=]+)\.([\w\-=]+)", re.ASCII) + + +class Token(t.NamedTuple): + """A Discord Bot token.""" + + user_id: str + timestamp: str + hmac: str class TokenRemover(Cog): @@ -68,7 +76,7 @@ class TokenRemover(Cog): """ await self.on_message(after) - async def take_action(self, msg: Message, found_token: str) -> None: + async def take_action(self, msg: Message, found_token: Token) -> None: """Remove the `msg` containing the `found_token` and send a mod log message.""" self.mod_log.ignore(Event.message_delete, msg.id) await self.delete_message(msg) @@ -95,20 +103,19 @@ class TokenRemover(Cog): await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) @staticmethod - def format_log_message(msg: Message, found_token: str) -> str: - """Return the log message to send for `found_token` being censored in `msg`.""" - user_id, creation_timestamp, hmac = found_token.split('.') + def format_log_message(msg: Message, token: Token) -> str: + """Return the log message to send for `token` being censored in `msg`.""" return LOG_MESSAGE.format( author=msg.author, author_id=msg.author.id, channel=msg.channel.mention, - user_id=user_id, - timestamp=creation_timestamp, - hmac='x' * len(hmac), + user_id=token.user_id, + timestamp=token.timestamp, + hmac='x' * len(token.hmac), ) @classmethod - def find_token_in_message(cls, msg: Message) -> t.Optional[str]: + def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: """Return a seemingly valid token found in `msg` or `None` if no token is found.""" if msg.author.bot: return @@ -116,29 +123,15 @@ class TokenRemover(Cog): # Use findall rather than search to guard against method calls prematurely returning the # token check (e.g. `message.channel.send` also matches our token pattern) maybe_matches = TOKEN_RE.findall(msg.content) - for substr in maybe_matches: - if cls.is_maybe_token(substr): + for match_groups in maybe_matches: + token = Token(*match_groups) + if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): # Short-circuit on first match - return substr + return token # No matching substring return - @classmethod - def is_maybe_token(cls, test_str: str) -> bool: - """Check the provided string to see if it is a seemingly valid token.""" - try: - user_id, creation_timestamp, hmac = test_str.split('.') - except ValueError: - log.debug(f"Invalid token format in '{test_str}': does not have all 3 parts.") - return False - - if cls.is_valid_user_id(user_id) and cls.is_valid_timestamp(creation_timestamp): - return True - else: - log.debug(f"Invalid user ID or timestamp in '{test_str}'.") - return False - @staticmethod def is_valid_user_id(b64_content: str) -> bool: """ -- cgit v1.2.3 From bfe79efdfe699bf7289cba9db95d5637a7fb965a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 May 2020 19:46:51 -0700 Subject: Token remover: use finditer instead of findall It makes more sense to use the lazy function when the loop is already short-circuiting on the first valid token it finds. --- bot/cogs/token_remover.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index e5d0ae838..8913ca64d 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -120,11 +120,10 @@ class TokenRemover(Cog): if msg.author.bot: return - # Use findall rather than search to guard against method calls prematurely returning the + # Use finditer rather than search to guard against method calls prematurely returning the # token check (e.g. `message.channel.send` also matches our token pattern) - maybe_matches = TOKEN_RE.findall(msg.content) - for match_groups in maybe_matches: - token = Token(*match_groups) + for match in TOKEN_RE.finditer(msg.content): + token = Token(*match.groups()) if cls.is_valid_user_id(token.user_id) and cls.is_valid_timestamp(token.timestamp): # Short-circuit on first match return token -- cgit v1.2.3 From 5386eda1731bb8eae287c20ed70a76399db2ae0e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 May 2020 19:55:23 -0700 Subject: Token remover: specify Discord epoch in seconds The timestamp in the token is in seconds and is being compared against the epoch. To make life easier, they should use the same unit. Previously, the epoch was in milliseconds. --- bot/cogs/token_remover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 8913ca64d..46329e207 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -27,7 +27,7 @@ DELETION_MESSAGE_TEMPLATE = ( "Feel free to re-post it with the token removed. " "If you believe this was a mistake, please let us know!" ) -DISCORD_EPOCH = 1_420_070_400_000 +DISCORD_EPOCH = 1_420_070_400 TOKEN_EPOCH = 1_293_840_000 # Three parts delimited by dots: user ID, creation timestamp, HMAC. -- cgit v1.2.3 From 47886501fb7d030f1cb91c69413058e3ffcb76bf Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 May 2020 20:47:32 -0700 Subject: Test token regex won't match non-base64 characters --- tests/bot/cogs/test_token_remover.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 8e743a715..dbea5ad1b 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -144,10 +144,9 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): "x..z", " . . ", "\n.\n.\n", - "'.'.'", - '"."."', - "(.(.(", - ").).)" + "hellö.world.bye", + "base64.nötbåse64.morebase64", + "19jd3J.dfkm3d.€víł§tüff", ) for token in tokens: -- cgit v1.2.3 From e76099d48b9a895c48e58c5f5489886f4191eeb6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 May 2020 20:50:30 -0700 Subject: Add more valid tokens to test the regex with --- tests/bot/cogs/test_token_remover.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index dbea5ad1b..6a280f358 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -156,10 +156,12 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): def test_regex_valid_tokens(self): """Messages that look like tokens should be matched.""" - # Don't worry, the token's been invalidated. + # Don't worry, these tokens have been invalidated. tokens = ( - "x1.y2.z_3", - "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8" + "NDcyMjY1OTQzMDYy_DEzMz-y.XsyRkw.VXmErH7j511turNpfURmb0rVNm8", + "NDcyMjY1OTQzMDYyNDEzMzMy.Xrim9Q.Ysnu2wacjaKs7qnoo46S8Dm2us8", + "NDc1MDczNjI5Mzk5NTQ3OTA0.XsyR-w.sJf6omBPORBPju3WJEIAcwW9Zds", + "NDY3MjIzMjMwNjUwNzc3NjQx.XsySD_.s45jqDV_Iisn-symw0yDRrk_jf4", ) for token in tokens: -- cgit v1.2.3 From a8a216d0803b67a330ae092a17bea563f5012275 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 May 2020 21:02:24 -0700 Subject: Fix valid token regex test It was broken due to the addition of groups. Rather than returning the full match, `findall` returns groups if any exist. The test was comparing a tuple of groups to the token string, which was of course failing. Now `fullmatch` is used cause it's simpler - just check for `None` and don't worry about iterating matches to search. --- tests/bot/cogs/test_token_remover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 6a280f358..518bf91ca 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -166,8 +166,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): for token in tokens: with self.subTest(token=token): - results = token_remover.TOKEN_RE.findall(token) - self.assertIn(token, results) + results = token_remover.TOKEN_RE.fullmatch(token) + self.assertIsNotNone(results, f"{token} was not matched by the regex") def test_regex_matches_multiple_valid(self): """Should support multiple matches in the middle of a string.""" -- cgit v1.2.3 From 19cc849d4c70bc3e792460ad712aa308fa500462 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 May 2020 21:07:21 -0700 Subject: Fix multiple match text for token regex It has to account for the addition of groups. It's easiest to compare the entire string so `finditer` is used to return re.Match objects; the tuples of `findall` would be cumbersome. Also threw in a change to use `assertCountEqual` cause the order doesn't really matter. --- tests/bot/cogs/test_token_remover.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 518bf91ca..2ecfae2bd 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -174,8 +174,9 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): tokens = ["x.y.z", "a.b.c"] message = f"garbage {tokens[0]} hello {tokens[1]} world" - results = token_remover.TOKEN_RE.findall(message) - self.assertEqual(tokens, results) + results = token_remover.TOKEN_RE.finditer(message) + results = [match[0] for match in results] + self.assertCountEqual(tokens, results) @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") def test_is_maybe_token_missing_part_returns_false(self, valid_user, valid_time): -- cgit v1.2.3 From 300f8c093edea03855d94be179c64c328ec842ac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 25 May 2020 21:09:04 -0700 Subject: Use real token values for testing multiple matches in regex --- tests/bot/cogs/test_token_remover.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 2ecfae2bd..971bc93fc 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -171,12 +171,13 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): def test_regex_matches_multiple_valid(self): """Should support multiple matches in the middle of a string.""" - tokens = ["x.y.z", "a.b.c"] - message = f"garbage {tokens[0]} hello {tokens[1]} world" + token_1 = "NDY3MjIzMjMwNjUwNzc3NjQx.XsyWGg.uFNEQPCc4ePwGh7egG8UicQssz8" + token_2 = "NDcyMjY1OTQzMDYyNDEzMzMy.XsyWMw.l8XPnDqb0lp-EiQ2g_0xVFT1pyc" + message = f"garbage {token_1} hello {token_2} world" results = token_remover.TOKEN_RE.finditer(message) results = [match[0] for match in results] - self.assertCountEqual(tokens, results) + self.assertCountEqual((token_1, token_2), results) @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") def test_is_maybe_token_missing_part_returns_false(self, valid_user, valid_time): -- cgit v1.2.3 From 96db6087254c957fcb8fb45aad7ffcddb46ee839 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 27 May 2020 17:08:18 -0700 Subject: Switch findall to finditer in assertions `find_token_in_message` now uses the latter so the tests should adjust accordingly. --- tests/bot/cogs/test_token_remover.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 971bc93fc..4fff3ab33 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -94,18 +94,18 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): return_value = TokenRemover.find_token_in_message(self.msg) self.assertIsNone(return_value) - token_re.findall.assert_not_called() + token_re.finditer.assert_not_called() @autospec(TokenRemover, "is_maybe_token") @autospec("bot.cogs.token_remover", "TOKEN_RE") def test_find_token_no_matches_returns_none(self, token_re, is_maybe_token): """None should be returned if the regex matches no tokens in a message.""" - token_re.findall.return_value = () + token_re.finditer.return_value = () return_value = TokenRemover.find_token_in_message(self.msg) self.assertIsNone(return_value) - token_re.findall.assert_called_once_with(self.msg.content) + token_re.finditer.assert_called_once_with(self.msg.content) is_maybe_token.assert_not_called() @autospec(TokenRemover, "is_maybe_token") @@ -123,7 +123,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): return_value = TokenRemover.find_token_in_message(self.msg) self.assertEqual(return_value, matches[true_index]) - token_re.findall.assert_called_once_with(self.msg.content) + token_re.finditer.assert_called_once_with(self.msg.content) # assert_has_calls isn't used cause it'd allow for extra calls before or after. # The function should short-circuit, so nothing past true_index should have been used. -- cgit v1.2.3 From f937032466a4124bacf217d1bfd0af097fc3395d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 27 May 2020 19:31:55 -0700 Subject: Adjust token remover tests to use the Token NamedTuple --- tests/bot/cogs/test_token_remover.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 4fff3ab33..65bc1ee58 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -7,7 +7,7 @@ from discord import Colour from bot import constants from bot.cogs import token_remover from bot.cogs.moderation import ModLog -from bot.cogs.token_remover import TokenRemover +from bot.cogs.token_remover import Token, TokenRemover from tests.helpers import MockBot, MockMessage, autospec @@ -224,17 +224,19 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @autospec("bot.cogs.token_remover", "LOG_MESSAGE") def test_format_log_message(self, log_message): """Should correctly format the log message with info from the message and token.""" + token = Token("NDY3MjIzMjMwNjUwNzc3NjQx", "XsySD_", "s45jqDV_Iisn-symw0yDRrk_jf4") log_message.format.return_value = "Howdy" - return_value = TokenRemover.format_log_message(self.msg, "MTIz.DN9R_A.xyz") + + return_value = TokenRemover.format_log_message(self.msg, token) self.assertEqual(return_value, log_message.format.return_value) log_message.format.assert_called_once_with( author=self.msg.author, author_id=self.msg.author.id, channel=self.msg.channel.mention, - user_id="MTIz", - timestamp="DN9R_A", - hmac="xxx", + user_id=token.user_id, + timestamp=token.timestamp, + hmac="x" * len(token.hmac), ) @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) @@ -244,7 +246,7 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): """Should delete the message and send a mod log.""" cog = TokenRemover(self.bot) mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) - token = "MTIz.DN9R_A.xyz" + token = mock.create_autospec(Token, spec_set=True, instance=True) log_msg = "testing123" mod_log_property.return_value = mod_log -- cgit v1.2.3 From 12b8f5002807144451a313180c639bf6b4925f2e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 27 May 2020 20:00:33 -0700 Subject: Add more thorough and realistic inputs for token ID and timestamp tests The tests for valid inputs and invalid inputs were split to make them more readable. --- tests/bot/cogs/test_token_remover.py | 70 ++++++++++++++++++++++++++---------- 1 file changed, 52 insertions(+), 18 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 65bc1ee58..ffe76865a 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -24,31 +24,65 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.msg.author.__str__ = MagicMock(return_value=self.msg.author.name) self.msg.author.avatar_url_as.return_value = "picture-lemon.png" - def test_is_valid_user_id(self): - """Should correctly discern valid user IDs and ignore non-numeric and non-ASCII IDs.""" - subtests = ( - ("MTIz", True), # base64(123) - ("YWJj", False), # base64(abc) - ("λδµ", False), + def test_is_valid_user_id_valid(self): + """Should consider user IDs valid if they decode entirely to ASCII digits.""" + ids = ( + "NDcyMjY1OTQzMDYyNDEzMzMy", + "NDc1MDczNjI5Mzk5NTQ3OTA0", + "NDY3MjIzMjMwNjUwNzc3NjQx", ) - for user_id, is_valid in subtests: - with self.subTest(user_id=user_id, is_valid=is_valid): + for user_id in ids: + with self.subTest(user_id=user_id): result = TokenRemover.is_valid_user_id(user_id) - self.assertIs(result, is_valid) + self.assertTrue(result) + + def test_is_valid_user_id_invalid(self): + """Should consider non-digit and non-ASCII IDs invalid.""" + ids = ( + ("SGVsbG8gd29ybGQ", "non-digit ASCII"), + ("0J_RgNC40LLQtdGCINC80LjRgA", "cyrillic text"), + ("4pO14p6L4p6C4pG34p264pGl8J-EiOKSj-KCieKBsA", "Unicode digits"), + ("4oaA4oaB4oWh4oWi4Lyz4Lyq4Lyr4LG9", "Unicode numerals"), + ("8J2fjvCdn5nwnZ-k8J2fr_Cdn7rgravvvJngr6c", "Unicode decimals"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), + ) - def test_is_valid_timestamp(self): - """Should correctly discern valid timestamps.""" - subtests = ( - ("DN9r_A", True), - ("MTIz", False), # base64(123) - ("λδµ", False), + for user_id, msg in ids: + with self.subTest(msg=msg): + result = TokenRemover.is_valid_user_id(user_id) + self.assertFalse(result) + + def test_is_valid_timestamp_valid(self): + """Should consider timestamps valid if they're greater than the Discord epoch.""" + timestamps = ( + "XsyRkw", + "Xrim9Q", + "XsyR-w", + "XsySD_", + "Dn9r_A", + ) + + for timestamp in timestamps: + with self.subTest(timestamp=timestamp): + result = TokenRemover.is_valid_timestamp(timestamp) + self.assertTrue(result) + + def test_is_valid_timestamp_invalid(self): + """Should consider timestamps invalid if they're before Discord epoch or can't be parsed.""" + timestamps = ( + ("B4Yffw", "DISCORD_EPOCH - TOKEN_EPOCH - 1"), + ("ew", "123"), + ("AoIKgA", "42076800"), + ("{hello}[world]&(bye!)", "ASCII invalid Base64"), + ("Þíß-ï§-ňøẗ-våłìÐ", "Unicode invalid Base64"), ) - for timestamp, is_valid in subtests: - with self.subTest(timestamp=timestamp, is_valid=is_valid): + for timestamp, msg in timestamps: + with self.subTest(msg=msg): result = TokenRemover.is_valid_timestamp(timestamp) - self.assertIs(result, is_valid) + self.assertFalse(result) def test_mod_log_property(self): """The `mod_log` property should ask the bot to return the `ModLog` cog.""" -- cgit v1.2.3 From 67472080fef5c38b21d74daa2178c3f35081b58f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 28 May 2020 19:52:41 -0700 Subject: Remove is_maybe_token tests The function was removed due to redundancy. Therefore, its tests are obsolete. --- tests/bot/cogs/test_token_remover.py | 33 --------------------------------- 1 file changed, 33 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index ffe76865a..5dd12636c 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -213,39 +213,6 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): results = [match[0] for match in results] self.assertCountEqual((token_1, token_2), results) - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") - def test_is_maybe_token_missing_part_returns_false(self, valid_user, valid_time): - """False should be returned for tokens which do not have all 3 parts.""" - return_value = TokenRemover.is_maybe_token("x.y") - - self.assertFalse(return_value) - valid_user.assert_not_called() - valid_time.assert_not_called() - - @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") - def test_is_maybe_token(self, valid_user, valid_time): - """Should return True if the user ID and timestamp are valid or return False otherwise.""" - subtests = ( - (False, True, False), - (True, False, False), - (True, True, True), - ) - - for user_return, time_return, expected in subtests: - valid_user.reset_mock() - valid_time.reset_mock() - - with self.subTest(user_return=user_return, time_return=time_return, expected=expected): - valid_user.return_value = user_return - valid_time.return_value = time_return - - actual = TokenRemover.is_maybe_token("x.y.z") - self.assertIs(actual, expected) - - valid_user.assert_called_once_with("x") - if user_return: - valid_time.assert_called_once_with("y") - async def test_delete_message(self): """The message should be deleted, and a message should be sent to the same channel.""" await TokenRemover.delete_message(self.msg) -- cgit v1.2.3 From 84cd8235863acc80b7f140309424c33180cc34ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 28 May 2020 20:32:48 -0700 Subject: Adjust find_token_in_message tests for the recent cog changes It now supports the changes that switched to finditer, added match groups, and added the Token NamedTuple. It also accounts for the is_maybe_token function being removed. For the sake of simplicity, call assertions on is_valid_user_id and is_valid_timestamp were not made. --- tests/bot/cogs/test_token_remover.py | 39 ++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 5dd12636c..8238e235a 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -1,4 +1,5 @@ import unittest +from re import Match from unittest import mock from unittest.mock import MagicMock @@ -130,9 +131,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.finditer.assert_not_called() - @autospec(TokenRemover, "is_maybe_token") @autospec("bot.cogs.token_remover", "TOKEN_RE") - def test_find_token_no_matches_returns_none(self, token_re, is_maybe_token): + def test_find_token_no_matches(self, token_re): """None should be returned if the regex matches no tokens in a message.""" token_re.finditer.return_value = () @@ -140,30 +140,31 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(return_value) token_re.finditer.assert_called_once_with(self.msg.content) - is_maybe_token.assert_not_called() - @autospec(TokenRemover, "is_maybe_token") + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec("bot.cogs.token_remover", "Token") @autospec("bot.cogs.token_remover", "TOKEN_RE") - def test_find_token_returns_found_token(self, token_re, is_maybe_token): - """The found token should be returned.""" - true_index = 1 - matches = ("foo", "bar", "baz") - side_effects = [False] * len(matches) - side_effects[true_index] = True - - token_re.findall.return_value = matches - is_maybe_token.side_effect = side_effects + def test_find_token_valid_match(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + """The first match with a valid user ID and timestamp should be returned as a `Token`.""" + matches = [ + mock.create_autospec(Match, spec_set=True, instance=True), + mock.create_autospec(Match, spec_set=True, instance=True), + ] + tokens = [ + mock.create_autospec(Token, spec_set=True, instance=True), + mock.create_autospec(Token, spec_set=True, instance=True), + ] + + token_re.finditer.return_value = matches + token_cls.side_effect = tokens + is_valid_id.side_effect = (False, True) # The 1st match will be invalid, 2nd one valid. + is_valid_timestamp.return_value = True return_value = TokenRemover.find_token_in_message(self.msg) - self.assertEqual(return_value, matches[true_index]) + self.assertEqual(tokens[1], return_value) token_re.finditer.assert_called_once_with(self.msg.content) - # assert_has_calls isn't used cause it'd allow for extra calls before or after. - # The function should short-circuit, so nothing past true_index should have been used. - calls = [mock.call(match) for match in matches[:true_index + 1]] - self.assertEqual(is_maybe_token.mock_calls, calls) - def test_regex_invalid_tokens(self): """Messages without anything looking like a token are not matched.""" tokens = ( -- cgit v1.2.3 From 5930a044b8347019d474a809fc86f89263574ad0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 28 May 2020 20:33:34 -0700 Subject: Test find_token_in_message returns None for invalid matches This covers the case when a token is matched, but its user ID and timestamp turn out to be invalid. --- tests/bot/cogs/test_token_remover.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 8238e235a..9b4b04ecd 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -165,6 +165,21 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(tokens[1], return_value) token_re.finditer.assert_called_once_with(self.msg.content) + @autospec(TokenRemover, "is_valid_user_id", "is_valid_timestamp") + @autospec("bot.cogs.token_remover", "Token") + @autospec("bot.cogs.token_remover", "TOKEN_RE") + def test_find_token_invalid_matches(self, token_re, token_cls, is_valid_id, is_valid_timestamp): + """None should be returned if no matches have valid user IDs or timestamps.""" + token_re.finditer.return_value = [mock.create_autospec(Match, spec_set=True, instance=True)] + token_cls.return_value = mock.create_autospec(Token, spec_set=True, instance=True) + is_valid_id.return_value = False + is_valid_timestamp.return_value = False + + return_value = TokenRemover.find_token_in_message(self.msg) + + self.assertIsNone(return_value) + token_re.finditer.assert_called_once_with(self.msg.content) + def test_regex_invalid_tokens(self): """Messages without anything looking like a token are not matched.""" tokens = ( -- cgit v1.2.3 From 2d36b0a89410c229eb8c7629c49d46ffb7f1523d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 29 May 2020 09:18:26 +0300 Subject: Filtering: Implement bad words detection in nicknames --- bot/cogs/filtering.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 1d9fddb12..d54beeabf 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -1,8 +1,10 @@ import logging import re +from datetime import datetime, timedelta from typing import Optional, Union import discord.errors +from dateutil import parser from dateutil.relativedelta import relativedelta from discord import Colour, Member, Message, TextChannel from discord.ext.commands import Cog @@ -14,6 +16,7 @@ from bot.constants import ( Channels, Colours, Filter, Icons, URLs ) +from bot.utils.redis_cache import RedisCache log = logging.getLogger(__name__) @@ -52,6 +55,9 @@ def expand_spoilers(text: str) -> str: class Filtering(Cog): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" + # Redis cache for last bad words in nickname alert sent per user. + name_alerts = RedisCache() + def __init__(self, bot: Bot): self.bot = bot @@ -126,6 +132,41 @@ class Filtering(Cog): delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) + @Cog.listener('on_message') + async def bad_words_in_name(self, msg: Message) -> None: + """Check bad words from user display name. When there is more than 3 days after last alert, send new alert.""" + if await self.name_alerts.contains(msg.author.id): + last_alert = parser.isoparse(await self.name_alerts.get(msg.author.id)) + + # When there is less than 3 days after last alert, return + if datetime.now() - timedelta(days=3) < last_alert: + return + + # Check does nickname have match in filters. + matches = [] + for pattern in WATCHLIST_PATTERNS: + match = pattern.search(msg.author.display_name) + if match: + matches.append(match) + + # When there is any match, then send alert to mods. + if matches: + log_string = ( + f"**User:** {msg.author.mention} (`{msg.author.id}`)\n" + f"**Display Name:** {msg.author.display_name}\n" + f"**Bad Matches:** {', '.join(match.group() for match in matches)}" + ) + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colours.soft_red, + title="Username filtering alert", + text=log_string, + channel_id=Channels.mod_alerts + ) + + # Update time when alert sent + await self.name_alerts.set(msg.author.id, datetime.now().isoformat()) + async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" # Should we filter this message? -- cgit v1.2.3 From 9ee955454141d093f1cd71fc84a5340f803fa142 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Fri, 29 May 2020 19:21:39 +0300 Subject: Filtering: Refactor bad names checking - Make `bad_words_in_name` and attach it to current `on_message`. - Implement `asyncio.Lock` to avoid race conditions. - Made that this first check is there matches and when there is, check for alert. --- bot/cogs/filtering.py | 67 +++++++++++++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 31 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index d54beeabf..17113d551 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -1,3 +1,4 @@ +import asyncio import logging import re from datetime import datetime, timedelta @@ -60,6 +61,7 @@ class Filtering(Cog): def __init__(self, bot: Bot): self.bot = bot + self.name_lock: Optional[asyncio.Lock] = None staff_mistake_str = "If you believe this was a mistake, please let staff know!" self.filters = { @@ -118,6 +120,7 @@ class Filtering(Cog): async def on_message(self, msg: Message) -> None: """Invoke message filter for new messages.""" await self._filter_message(msg) + await self.bad_words_in_name(msg) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: @@ -132,40 +135,42 @@ class Filtering(Cog): delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) - @Cog.listener('on_message') async def bad_words_in_name(self, msg: Message) -> None: """Check bad words from user display name. When there is more than 3 days after last alert, send new alert.""" - if await self.name_alerts.contains(msg.author.id): - last_alert = parser.isoparse(await self.name_alerts.get(msg.author.id)) - - # When there is less than 3 days after last alert, return - if datetime.now() - timedelta(days=3) < last_alert: - return - - # Check does nickname have match in filters. - matches = [] - for pattern in WATCHLIST_PATTERNS: - match = pattern.search(msg.author.display_name) - if match: - matches.append(match) - - # When there is any match, then send alert to mods. - if matches: - log_string = ( - f"**User:** {msg.author.mention} (`{msg.author.id}`)\n" - f"**Display Name:** {msg.author.display_name}\n" - f"**Bad Matches:** {', '.join(match.group() for match in matches)}" - ) - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colours.soft_red, - title="Username filtering alert", - text=log_string, - channel_id=Channels.mod_alerts - ) + if not self.name_lock: + self.name_lock = asyncio.Lock() + + # Use lock to avoid race conditions + async with self.name_lock: + # Check does nickname have match in filters. + matches = [] + for pattern in WATCHLIST_PATTERNS: + match = pattern.search(msg.author.display_name) + if match: + matches.append(match) + + if matches: + last_alert = await self.name_alerts.get(msg.author.id) + if last_alert: + last_alert = parser.isoparse(last_alert) + if datetime.now() - timedelta(days=3) < last_alert: + return + + log_string = ( + f"**User:** {msg.author.mention} (`{msg.author.id}`)\n" + f"**Display Name:** {msg.author.display_name}\n" + f"**Bad Matches:** {', '.join(match.group() for match in matches)}" + ) + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colours.soft_red, + title="Username filtering alert", + text=log_string, + channel_id=Channels.mod_alerts + ) - # Update time when alert sent - await self.name_alerts.set(msg.author.id, datetime.now().isoformat()) + # Update time when alert sent + await self.name_alerts.set(msg.author.id, datetime.now().isoformat()) async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" -- cgit v1.2.3 From 0e12ff189a416127421a878c2421d7f5a369d26e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 30 May 2020 16:32:07 +0300 Subject: Filtering: Create lock in `__init__` Move lock creation from `bad_words_in_name` to `__init__` --- bot/cogs/filtering.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 17113d551..c57ab0688 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -61,7 +61,7 @@ class Filtering(Cog): def __init__(self, bot: Bot): self.bot = bot - self.name_lock: Optional[asyncio.Lock] = None + self.name_lock = asyncio.Lock() staff_mistake_str = "If you believe this was a mistake, please let staff know!" self.filters = { @@ -137,9 +137,6 @@ class Filtering(Cog): async def bad_words_in_name(self, msg: Message) -> None: """Check bad words from user display name. When there is more than 3 days after last alert, send new alert.""" - if not self.name_lock: - self.name_lock = asyncio.Lock() - # Use lock to avoid race conditions async with self.name_lock: # Check does nickname have match in filters. -- cgit v1.2.3 From e5534bc73d4b2d7b4b87326ab3b729b955e7c344 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 08:26:23 +0300 Subject: Source: Fix docstrings Co-authored-by: Mark --- bot/cogs/source.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 5b8d8ded2..6e71ae5b2 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -10,7 +10,7 @@ from bot.constants import URLs class SourceConverter(Converter): - """Convert argument to help command, command or Cog.""" + """Convert an argument into a help command, command, or cog.""" async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: """ @@ -37,14 +37,14 @@ class SourceConverter(Converter): class BotSource(Cog): - """Cog of Python Discord Python bot project source information.""" + """Displays information about the bot's source code.""" def __init__(self, bot: Bot): self.bot = bot @command(name="source", aliases=("src",)) async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: - """Get GitHub link and information about help command, command or Cog.""" + """Display information and a GitHub link to the source code of a command or cog.""" if not source_item: embed = Embed(title="Bot GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") @@ -95,5 +95,5 @@ class BotSource(Cog): def setup(bot: Bot) -> None: - """Load `Source` cog.""" + """Load the BotSource cog.""" bot.add_cog(BotSource(bot)) -- cgit v1.2.3 From befbb1afed56b60336c8668a9134e28e069e0eac Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 08:47:46 +0300 Subject: Source: Remove checks running from source command --- bot/cogs/source.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 6e71ae5b2..7076c1eb3 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -52,7 +52,7 @@ class BotSource(Cog): return url = self.get_source_link(source_item) - await ctx.send(embed=await self.build_embed(url, source_item, ctx)) + await ctx.send(embed=await self.build_embed(url, source_item)) @staticmethod def get_source_link(source_item: Union[HelpCommand, Command, Cog]) -> str: @@ -73,7 +73,7 @@ class BotSource(Cog): return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" @staticmethod - async def build_embed(link: str, source_object: Union[HelpCommand, Command, Cog], ctx: Context) -> Embed: + async def build_embed(link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" @@ -88,9 +88,6 @@ class BotSource(Cog): embed = Embed(title=title, description=description) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") - if isinstance(source_object, Command): - embed.add_field(name="Can be used by you here?", value=await source_object.can_run(ctx)) - return embed -- cgit v1.2.3 From 756ed4286339a26e6e1e4edb7431a026d8817881 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:09:35 +0300 Subject: Source: Direct aliases to their original commands --- bot/cogs/source.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 7076c1eb3..0880dd62f 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -54,15 +54,20 @@ class BotSource(Cog): url = self.get_source_link(source_item) await ctx.send(embed=await self.build_embed(url, source_item)) - @staticmethod - def get_source_link(source_item: Union[HelpCommand, Command, Cog]) -> str: + def get_source_link(self, source_item: Union[HelpCommand, Command, Cog]) -> str: """Build GitHub link of source item.""" if isinstance(source_item, HelpCommand): src = type(source_item) filename = inspect.getsourcefile(src) elif isinstance(source_item, Command): - src = source_item.callback.__code__ - filename = src.co_filename + if source_item.cog_name == "Alias": + cmd_name = source_item.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + src = cmd.callback.__code__ + filename = src.co_filename + else: + src = source_item.callback.__code__ + filename = src.co_filename else: src = type(source_item) filename = inspect.getsourcefile(src) @@ -72,15 +77,20 @@ class BotSource(Cog): return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" - @staticmethod - async def build_embed(link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: + async def build_embed(self, link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" description = source_object.__doc__ elif isinstance(source_object, Command): + if source_object.cog_name == "Alias": + cmd_name = source_object.callback.__name__.replace("_alias", "") + cmd = self.bot.get_command(cmd_name.replace("_", " ")) + description = cmd.help + else: + description = source_object.help + title = source_object.qualified_name - description = source_object.help else: title = source_object.qualified_name description = source_object.description -- cgit v1.2.3 From a064e2b6b4f6fabdb6a0fae5de5ee957dbb85b75 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:31:07 +0300 Subject: Source: Implement tags file showing to source command --- bot/cogs/source.py | 43 +++++++++++++++++++++++++++---------------- 1 file changed, 27 insertions(+), 16 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 0880dd62f..1c702f81d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -10,21 +10,22 @@ from bot.constants import URLs class SourceConverter(Converter): - """Convert an argument into a help command, command, or cog.""" - - async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog]: - """ - Convert argument into source object. - - Order how arguments is checked: - 1. When argument is `help`, return bot help command - 2. When argument is valid command, return this command - 3. When argument is valid Cog, return this Cog - 4. Otherwise raise `BadArgument` error - """ + """Convert an argument into a help command, tag, command, or cog.""" + + async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog, str]: + """Convert argument into source object.""" if argument.lower() == "help": return ctx.bot.help_command + tags_cog = ctx.bot.get_cog("Tags") + + if argument.lower() in tags_cog._cache: + tag = argument.lower() + if tags_cog._cache[tag]["restricted_to"] != "developers": + return f"bot/resources/tags/{tags_cog._cache[tag]['restricted_to']}/{tag}.md" + else: + return f"bot/resources/tags/{tag}.md" + cmd = ctx.bot.get_command(argument) if cmd: return cmd @@ -44,7 +45,7 @@ class BotSource(Cog): @command(name="source", aliases=("src",)) async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: - """Display information and a GitHub link to the source code of a command or cog.""" + """Display information and a GitHub link to the source code of a command, tag, or cog.""" if not source_item: embed = Embed(title="Bot GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") @@ -54,7 +55,7 @@ class BotSource(Cog): url = self.get_source_link(source_item) await ctx.send(embed=await self.build_embed(url, source_item)) - def get_source_link(self, source_item: Union[HelpCommand, Command, Cog]) -> str: + def get_source_link(self, source_item: Union[HelpCommand, Command, Cog, str]) -> str: """Build GitHub link of source item.""" if isinstance(source_item, HelpCommand): src = type(source_item) @@ -68,14 +69,21 @@ class BotSource(Cog): else: src = source_item.callback.__code__ filename = src.co_filename + elif isinstance(source_item, str): + filename = source_item else: src = type(source_item) filename = inspect.getsourcefile(src) - lines, first_line_no = inspect.getsourcelines(src) + if not isinstance(source_item, str): + lines, first_line_no = inspect.getsourcelines(src) + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" + else: + lines_extension = "" + file_location = os.path.relpath(filename) - return f"{URLs.github_bot_repo}/blob/master/{file_location}#L{first_line_no}-L{first_line_no+len(lines)-1}" + return f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" async def build_embed(self, link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: """Build embed based on source object.""" @@ -91,6 +99,9 @@ class BotSource(Cog): description = source_object.help title = source_object.qualified_name + elif isinstance(source_object, str): + title = f"Tag: {source_object.split('/')[-1].split('.')[0]}" + description = "" else: title = source_object.qualified_name description = source_object.description -- cgit v1.2.3 From bb6cc2193cad398d68db29d4f991fce94ae06549 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:44:34 +0300 Subject: Source: Migrate from os.path to Path --- bot/cogs/source.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 1c702f81d..21f18f45f 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,5 +1,5 @@ import inspect -import os +from pathlib import Path from typing import Union from discord import Embed @@ -22,9 +22,9 @@ class SourceConverter(Converter): if argument.lower() in tags_cog._cache: tag = argument.lower() if tags_cog._cache[tag]["restricted_to"] != "developers": - return f"bot/resources/tags/{tags_cog._cache[tag]['restricted_to']}/{tag}.md" + return f"/bot/bot/resources/tags/{tags_cog._cache[tag]['restricted_to']}/{tag}.md" else: - return f"bot/resources/tags/{tag}.md" + return f"/bot/bot/resources/tags/{tag}.md" cmd = ctx.bot.get_command(argument) if cmd: @@ -74,6 +74,7 @@ class BotSource(Cog): else: src = type(source_item) filename = inspect.getsourcefile(src) + print(filename) if not isinstance(source_item, str): lines, first_line_no = inspect.getsourcelines(src) @@ -81,7 +82,8 @@ class BotSource(Cog): else: lines_extension = "" - file_location = os.path.relpath(filename) + file_location = Path(filename).relative_to("/bot/") + print(file_location) return f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" -- cgit v1.2.3 From 9db1377d8c4796d0e2eedc8eeee039566a7770b5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:46:15 +0300 Subject: Source: Move big unions to variable of type --- bot/cogs/source.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 21f18f45f..40200eb69 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -8,11 +8,13 @@ from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, from bot.bot import Bot from bot.constants import URLs +SourceType = Union[HelpCommand, Command, Cog, str] + class SourceConverter(Converter): """Convert an argument into a help command, tag, command, or cog.""" - async def convert(self, ctx: Context, argument: str) -> Union[HelpCommand, Command, Cog, str]: + async def convert(self, ctx: Context, argument: str) -> SourceType: """Convert argument into source object.""" if argument.lower() == "help": return ctx.bot.help_command @@ -55,7 +57,7 @@ class BotSource(Cog): url = self.get_source_link(source_item) await ctx.send(embed=await self.build_embed(url, source_item)) - def get_source_link(self, source_item: Union[HelpCommand, Command, Cog, str]) -> str: + def get_source_link(self, source_item: SourceType) -> str: """Build GitHub link of source item.""" if isinstance(source_item, HelpCommand): src = type(source_item) @@ -87,7 +89,7 @@ class BotSource(Cog): return f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" - async def build_embed(self, link: str, source_object: Union[HelpCommand, Command, Cog]) -> Embed: + async def build_embed(self, link: str, source_object: SourceType) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" -- cgit v1.2.3 From 2db5add0893d090b7bdbc172ea2e83044301b2f6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 09:54:01 +0300 Subject: Source: Implement file and line showing in source embed footer --- bot/cogs/source.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 40200eb69..fbe519c43 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -1,6 +1,6 @@ import inspect from pathlib import Path -from typing import Union +from typing import Optional, Tuple, Union from discord import Embed from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command @@ -54,10 +54,10 @@ class BotSource(Cog): await ctx.send(embed=embed) return - url = self.get_source_link(source_item) - await ctx.send(embed=await self.build_embed(url, source_item)) + url, location, first_line = self.get_source_link(source_item) + await ctx.send(embed=await self.build_embed(url, source_item, location, first_line)) - def get_source_link(self, source_item: SourceType) -> str: + def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: """Build GitHub link of source item.""" if isinstance(source_item, HelpCommand): src = type(source_item) @@ -82,14 +82,15 @@ class BotSource(Cog): lines, first_line_no = inspect.getsourcelines(src) lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" else: + first_line_no = None lines_extension = "" file_location = Path(filename).relative_to("/bot/") - print(file_location) + url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" - return f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" + return url, file_location, first_line_no or None - async def build_embed(self, link: str, source_object: SourceType) -> Embed: + async def build_embed(self, link: str, source_object: SourceType, loc: str, first_line: Optional[int]) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" @@ -112,6 +113,8 @@ class BotSource(Cog): embed = Embed(title=title, description=description) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") + line_text = f":{first_line}" if first_line else "" + embed.set_footer(text=f"{loc}{line_text}") return embed -- cgit v1.2.3 From ff308186375eee5ddf734d9d0d49879c49995b29 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:53:28 +0300 Subject: Source: Show only first line of every source item docstring instead full --- bot/cogs/source.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index fbe519c43..232ee618e 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -76,7 +76,6 @@ class BotSource(Cog): else: src = type(source_item) filename = inspect.getsourcefile(src) - print(filename) if not isinstance(source_item, str): lines, first_line_no = inspect.getsourcelines(src) @@ -94,14 +93,14 @@ class BotSource(Cog): """Build embed based on source object.""" if isinstance(source_object, HelpCommand): title = "Help" - description = source_object.__doc__ + description = source_object.__doc__.splitlines()[1] elif isinstance(source_object, Command): if source_object.cog_name == "Alias": cmd_name = source_object.callback.__name__.replace("_alias", "") cmd = self.bot.get_command(cmd_name.replace("_", " ")) - description = cmd.help + description = cmd.short_doc else: - description = source_object.help + description = source_object.short_doc title = source_object.qualified_name elif isinstance(source_object, str): @@ -109,7 +108,7 @@ class BotSource(Cog): description = "" else: title = source_object.qualified_name - description = source_object.description + description = source_object.description.splitlines()[0] embed = Embed(title=title, description=description) embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") -- cgit v1.2.3 From 522c7489ea86d5c150145d610fc3a0fef7bd16bc Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:55:14 +0300 Subject: Source: Add thumbnail to source command bot repo embed --- bot/cogs/source.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 232ee618e..3f03f790c 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -51,6 +51,7 @@ class BotSource(Cog): if not source_item: embed = Embed(title="Bot GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") + embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") await ctx.send(embed=embed) return -- cgit v1.2.3 From ed8298cad287c2bc37566d2688b77dc64d62a7af Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:57:29 +0300 Subject: Source: Few text fixes, made help command detection better --- bot/cogs/source.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 3f03f790c..32f8d5e08 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -16,7 +16,7 @@ class SourceConverter(Converter): async def convert(self, ctx: Context, argument: str) -> SourceType: """Convert argument into source object.""" - if argument.lower() == "help": + if argument.lower().startswith("help"): return ctx.bot.help_command tags_cog = ctx.bot.get_cog("Tags") @@ -49,7 +49,7 @@ class BotSource(Cog): async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: """Display information and a GitHub link to the source code of a command, tag, or cog.""" if not source_item: - embed = Embed(title="Bot GitHub Repository") + embed = Embed(title="Bot's GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") embed.set_thumbnail(url="https://avatars1.githubusercontent.com/u/9919") await ctx.send(embed=embed) @@ -93,7 +93,7 @@ class BotSource(Cog): async def build_embed(self, link: str, source_object: SourceType, loc: str, first_line: Optional[int]) -> Embed: """Build embed based on source object.""" if isinstance(source_object, HelpCommand): - title = "Help" + title = "Help Command" description = source_object.__doc__.splitlines()[1] elif isinstance(source_object, Command): if source_object.cog_name == "Alias": -- cgit v1.2.3 From 0a4b365f5efdb31450647b8d628b3787142d4617 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:58:54 +0300 Subject: Source: Add command and cog prefixes to title of embed --- bot/cogs/source.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 32f8d5e08..9e6109ca2 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -103,12 +103,12 @@ class BotSource(Cog): else: description = source_object.short_doc - title = source_object.qualified_name + title = f"Command: {source_object.qualified_name}" elif isinstance(source_object, str): title = f"Tag: {source_object.split('/')[-1].split('.')[0]}" description = "" else: - title = source_object.qualified_name + title = f"Cog: {source_object.qualified_name}" description = source_object.description.splitlines()[0] embed = Embed(title=title, description=description) -- cgit v1.2.3 From 0007fb7e50f273108e70bcafbd736bfc75ce3e51 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 11:59:44 +0300 Subject: Source: In converter move cog checking before command --- bot/cogs/source.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 9e6109ca2..a5f90e490 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -28,14 +28,14 @@ class SourceConverter(Converter): else: return f"/bot/bot/resources/tags/{tag}.md" - cmd = ctx.bot.get_command(argument) - if cmd: - return cmd - cog = ctx.bot.get_cog(argument) if cog: return cog + cmd = ctx.bot.get_command(argument) + if cmd: + return cmd + raise BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") -- cgit v1.2.3 From aa83e72bd28f822c6ba84d73c5be05c6aea5d59b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:01:26 +0300 Subject: Source: Simplify imports --- bot/cogs/source.py | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index a5f90e490..5668ab6c6 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -3,18 +3,18 @@ from pathlib import Path from typing import Optional, Tuple, Union from discord import Embed -from discord.ext.commands import BadArgument, Cog, Command, Context, Converter, HelpCommand, command +from discord.ext import commands from bot.bot import Bot from bot.constants import URLs -SourceType = Union[HelpCommand, Command, Cog, str] +SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str] -class SourceConverter(Converter): +class SourceConverter(commands.Converter): """Convert an argument into a help command, tag, command, or cog.""" - async def convert(self, ctx: Context, argument: str) -> SourceType: + async def convert(self, ctx: commands.Context, argument: str) -> SourceType: """Convert argument into source object.""" if argument.lower().startswith("help"): return ctx.bot.help_command @@ -36,17 +36,17 @@ class SourceConverter(Converter): if cmd: return cmd - raise BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") + raise commands.BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") -class BotSource(Cog): +class BotSource(commands.Cog): """Displays information about the bot's source code.""" def __init__(self, bot: Bot): self.bot = bot - @command(name="source", aliases=("src",)) - async def source_command(self, ctx: Context, *, source_item: SourceConverter = None) -> None: + @commands.command(name="source", aliases=("src",)) + async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: """Display information and a GitHub link to the source code of a command, tag, or cog.""" if not source_item: embed = Embed(title="Bot's GitHub Repository") @@ -60,10 +60,10 @@ class BotSource(Cog): def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: """Build GitHub link of source item.""" - if isinstance(source_item, HelpCommand): + if isinstance(source_item, commands.HelpCommand): src = type(source_item) filename = inspect.getsourcefile(src) - elif isinstance(source_item, Command): + elif isinstance(source_item, commands.Command): if source_item.cog_name == "Alias": cmd_name = source_item.callback.__name__.replace("_alias", "") cmd = self.bot.get_command(cmd_name.replace("_", " ")) @@ -92,10 +92,10 @@ class BotSource(Cog): async def build_embed(self, link: str, source_object: SourceType, loc: str, first_line: Optional[int]) -> Embed: """Build embed based on source object.""" - if isinstance(source_object, HelpCommand): + if isinstance(source_object, commands.HelpCommand): title = "Help Command" description = source_object.__doc__.splitlines()[1] - elif isinstance(source_object, Command): + elif isinstance(source_object, commands.Command): if source_object.cog_name == "Alias": cmd_name = source_object.callback.__name__.replace("_alias", "") cmd = self.bot.get_command(cmd_name.replace("_", " ")) -- cgit v1.2.3 From 30510d6cfd877e5441022b8a8d893871fbf2a0a9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:16:26 +0300 Subject: Source: Show aliases on title of command source embed --- bot/cogs/source.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 5668ab6c6..8fd8cbed4 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -103,7 +103,8 @@ class BotSource(commands.Cog): else: description = source_object.short_doc - title = f"Command: {source_object.qualified_name}" + aliases_string = f" (or {', '.join(source_object.aliases)})" if source_object.aliases else "" + title = f"Command: {source_object.qualified_name}{aliases_string}" elif isinstance(source_object, str): title = f"Tag: {source_object.split('/')[-1].split('.')[0]}" description = "" -- cgit v1.2.3 From 2c0cb510219a27a875628ff4453be2ba7f0a9d7f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:17:10 +0300 Subject: Source: Include tag into converter's `BadArgument` raising --- bot/cogs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 8fd8cbed4..a3922297a 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -36,7 +36,7 @@ class SourceConverter(commands.Converter): if cmd: return cmd - raise commands.BadArgument(f"Unable to convert `{argument}` to valid command or Cog.") + raise commands.BadArgument(f"Unable to convert `{argument}` to valid command, tag, or Cog.") class BotSource(commands.Cog): -- cgit v1.2.3 From 4bee4f5e4e5258606da38fedc9026467dac007ae Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:24:29 +0300 Subject: Filtering: Use POSIX instead ISO format to storage alert cooldowns --- bot/cogs/filtering.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 17113d551..d1abd1193 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -5,7 +5,6 @@ from datetime import datetime, timedelta from typing import Optional, Union import discord.errors -from dateutil import parser from dateutil.relativedelta import relativedelta from discord import Colour, Member, Message, TextChannel from discord.ext.commands import Cog @@ -152,7 +151,7 @@ class Filtering(Cog): if matches: last_alert = await self.name_alerts.get(msg.author.id) if last_alert: - last_alert = parser.isoparse(last_alert) + last_alert = datetime.fromtimestamp(last_alert) if datetime.now() - timedelta(days=3) < last_alert: return @@ -170,7 +169,7 @@ class Filtering(Cog): ) # Update time when alert sent - await self.name_alerts.set(msg.author.id, datetime.now().isoformat()) + await self.name_alerts.set(msg.author.id, datetime.now().timestamp()) async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" -- cgit v1.2.3 From 33a030689d8dbe68168f8e371bbcce519f24685a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:25:39 +0300 Subject: Filtering: Rename `bad_words_in_name` to `check_is_bad_words_in_name` --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index d1abd1193..909c5b78f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -119,7 +119,7 @@ class Filtering(Cog): async def on_message(self, msg: Message) -> None: """Invoke message filter for new messages.""" await self._filter_message(msg) - await self.bad_words_in_name(msg) + await self.check_is_bad_words_in_name(msg) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: @@ -134,7 +134,7 @@ class Filtering(Cog): delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) - async def bad_words_in_name(self, msg: Message) -> None: + async def check_is_bad_words_in_name(self, msg: Message) -> None: """Check bad words from user display name. When there is more than 3 days after last alert, send new alert.""" if not self.name_lock: self.name_lock = asyncio.Lock() -- cgit v1.2.3 From 90e5b856305c1ca37cdc8e59a256dbc24dfaf5fd Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:26:32 +0300 Subject: Filtering: Add days between alerts as constant --- bot/cogs/filtering.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 909c5b78f..dbe7c6bc7 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -43,6 +43,8 @@ TOKEN_WATCHLIST_PATTERNS = [ ] WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS +DAYS_BETWEEN_ALERTS = 3 + def expand_spoilers(text: str) -> str: """Return a string containing all interpretations of a spoilered message.""" @@ -152,7 +154,7 @@ class Filtering(Cog): last_alert = await self.name_alerts.get(msg.author.id) if last_alert: last_alert = datetime.fromtimestamp(last_alert) - if datetime.now() - timedelta(days=3) < last_alert: + if datetime.now() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: return log_string = ( -- cgit v1.2.3 From cf41dc4f1964ec24242e05649f957e629c95e112 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:28:23 +0300 Subject: Filtering: On name filtering, replace Message with Embed as argument --- bot/cogs/filtering.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index dbe7c6bc7..25f5c9497 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -121,7 +121,7 @@ class Filtering(Cog): async def on_message(self, msg: Message) -> None: """Invoke message filter for new messages.""" await self._filter_message(msg) - await self.check_is_bad_words_in_name(msg) + await self.check_is_bad_words_in_name(msg.author) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: @@ -136,7 +136,7 @@ class Filtering(Cog): delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) - async def check_is_bad_words_in_name(self, msg: Message) -> None: + async def check_is_bad_words_in_name(self, member: Member) -> None: """Check bad words from user display name. When there is more than 3 days after last alert, send new alert.""" if not self.name_lock: self.name_lock = asyncio.Lock() @@ -146,20 +146,20 @@ class Filtering(Cog): # Check does nickname have match in filters. matches = [] for pattern in WATCHLIST_PATTERNS: - match = pattern.search(msg.author.display_name) + match = pattern.search(member.display_name) if match: matches.append(match) if matches: - last_alert = await self.name_alerts.get(msg.author.id) + last_alert = await self.name_alerts.get(member.id) if last_alert: last_alert = datetime.fromtimestamp(last_alert) if datetime.now() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: return log_string = ( - f"**User:** {msg.author.mention} (`{msg.author.id}`)\n" - f"**Display Name:** {msg.author.display_name}\n" + f"**User:** {member.mention} (`{member.id}`)\n" + f"**Display Name:** {member.display_name}\n" f"**Bad Matches:** {', '.join(match.group() for match in matches)}" ) await self.mod_log.send_log_message( @@ -171,7 +171,7 @@ class Filtering(Cog): ) # Update time when alert sent - await self.name_alerts.set(msg.author.id, datetime.now().timestamp()) + await self.name_alerts.set(member.id, datetime.now().timestamp()) async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" -- cgit v1.2.3 From 87872f8c681840470c55d71e798f439282fcae42 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:36:23 +0300 Subject: Filtering: Split name filtering to smaller functions --- bot/cogs/filtering.py | 35 ++++++++++++++++++++++++----------- 1 file changed, 24 insertions(+), 11 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 25f5c9497..4f1ad0986 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -2,7 +2,7 @@ import asyncio import logging import re from datetime import datetime, timedelta -from typing import Optional, Union +from typing import List, Optional, Union import discord.errors from dateutil.relativedelta import relativedelta @@ -136,6 +136,26 @@ class Filtering(Cog): delta = relativedelta(after.edited_at, before.edited_at).microseconds await self._filter_message(after, delta) + @staticmethod + def get_name_matches(name: str) -> List[re.Match]: + """Check bad words from passed string (name). Return list of matches.""" + matches = [] + for pattern in WATCHLIST_PATTERNS: + match = pattern.search(name) + if match: + matches.append(match) + return matches + + async def check_send_alert(self, member: Member) -> bool: + """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" + last_alert = await self.name_alerts.get(member.id) + if last_alert: + last_alert = datetime.fromtimestamp(last_alert) + if datetime.now() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: + return False + + return True + async def check_is_bad_words_in_name(self, member: Member) -> None: """Check bad words from user display name. When there is more than 3 days after last alert, send new alert.""" if not self.name_lock: @@ -144,18 +164,11 @@ class Filtering(Cog): # Use lock to avoid race conditions async with self.name_lock: # Check does nickname have match in filters. - matches = [] - for pattern in WATCHLIST_PATTERNS: - match = pattern.search(member.display_name) - if match: - matches.append(match) + matches = self.get_name_matches(member.display_name) if matches: - last_alert = await self.name_alerts.get(member.id) - if last_alert: - last_alert = datetime.fromtimestamp(last_alert) - if datetime.now() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: - return + if not self.check_send_alert(member): + return log_string = ( f"**User:** {member.mention} (`{member.id}`)\n" -- cgit v1.2.3 From 5b7df0ea02df485c507d602868bce215b4290626 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 12:37:30 +0300 Subject: Filtering: Fix docstring Co-authored-by: Mark --- bot/cogs/filtering.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index eb587d781..737317d46 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -157,7 +157,7 @@ class Filtering(Cog): return True async def check_is_bad_words_in_name(self, member: Member) -> None: - """Check bad words from user display name. When there is more than 3 days after last alert, send new alert.""" + """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" # Use lock to avoid race conditions async with self.name_lock: # Check does nickname have match in filters. -- cgit v1.2.3 From 876b4846f612fe0011cc2e0b498b4df9e54d74cb Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 31 May 2020 19:17:07 +0200 Subject: Add support for bool values in RedisCache We're gonna need this for the help channel handling, and it seems like a reasonable type to support anyway. It requires a tiny bit of special handling, but nothing outrageous. --- bot/utils/redis_cache.py | 14 ++++++++++++-- tests/bot/utils/test_redis_cache.py | 4 +++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index de80cee84..2926e7a89 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio import logging +from distutils.util import strtobool from functools import partialmethod from typing import Any, Dict, ItemsView, Optional, Tuple, Union @@ -11,7 +12,7 @@ log = logging.getLogger(__name__) # Type aliases RedisKeyType = Union[str, int] -RedisValueType = Union[str, int, float] +RedisValueType = Union[str, int, float, bool] RedisKeyOrValue = Union[RedisKeyType, RedisValueType] # Prefix tuples @@ -20,6 +21,7 @@ _VALUE_PREFIXES = ( ("f|", float), ("i|", int), ("s|", str), + ("b|", bool), ) _KEY_PREFIXES = ( ("i|", int), @@ -117,7 +119,8 @@ class RedisCache: def _to_typestring(key_or_value: RedisKeyOrValue, prefixes: _PrefixTuple) -> str: """Turn a valid Redis type into a typestring.""" for prefix, _type in prefixes: - if isinstance(key_or_value, _type): + # isinstance is a bad idea here, because isintance(False, int) == True. + if type(key_or_value) is _type: return f"{prefix}{key_or_value}" raise TypeError(f"RedisCache._to_typestring only supports the following: {prefixes}.") @@ -131,6 +134,13 @@ class RedisCache: # Now we convert our unicode string back into the type it originally was. for prefix, _type in prefixes: if key_or_value.startswith(prefix): + + # For booleans, we need special handling because bool("False") is True. + if prefix == "b|": + value = key_or_value[len(prefix):] + return bool(strtobool(value)) + + # Otherwise we can just convert normally. return _type(key_or_value[len(prefix):]) raise TypeError(f"RedisCache._from_typestring only supports the following: {prefixes}.") diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py index 8c1a40640..62c411681 100644 --- a/tests/bot/utils/test_redis_cache.py +++ b/tests/bot/utils/test_redis_cache.py @@ -59,7 +59,9 @@ class RedisCacheTests(unittest.IsolatedAsyncioTestCase): test_cases = ( ('favorite_fruit', 'melon'), ('favorite_number', 86), - ('favorite_fraction', 86.54) + ('favorite_fraction', 86.54), + ('favorite_boolean', False), + ('other_boolean', True), ) # Test that we can get and set different types. -- cgit v1.2.3 From 72adad95e06632a61ba7773289938ca69e5874aa Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 31 May 2020 21:47:03 +0300 Subject: Filtering: Small fixes - Use UTC from timestamp - Rename name bad words checking function --- bot/cogs/filtering.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 737317d46..baa2e5529 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -121,7 +121,7 @@ class Filtering(Cog): async def on_message(self, msg: Message) -> None: """Invoke message filter for new messages.""" await self._filter_message(msg) - await self.check_is_bad_words_in_name(msg.author) + await self.check_bad_words_in_name(msg.author) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: @@ -150,13 +150,13 @@ class Filtering(Cog): """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" last_alert = await self.name_alerts.get(member.id) if last_alert: - last_alert = datetime.fromtimestamp(last_alert) - if datetime.now() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: + last_alert = datetime.utcfromtimestamp(last_alert) + if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: return False return True - async def check_is_bad_words_in_name(self, member: Member) -> None: + async def check_bad_words_in_name(self, member: Member) -> None: """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" # Use lock to avoid race conditions async with self.name_lock: @@ -181,7 +181,7 @@ class Filtering(Cog): ) # Update time when alert sent - await self.name_alerts.set(member.id, datetime.now().timestamp()) + await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" -- cgit v1.2.3 From 860f4d4306fb846bf36cbcaedf8e1ee042550f06 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 31 May 2020 12:14:04 -0700 Subject: Fix missing await in bad nickname filter --- bot/cogs/filtering.py | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index baa2e5529..5c3d01e3a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -163,25 +163,24 @@ class Filtering(Cog): # Check does nickname have match in filters. matches = self.get_name_matches(member.display_name) - if matches: - if not self.check_send_alert(member): - return - - log_string = ( - f"**User:** {member.mention} (`{member.id}`)\n" - f"**Display Name:** {member.display_name}\n" - f"**Bad Matches:** {', '.join(match.group() for match in matches)}" - ) - await self.mod_log.send_log_message( - icon_url=Icons.token_removed, - colour=Colours.soft_red, - title="Username filtering alert", - text=log_string, - channel_id=Channels.mod_alerts - ) + if not matches or not await self.check_send_alert(member): + return + + log_string = ( + f"**User:** {member.mention} (`{member.id}`)\n" + f"**Display Name:** {member.display_name}\n" + f"**Bad Matches:** {', '.join(match.group() for match in matches)}" + ) + await self.mod_log.send_log_message( + icon_url=Icons.token_removed, + colour=Colours.soft_red, + title="Username filtering alert", + text=log_string, + channel_id=Channels.mod_alerts + ) - # Update time when alert sent - await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) + # Update time when alert sent + await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" -- cgit v1.2.3 From 0fb6c2dad3787054c92cc732af8b52799f20e06f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 31 May 2020 12:24:08 -0700 Subject: Add logging for the bad nickname filter --- bot/cogs/filtering.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 5c3d01e3a..caf204561 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -152,6 +152,7 @@ class Filtering(Cog): if last_alert: last_alert = datetime.utcfromtimestamp(last_alert) if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: + log.trace(f"Last alert was too recent for {member}'s nickname.") return False return True @@ -166,6 +167,7 @@ class Filtering(Cog): if not matches or not await self.check_send_alert(member): return + log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") log_string = ( f"**User:** {member.mention} (`{member.id}`)\n" f"**Display Name:** {member.display_name}\n" -- cgit v1.2.3 From ca1a8c55d68f15d910f21d568339e6555e4b7e54 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 31 May 2020 13:43:42 -0700 Subject: Remove redis namespace collision prevention When cogs reload, it would consider their namespace as a conflict with the original namespace. This feature will be removed as a fix until we come up with a better solution. --- bot/utils/redis_cache.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index de80cee84..354e987b9 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -100,16 +100,7 @@ class RedisCache: def _set_namespace(self, namespace: str) -> None: """Try to set the namespace, but do not permit collisions.""" - # We need a unique namespace, to prevent collisions. This loop - # will try appending underscores to the end of the namespace until - # it finds one that is unique. - # - # For example, if `john` and `john_` are both taken, the namespace will - # be `john__` at the end of this loop. - while namespace in self._namespaces: - namespace += "_" - - log.trace(f"RedisCache setting namespace to {self._namespace}") + log.trace(f"RedisCache setting namespace to {namespace}") self._namespaces.append(namespace) self._namespace = namespace -- cgit v1.2.3 From ebbaa6274cfc278c772593b193356aa8bf066de4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 31 May 2020 14:17:20 -0700 Subject: Remove redis namespace collision test --- tests/bot/utils/test_redis_cache.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/tests/bot/utils/test_redis_cache.py b/tests/bot/utils/test_redis_cache.py index 8c1a40640..e5d6e4078 100644 --- a/tests/bot/utils/test_redis_cache.py +++ b/tests/bot/utils/test_redis_cache.py @@ -44,16 +44,6 @@ class RedisCacheTests(unittest.IsolatedAsyncioTestCase): with self.assertRaises(RuntimeError): await bad_cache.set("test", "me_up_deadman") - def test_namespace_collision(self): - """Test that we prevent colliding namespaces.""" - bob_cache_1 = RedisCache() - bob_cache_1._set_namespace("BobRoss") - self.assertEqual(bob_cache_1._namespace, "BobRoss") - - bob_cache_2 = RedisCache() - bob_cache_2._set_namespace("BobRoss") - self.assertEqual(bob_cache_2._namespace, "BobRoss_") - async def test_set_get_item(self): """Test that users can set and get items from the RedisDict.""" test_cases = ( -- cgit v1.2.3 From 00f52a15c67fa2a8dc9dc87769ba56cff9e2cdf4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 07:55:49 +0300 Subject: Tags: Add tag file location storage to cache --- bot/cogs/tags.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 6f03a3475..571c0ed28 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -47,6 +47,7 @@ class Tags(Cog): "description": file.read_text(encoding="utf8"), }, "restricted_to": "developers", + "location": str(file) } # Convert to a list to allow negative indexing. -- cgit v1.2.3 From 9f93a40bb8d8bd7d0538465f9d4eda79d02e540c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 08:11:41 +0300 Subject: Source: Simplify tags name and location parsing --- bot/cogs/source.py | 22 ++++++++++++++-------- bot/cogs/tags.py | 2 +- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index a3922297a..e01209c28 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -21,12 +21,8 @@ class SourceConverter(commands.Converter): tags_cog = ctx.bot.get_cog("Tags") - if argument.lower() in tags_cog._cache: - tag = argument.lower() - if tags_cog._cache[tag]["restricted_to"] != "developers": - return f"/bot/bot/resources/tags/{tags_cog._cache[tag]['restricted_to']}/{tag}.md" - else: - return f"/bot/bot/resources/tags/{tag}.md" + if tags_cog and argument.lower() in tags_cog._cache: + return argument.lower() cog = ctx.bot.get_cog(argument) if cog: @@ -56,6 +52,12 @@ class BotSource(commands.Cog): return url, location, first_line = self.get_source_link(source_item) + + # There is no URL only when bot can't fetch Tags cog + if not url: + await ctx.send("Unable to get `Tags` cog.") + return + await ctx.send(embed=await self.build_embed(url, source_item, location, first_line)) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: @@ -73,7 +75,11 @@ class BotSource(commands.Cog): src = source_item.callback.__code__ filename = src.co_filename elif isinstance(source_item, str): - filename = source_item + tags_cog = self.bot.get_cog("Tags") + if not tags_cog: + return "", "", None + + filename = tags_cog._cache[source_item]["location"] else: src = type(source_item) filename = inspect.getsourcefile(src) @@ -106,7 +112,7 @@ class BotSource(commands.Cog): aliases_string = f" (or {', '.join(source_object.aliases)})" if source_object.aliases else "" title = f"Command: {source_object.qualified_name}{aliases_string}" elif isinstance(source_object, str): - title = f"Tag: {source_object.split('/')[-1].split('.')[0]}" + title = f"Tag: {source_object}" description = "" else: title = f"Cog: {source_object.qualified_name}" diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 571c0ed28..3d76c5c08 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -47,7 +47,7 @@ class Tags(Cog): "description": file.read_text(encoding="utf8"), }, "restricted_to": "developers", - "location": str(file) + "location": f"/bot/{file}" } # Convert to a list to allow negative indexing. -- cgit v1.2.3 From c397b871fbce903f251abd32662d40d33a95e0de Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 08:16:59 +0300 Subject: Source: Move calling `get_source_link` to `build_embed` --- bot/cogs/source.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index e01209c28..00a5b344b 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -51,14 +51,11 @@ class BotSource(commands.Cog): await ctx.send(embed=embed) return - url, location, first_line = self.get_source_link(source_item) + embed = await self.build_embed(source_item, ctx) - # There is no URL only when bot can't fetch Tags cog - if not url: - await ctx.send("Unable to get `Tags` cog.") - return - - await ctx.send(embed=await self.build_embed(url, source_item, location, first_line)) + # When embed don't exist, then there was error and this is already handled. + if embed: + await ctx.send(embed=await self.build_embed(source_item, ctx)) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: """Build GitHub link of source item.""" @@ -96,8 +93,15 @@ class BotSource(commands.Cog): return url, file_location, first_line_no or None - async def build_embed(self, link: str, source_object: SourceType, loc: str, first_line: Optional[int]) -> Embed: + async def build_embed(self, source_object: SourceType, ctx: commands.Context) -> Optional[Embed]: """Build embed based on source object.""" + url, location, first_line = self.get_source_link(source_object) + + # There is no URL only when bot can't fetch Tags cog + if not url: + await ctx.send("Unable to get `Tags` cog.") + return + if isinstance(source_object, commands.HelpCommand): title = "Help Command" description = source_object.__doc__.splitlines()[1] @@ -119,9 +123,9 @@ class BotSource(commands.Cog): description = source_object.description.splitlines()[0] embed = Embed(title=title, description=description) - embed.add_field(name="Source Code", value=f"[Go to GitHub]({link})") + embed.add_field(name="Source Code", value=f"[Go to GitHub]({url})") line_text = f":{first_line}" if first_line else "" - embed.set_footer(text=f"{loc}{line_text}") + embed.set_footer(text=f"{location}{line_text}") return embed -- cgit v1.2.3 From 18968d27ae5bc3b004c881a0c78bcfb305371158 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 08:18:27 +0300 Subject: Source: Update `get_source_link` docstring --- bot/cogs/source.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 00a5b344b..50fd4599d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -58,7 +58,7 @@ class BotSource(commands.Cog): await ctx.send(embed=await self.build_embed(source_item, ctx)) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: - """Build GitHub link of source item.""" + """Build GitHub link of source item, return this link, file location and first line number.""" if isinstance(source_item, commands.HelpCommand): src = type(source_item) filename = inspect.getsourcefile(src) -- cgit v1.2.3 From ea937e5a69b4cf233216510c96800bdf5940ff16 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 08:19:21 +0300 Subject: Source: Remove showing aliases for commands --- bot/cogs/source.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 50fd4599d..57ae17f77 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -113,8 +113,7 @@ class BotSource(commands.Cog): else: description = source_object.short_doc - aliases_string = f" (or {', '.join(source_object.aliases)})" if source_object.aliases else "" - title = f"Command: {source_object.qualified_name}{aliases_string}" + title = f"Command: {source_object.qualified_name}" elif isinstance(source_object, str): title = f"Tag: {source_object}" description = "" -- cgit v1.2.3 From 8429c2284901675297c78f00bf0e5a6b15d80e31 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Mon, 1 Jun 2020 13:17:40 +0300 Subject: Source: Refactor Tags cog missing handling --- bot/cogs/source.py | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 57ae17f77..32a78a0c0 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -8,7 +8,7 @@ from discord.ext import commands from bot.bot import Bot from bot.constants import URLs -SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str] +SourceType = Union[commands.HelpCommand, commands.Command, commands.Cog, str, commands.ExtensionNotLoaded] class SourceConverter(commands.Converter): @@ -19,11 +19,6 @@ class SourceConverter(commands.Converter): if argument.lower().startswith("help"): return ctx.bot.help_command - tags_cog = ctx.bot.get_cog("Tags") - - if tags_cog and argument.lower() in tags_cog._cache: - return argument.lower() - cog = ctx.bot.get_cog(argument) if cog: return cog @@ -32,6 +27,14 @@ class SourceConverter(commands.Converter): if cmd: return cmd + tags_cog = ctx.bot.get_cog("Tags") + + if not tags_cog: + await ctx.send("Unable to get `Tags` cog.") + return commands.ExtensionNotLoaded("bot.cogs.tags") + elif argument.lower() in tags_cog._cache: + return argument.lower() + raise commands.BadArgument(f"Unable to convert `{argument}` to valid command, tag, or Cog.") @@ -44,6 +47,10 @@ class BotSource(commands.Cog): @commands.command(name="source", aliases=("src",)) async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: """Display information and a GitHub link to the source code of a command, tag, or cog.""" + # When we have problem to get Tags cog, exit early + if isinstance(source_item, commands.ExtensionNotLoaded): + return + if not source_item: embed = Embed(title="Bot's GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") @@ -51,11 +58,8 @@ class BotSource(commands.Cog): await ctx.send(embed=embed) return - embed = await self.build_embed(source_item, ctx) - - # When embed don't exist, then there was error and this is already handled. - if embed: - await ctx.send(embed=await self.build_embed(source_item, ctx)) + embed = await self.build_embed(source_item) + await ctx.send(embed=embed) def get_source_link(self, source_item: SourceType) -> Tuple[str, str, Optional[int]]: """Build GitHub link of source item, return this link, file location and first line number.""" @@ -73,9 +77,6 @@ class BotSource(commands.Cog): filename = src.co_filename elif isinstance(source_item, str): tags_cog = self.bot.get_cog("Tags") - if not tags_cog: - return "", "", None - filename = tags_cog._cache[source_item]["location"] else: src = type(source_item) @@ -93,15 +94,10 @@ class BotSource(commands.Cog): return url, file_location, first_line_no or None - async def build_embed(self, source_object: SourceType, ctx: commands.Context) -> Optional[Embed]: + async def build_embed(self, source_object: SourceType) -> Optional[Embed]: """Build embed based on source object.""" url, location, first_line = self.get_source_link(source_object) - # There is no URL only when bot can't fetch Tags cog - if not url: - await ctx.send("Unable to get `Tags` cog.") - return - if isinstance(source_object, commands.HelpCommand): title = "Help Command" description = source_object.__doc__.splitlines()[1] -- cgit v1.2.3 From eac3be892a31a508f966fd73f1802086d83ed954 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 1 Jun 2020 19:09:22 +0200 Subject: Use Scheduler instead of asyncio.sleep on silence cog `asyncio.sleep` doesn't provide us with the ability to stop that timer, while in most of the cases, this is fine, there is a possibility that channel will be unsilenced manually and silenced again, but this sleep from the first silence will cancel the second (new) silence. This will replace this `asyncio.sleep` with Scheduler which provides the ability to cancel the unsilencing task when aborted manually. That means we also have the ability to send a response if the channel is not silenced and someone tries to unsilence it. --- bot/cogs/moderation/silence.py | 66 +++++++++++++++++++++++++++++++++++------- 1 file changed, 56 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 25febfa51..a2fd39906 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,19 +1,54 @@ import asyncio +import datetime import logging +from collections import namedtuple from contextlib import suppress from typing import Optional -from discord import TextChannel -from discord.ext import commands, tasks -from discord.ext.commands import Context - from bot.bot import Bot -from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles +from bot.constants import MODERATION_ROLES, Channels, Emojis, Guild, Roles from bot.converters import HushDurationConverter +from bot.utils import time from bot.utils.checks import with_role_check +from bot.utils.scheduling import Scheduler +from discord import TextChannel +from discord.ext import commands, tasks +from discord.ext.commands import Context log = logging.getLogger(__name__) +SilencedChannel = namedtuple( + "SilencedChannel", ("id", "ctx", "silence", "stop") +) + + +class UnsilenceScheduler(Scheduler): + """Scheduler for unsilencing channels""" + + def __init__(self, bot: Bot): + super().__init__() + + self.bot = bot + + async def schedule_unsilence(self, channel: SilencedChannel) -> None: + """Schedule expiration for silenced channels""" + await self.bot.wait_until_guild_available() + log.debug("Scheduling unsilencer") + self.schedule_task(channel.id, channel) + + async def _scheduled_task(self, channel: SilencedChannel) -> None: + """ + Removes expired silenced channel from `silence.muted_channels` + and calls `silence.unsilence` to unsilence the channel + after the silence expires + """ + await time.wait_until(channel.stop) + log.info("Unsilencing channel after set delay.") + + # Because `silence.unsilence` explicitly cancels this scheduled task, it is shielded + # to avoid prematurely cancelling itself. + await asyncio.shield(channel.ctx.invoke(channel.silence.unsilence)) + class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -61,6 +96,7 @@ class Silence(commands.Cog): self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() + self.scheduler = UnsilenceScheduler(bot) async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" @@ -90,9 +126,15 @@ class Silence(commands.Cog): return await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") - await asyncio.sleep(duration*60) - log.info("Unsilencing channel after set delay.") - await ctx.invoke(self.unsilence) + + channel = SilencedChannel( + id=ctx.channel.id, + ctx=ctx, + silence=self, + stop=datetime.datetime.now() + datetime.timedelta(minutes=duration), + ) + + await self.scheduler.schedule_unsilence(channel) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -103,8 +145,11 @@ class Silence(commands.Cog): """ await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") - if await self._unsilence(ctx.channel): - await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") + if not await self._unsilence(ctx.channel): + await ctx.send(f"{Emojis.cross_mark} current channel is not silenced.") + return + + await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ @@ -141,6 +186,7 @@ class Silence(commands.Cog): await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) + self.scheduler.cancel_task(channel.id) self.muted_channels.discard(channel) return True log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") -- cgit v1.2.3 From 9b0df33c90326f62d699afc70c13d3d375affeab Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 1 Jun 2020 23:08:32 +0200 Subject: Fix Formatting/Styling --- bot/cogs/moderation/silence.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index a2fd39906..448f17966 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -6,11 +6,12 @@ from contextlib import suppress from typing import Optional from bot.bot import Bot -from bot.constants import MODERATION_ROLES, Channels, Emojis, Guild, Roles +from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter from bot.utils import time from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler + from discord import TextChannel from discord.ext import commands, tasks from discord.ext.commands import Context @@ -23,7 +24,7 @@ SilencedChannel = namedtuple( class UnsilenceScheduler(Scheduler): - """Scheduler for unsilencing channels""" + """Scheduler for unsilencing channels.""" def __init__(self, bot: Bot): super().__init__() @@ -31,17 +32,13 @@ class UnsilenceScheduler(Scheduler): self.bot = bot async def schedule_unsilence(self, channel: SilencedChannel) -> None: - """Schedule expiration for silenced channels""" + """Schedule expiration for silenced channels.""" await self.bot.wait_until_guild_available() log.debug("Scheduling unsilencer") self.schedule_task(channel.id, channel) async def _scheduled_task(self, channel: SilencedChannel) -> None: - """ - Removes expired silenced channel from `silence.muted_channels` - and calls `silence.unsilence` to unsilence the channel - after the silence expires - """ + """Calls `silence.unsilence` on expired silenced channel to unsilence it.""" await time.wait_until(channel.stop) log.info("Unsilencing channel after set delay.") -- cgit v1.2.3 From 0a7b64df552d394e9d1f38fb167ec93334b9bead Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Mon, 1 Jun 2020 23:24:07 +0200 Subject: Optimize Imports --- bot/cogs/moderation/silence.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 448f17966..5dfa9cc8a 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -5,6 +5,10 @@ from collections import namedtuple from contextlib import suppress from typing import Optional +from discord import TextChannel +from discord.ext import commands, tasks +from discord.ext.commands import Context + from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter @@ -12,10 +16,6 @@ from bot.utils import time from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler -from discord import TextChannel -from discord.ext import commands, tasks -from discord.ext.commands import Context - log = logging.getLogger(__name__) SilencedChannel = namedtuple( -- cgit v1.2.3 From 9b3ab7df5ae1ecf95705f2fab7d99fdb36eb98ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 2 Jun 2020 19:22:49 -0700 Subject: Token remover: remove the `delete_message` function It's redundant; there's no benefit here in abstracting two lines of code into a function. --- bot/cogs/token_remover.py | 9 ++------- tests/bot/cogs/test_token_remover.py | 19 +++++++------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 46329e207..d55e079e9 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -79,7 +79,8 @@ class TokenRemover(Cog): async def take_action(self, msg: Message, found_token: Token) -> None: """Remove the `msg` containing the `found_token` and send a mod log message.""" self.mod_log.ignore(Event.message_delete, msg.id) - await self.delete_message(msg) + await msg.delete() + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) log_message = self.format_log_message(msg, found_token) log.debug(log_message) @@ -96,12 +97,6 @@ class TokenRemover(Cog): self.bot.stats.incr("tokens.removed_tokens") - @staticmethod - async def delete_message(msg: Message) -> None: - """Remove a `msg` containing a token and send an explanatory message in the same channel.""" - await msg.delete() - await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) - @staticmethod def format_log_message(msg: Message, token: Token) -> str: """Return the log message to send for `token` being censored in `msg`.""" diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 9b4b04ecd..a10124d2d 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -229,15 +229,6 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): results = [match[0] for match in results] self.assertCountEqual((token_1, token_2), results) - async def test_delete_message(self): - """The message should be deleted, and a message should be sent to the same channel.""" - await TokenRemover.delete_message(self.msg) - - self.msg.delete.assert_called_once_with() - self.msg.channel.send.assert_called_once_with( - token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) - ) - @autospec("bot.cogs.token_remover", "LOG_MESSAGE") def test_format_log_message(self, log_message): """Should correctly format the log message with info from the message and token.""" @@ -258,8 +249,8 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) @autospec("bot.cogs.token_remover", "log") - @autospec(TokenRemover, "delete_message", "format_log_message") - async def test_take_action(self, delete_message, format_log_message, logger, mod_log_property): + @autospec(TokenRemover, "format_log_message") + async def test_take_action(self, format_log_message, logger, mod_log_property): """Should delete the message and send a mod log.""" cog = TokenRemover(self.bot) mod_log = mock.create_autospec(ModLog, spec_set=True, instance=True) @@ -271,7 +262,11 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): await cog.take_action(self.msg, token) - delete_message.assert_awaited_once_with(self.msg) + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_called_once_with( + token_remover.DELETION_MESSAGE_TEMPLATE.format(mention=self.msg.author.mention) + ) + format_log_message.assert_called_once_with(self.msg, token) logger.debug.assert_called_with(log_msg) self.bot.stats.incr.assert_called_once_with("tokens.removed_tokens") -- cgit v1.2.3 From 5f5a51b1715228ac5b401ef6bed8a83491e313de Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Thu, 4 Jun 2020 03:17:11 -0400 Subject: Improve LinePaginator to support long lines --- bot/cogs/moderation/management.py | 8 ++--- bot/pagination.py | 66 +++++++++++++++++++++++++++++++++++---- tests/bot/test_pagination.py | 41 +++++++++++++++++++----- 3 files changed, 98 insertions(+), 17 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 250a24247..ad17a90b0 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -83,14 +83,14 @@ class ModManagement(commands.Cog): "actor__id": ctx.author.id, "ordering": "-inserted_at" } - infractions = await self.bot.api_client.get(f"bot/infractions", params=params) + infractions = await self.bot.api_client.get("bot/infractions", params=params) if infractions: old_infraction = infractions[0] infraction_id = old_infraction["id"] else: await ctx.send( - f":x: Couldn't find most recent infraction; you have never given an infraction." + ":x: Couldn't find most recent infraction; you have never given an infraction." ) return else: @@ -224,7 +224,7 @@ class ModManagement(commands.Cog): ) -> None: """Send a paginated embed of infractions for the specified user.""" if not infractions: - await ctx.send(f":warning: No infractions could be found for that query.") + await ctx.send(":warning: No infractions could be found for that query.") return lines = tuple( @@ -268,12 +268,12 @@ class ModManagement(commands.Cog): User: {self.bot.get_user(user_id)} (`{user_id}`) Type: **{infraction["type"]}** Shadow: {hidden} - Reason: {infraction["reason"] or "*None*"} Created: {created} Expires: {expires} Remaining: {remaining} Actor: {actor.mention if actor else actor_id} ID: `{infraction["id"]}` + Reason: {infraction["reason"] or "*None*"} {"**===============**" if active else "==============="} """) diff --git a/bot/pagination.py b/bot/pagination.py index 90c8f849c..5c7be564d 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -37,12 +37,19 @@ class LinePaginator(Paginator): The suffix appended at the end of every page. e.g. three backticks. * max_size: `int` The maximum amount of codepoints allowed in a page. + * scale_to_size: `int` + The maximum amount of characters a single line can scale up to. * max_lines: `int` The maximum amount of lines allowed in a page. """ def __init__( - self, prefix: str = '```', suffix: str = '```', max_size: int = 2000, max_lines: int = None + self, + prefix: str = '```', + suffix: str = '```', + max_size: int = 2000, + scale_to_size: int = 2000, + max_lines: t.Optional[int] = None ) -> None: """ This function overrides the Paginator.__init__ from inside discord.ext.commands. @@ -52,6 +59,10 @@ class LinePaginator(Paginator): self.prefix = prefix self.suffix = suffix self.max_size = max_size - len(suffix) + if scale_to_size < max_size: + raise ValueError("scale_to_size must be >= max_size.") + + self.scale_to_size = scale_to_size self.max_lines = max_lines self._current_page = [prefix] self._linecount = 0 @@ -62,14 +73,26 @@ class LinePaginator(Paginator): """ Adds a line to the current page. - If the line exceeds the `self.max_size` then an exception is raised. + If the line exceeds `self.max_size`, then `self.max_size` will go up to `scale_to_size` for + a single line before creating a new page. If it is still exceeded, the excess characters + are stored and placed on the next pages until there are none remaining (by word boundary). + + Raises a RuntimeError if `self.max_size` is still exceeded after attempting to continue + onto the next page. This function overrides the `Paginator.add_line` from inside `discord.ext.commands`. It overrides in order to allow us to configure the maximum number of lines per page. """ - if len(line) > self.max_size - len(self.prefix) - 2: - raise RuntimeError('Line exceeds maximum page size %s' % (self.max_size - len(self.prefix) - 2)) + remaining_words = None + if len(line) > (max_chars := self.max_size - len(self.prefix) - 2): + if len(line) > self.scale_to_size: + line, remaining_words = self._split_remaining_words(line, max_chars) + # If line still exceeds scale_to_size, we were unable to split into a second + # page without truncating. + if len(line) > self.scale_to_size: + raise RuntimeError(f'Line exceeds maximum scale_to_size {self.scale_to_size}' + ' and could not be split.') if self.max_lines is not None: if self._linecount >= self.max_lines: @@ -87,6 +110,36 @@ class LinePaginator(Paginator): self._current_page.append('') self._count += 1 + if remaining_words: + self.add_line(remaining_words) + + def _split_remaining_words(self, line: str, max_chars: int) -> t.Tuple[str, t.Optional[str]]: + """Internal: split a line into two strings; one that fits within *max_chars* characters + (reduced_words) and another for the remaining (remaining_words), rounding down to the + nearest word. + + Return a tuple in the format (reduced_words, remaining_words). + """ + reduced_words = [] + # "(Continued)" is used on a line by itself to indicate the continuation of last page + remaining_words = ["(Continued)\n", "---------------\n"] + reduced_char_count = 0 + is_full = False + + for word in line.split(" "): + if not is_full: + if len(word) + reduced_char_count <= max_chars: + reduced_words.append(word) + reduced_char_count += len(word) + else: + is_full = True + remaining_words.append(word) + else: + remaining_words.append(word) + + return " ".join(reduced_words), " ".join(remaining_words) if len(remaining_words) > 2 \ + else None + @classmethod async def paginate( cls, @@ -97,6 +150,7 @@ class LinePaginator(Paginator): suffix: str = "", max_lines: t.Optional[int] = None, max_size: int = 500, + scale_to_size: int = 2000, empty: bool = True, restrict_to_user: User = None, timeout: int = 300, @@ -147,7 +201,7 @@ class LinePaginator(Paginator): if not lines: if exception_on_empty_embed: - log.exception(f"Pagination asked for empty lines iterable") + log.exception("Pagination asked for empty lines iterable") raise EmptyPaginatorEmbed("No lines to paginate") log.debug("No lines to add to paginator, adding '(nothing to display)' message") @@ -357,7 +411,7 @@ class ImagePaginator(Paginator): if not pages: if exception_on_empty_embed: - log.exception(f"Pagination asked for empty image list") + log.exception("Pagination asked for empty image list") raise EmptyPaginatorEmbed("No images to paginate") log.debug("No images to add to paginator, adding '(no images to display)' message") diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py index 0a734b505..f2e2c27ce 100644 --- a/tests/bot/test_pagination.py +++ b/tests/bot/test_pagination.py @@ -8,17 +8,44 @@ class LinePaginatorTests(TestCase): def setUp(self): """Create a paginator for the test method.""" - self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30) - - def test_add_line_raises_on_too_long_lines(self): - """`add_line` should raise a `RuntimeError` for too long lines.""" - message = f"Line exceeds maximum page size {self.paginator.max_size - 2}" - with self.assertRaises(RuntimeError, msg=message): - self.paginator.add_line('x' * self.paginator.max_size) + self.paginator = pagination.LinePaginator(prefix='', suffix='', max_size=30, + scale_to_size=50) def test_add_line_works_on_small_lines(self): """`add_line` should allow small lines to be added.""" self.paginator.add_line('x' * (self.paginator.max_size - 3)) + # Note that the page isn't added to _pages until it's full. + self.assertEqual(len(self.paginator._pages), 0) + + def test_add_line_works_on_long_lines(self): + """`add_line` should scale long lines up to `scale_to_size`.""" + self.paginator.add_line('x' * self.paginator.scale_to_size) + self.assertEqual(len(self.paginator._pages), 1) + + # Any additional lines should start a new page after `max_size` is exceeded. + self.paginator.add_line('x') + self.assertEqual(len(self.paginator._pages), 2) + + def test_add_line_continuation(self): + """When `scale_to_size` is exceeded, remaining words should be split onto the next page.""" + self.paginator.add_line('zyz ' * (self.paginator.scale_to_size//4 + 1)) + self.assertEqual(len(self.paginator._pages), 2) + + def test_add_line_no_continuation(self): + """If adding a new line to an existing page would exceed `max_size`, it should start a new + page rather than using continuation. + """ + self.paginator.add_line('z' * (self.paginator.max_size - 3)) + self.paginator.add_line('z') + self.assertEqual(len(self.paginator._pages), 1) + + def test_add_line_raises_on_very_long_words(self): + """`add_line` should raise if a single long word is added that exceeds `scale_to_size`. + + Note: truncation is also a potential option, but this should not occur from normal usage. + """ + with self.assertRaises(RuntimeError): + self.paginator.add_line('x' * (self.paginator.scale_to_size + 1)) class ImagePaginatorTests(TestCase): -- cgit v1.2.3 From a465a1eb5d06b342d4fcc14746668bcbe57cd215 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Thu, 4 Jun 2020 04:06:50 -0400 Subject: Fix docstring for _split_remaing_words() --- bot/pagination.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index 5c7be564d..590d9da96 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -114,9 +114,13 @@ class LinePaginator(Paginator): self.add_line(remaining_words) def _split_remaining_words(self, line: str, max_chars: int) -> t.Tuple[str, t.Optional[str]]: - """Internal: split a line into two strings; one that fits within *max_chars* characters - (reduced_words) and another for the remaining (remaining_words), rounding down to the - nearest word. + """Internal: split a line into two strings -- reduced_words and remaining_words. + + reduced_words: the remaining words in `line`, after attempting to remove all words that + exceed `max_chars` (rounding down to the nearest word boundary). + + remaining_words: the words in `line` which exceed `max_chars`. This value is None if + no words could be split from `line`. Return a tuple in the format (reduced_words, remaining_words). """ -- cgit v1.2.3 From 974bf0fe2074d141299b826ce7b8a2479df960b5 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Thu, 4 Jun 2020 04:12:37 -0400 Subject: Fix _split_remaining_words() docstring summary --- bot/pagination.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index 590d9da96..ba10da57e 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -114,7 +114,8 @@ class LinePaginator(Paginator): self.add_line(remaining_words) def _split_remaining_words(self, line: str, max_chars: int) -> t.Tuple[str, t.Optional[str]]: - """Internal: split a line into two strings -- reduced_words and remaining_words. + """ + Internal: split a line into two strings -- reduced_words and remaining_words. reduced_words: the remaining words in `line`, after attempting to remove all words that exceed `max_chars` (rounding down to the nearest word boundary). -- cgit v1.2.3 From 469692d53a4ed74500a5806273e9c778a97afae8 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Thu, 4 Jun 2020 22:50:36 +0200 Subject: Use Scheduler inside the cog - There shouldn't be another class only for Scheduler instead, we can implement it directly into Silence class --- bot/cogs/moderation/silence.py | 50 +++++++++++++++++------------------------- 1 file changed, 20 insertions(+), 30 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 5dfa9cc8a..398a70e51 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -19,34 +19,10 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) SilencedChannel = namedtuple( - "SilencedChannel", ("id", "ctx", "silence", "stop") + "SilencedChannel", ("id", "ctx", "stop") ) -class UnsilenceScheduler(Scheduler): - """Scheduler for unsilencing channels.""" - - def __init__(self, bot: Bot): - super().__init__() - - self.bot = bot - - async def schedule_unsilence(self, channel: SilencedChannel) -> None: - """Schedule expiration for silenced channels.""" - await self.bot.wait_until_guild_available() - log.debug("Scheduling unsilencer") - self.schedule_task(channel.id, channel) - - async def _scheduled_task(self, channel: SilencedChannel) -> None: - """Calls `silence.unsilence` on expired silenced channel to unsilence it.""" - await time.wait_until(channel.stop) - log.info("Unsilencing channel after set delay.") - - # Because `silence.unsilence` explicitly cancels this scheduled task, it is shielded - # to avoid prematurely cancelling itself. - await asyncio.shield(channel.ctx.invoke(channel.silence.unsilence)) - - class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -85,7 +61,7 @@ class SilenceNotifier(tasks.Loop): await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") -class Silence(commands.Cog): +class Silence(Scheduler, commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" def __init__(self, bot: Bot): @@ -93,7 +69,22 @@ class Silence(commands.Cog): self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() - self.scheduler = UnsilenceScheduler(bot) + super().__init__() + + async def schedule_unsilence(self, channel: SilencedChannel) -> None: + """Schedule expiration for silenced channels.""" + await self.bot.wait_until_guild_available() + log.debug("Scheduling unsilencer") + self.schedule_task(channel.id, channel) + + async def _scheduled_task(self, channel: SilencedChannel) -> None: + """Calls `self.unsilence` on expired silenced channel to unsilence it.""" + await time.wait_until(channel.stop) + log.info("Unsilencing channel after set delay.") + + # Because `self.unsilence` explicitly cancels this scheduled tas, it is shielded + # to avoid prematurely cancelling itself + await asyncio.shield(channel.ctx.invoke(self.unsilence)) async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" @@ -127,11 +118,10 @@ class Silence(commands.Cog): channel = SilencedChannel( id=ctx.channel.id, ctx=ctx, - silence=self, stop=datetime.datetime.now() + datetime.timedelta(minutes=duration), ) - await self.scheduler.schedule_unsilence(channel) + await self.schedule_unsilence(channel) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -183,7 +173,7 @@ class Silence(commands.Cog): await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") self.notifier.remove_channel(channel) - self.scheduler.cancel_task(channel.id) + self.cancel_task(channel.id) self.muted_channels.discard(channel) return True log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") -- cgit v1.2.3 From 699760a3d803c379dad1236f36c919eb0775e490 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 6 Jun 2020 00:49:33 +0200 Subject: Refactor help_channels.py to use RedisCache. More specifically, we're turning three dicts into RedisCaches: - help_channel_claimants - unanswered - claim_times These will still work the same way, but will now persist their contents across restarts. --- bot/cogs/help_channels.py | 60 +++++++++++++++++++++++++---------------------- 1 file changed, 32 insertions(+), 28 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 70cef339a..8c01e5dc4 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -9,12 +9,14 @@ from contextlib import suppress from datetime import datetime from pathlib import Path +import dateutil import discord import discord.abc from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.utils import RedisCache from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler @@ -99,13 +101,24 @@ class HelpChannels(Scheduler, commands.Cog): Help channels are named after the chemical elements in `bot/resources/elements.json`. """ + # This cache tracks which channels are claimed by which members. + # RedisCache[discord.TextChannel.id, t.Union[discord.User.id, discord.Member.id]] + help_channel_claimants = RedisCache() + + # This cache maps a help channel to whether it has had any + # activity other than the original claimant. True being no other + # activity and False being other activity. + # RedisCache[discord.TextChannel.id, bool] + unanswered = RedisCache() + + # This dictionary maps a help channel to the time it was claimed + # RedisCache[discord.TextChannel.id, datetime.datetime] + claim_times = RedisCache() + def __init__(self, bot: Bot): super().__init__() self.bot = bot - self.help_channel_claimants: ( - t.Dict[discord.TextChannel, t.Union[discord.Member, discord.User]] - ) = {} # Categories self.available_category: discord.CategoryChannel = None @@ -125,16 +138,6 @@ class HelpChannels(Scheduler, commands.Cog): self.on_message_lock = asyncio.Lock() self.init_task = self.bot.loop.create_task(self.init_cog()) - # Stats - - # This dictionary maps a help channel to the time it was claimed - self.claim_times: t.Dict[int, datetime] = {} - - # This dictionary maps a help channel to whether it has had any - # activity other than the original claimant. True being no other - # activity and False being other activity. - self.unanswered: t.Dict[int, bool] = {} - def cog_unload(self) -> None: """Cancel the init task and scheduled tasks when the cog unloads.""" log.trace("Cog unload: cancelling the init_cog task") @@ -197,7 +200,7 @@ class HelpChannels(Scheduler, commands.Cog): async def dormant_check(self, ctx: commands.Context) -> bool: """Return True if the user is the help channel claimant or passes the role check.""" - if self.help_channel_claimants.get(ctx.channel) == ctx.author: + if await self.help_channel_claimants.get(ctx.channel.id) == ctx.author.id: log.trace(f"{ctx.author} is the help channel claimant, passing the check for dormant.") self.bot.stats.incr("help.dormant_invoke.claimant") return True @@ -223,7 +226,7 @@ class HelpChannels(Scheduler, commands.Cog): if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): with suppress(KeyError): - del self.help_channel_claimants[ctx.channel] + await self.help_channel_claimants.delete(ctx.channel.id) await self.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. @@ -546,13 +549,14 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr(f"help.dormant_calls.{caller}") - if channel.id in self.claim_times: - claimed = self.claim_times[channel.id] + if await self.claim_times.contains(channel.id): + claimed_datestring = await self.claim_times.get(channel.id) + claimed = dateutil.parser.parse(claimed_datestring) in_use_time = datetime.now() - claimed self.bot.stats.timing("help.in_use_time", in_use_time) - if channel.id in self.unanswered: - if self.unanswered[channel.id]: + if await self.unanswered.contains(channel.id): + if await self.unanswered.get(channel.id): self.bot.stats.incr("help.sessions.unanswered") else: self.bot.stats.incr("help.sessions.answered") @@ -638,16 +642,16 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Check if there is an entry in unanswered (does not persist across restarts) - if channel.id in self.unanswered: - claimant = self.help_channel_claimants.get(channel) - if not claimant: - # The mapping for this channel was lost, we can't do anything. + if await self.unanswered.contains(channel.id): + claimant_id = await self.help_channel_claimants.get(channel.id) + if not claimant_id: + # The mapping for this channel doesn't exist, we can't do anything. return # Check the message did not come from the claimant - if claimant.id != message.author.id: + if claimant_id != message.author.id: # Mark the channel as answered - self.unanswered[channel.id] = False + await self.unanswered.set(channel.id, False) @commands.Cog.listener() async def on_message(self, message: discord.Message) -> None: @@ -680,12 +684,12 @@ class HelpChannels(Scheduler, commands.Cog): await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) # Add user with channel for dormant check. - self.help_channel_claimants[channel] = message.author + await self.help_channel_claimants.set(channel.id, message.author.id) self.bot.stats.incr("help.claimed") - self.claim_times[channel.id] = datetime.now() - self.unanswered[channel.id] = True + await self.claim_times.set(channel.id, str(datetime.now())) + await self.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") -- cgit v1.2.3 From fc4eddc7eee3670fdbe5726b13d28ddba57a156b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 6 Jun 2020 11:57:18 +0200 Subject: Store booleans as integers instead of strings. This means we don't need to rely on strtobool, and is a cleaner implementation overall. Thanks @MarkKoz. --- bot/utils/redis_cache.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index 2926e7a89..347a0e54a 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio import logging -from distutils.util import strtobool from functools import partialmethod from typing import Any, Dict, ItemsView, Optional, Tuple, Union @@ -119,9 +118,15 @@ class RedisCache: def _to_typestring(key_or_value: RedisKeyOrValue, prefixes: _PrefixTuple) -> str: """Turn a valid Redis type into a typestring.""" for prefix, _type in prefixes: + # Convert bools into integers before storing them. + if type(key_or_value) is bool: + bool_int = int(key_or_value) + return f"{prefix}{bool_int}" + # isinstance is a bad idea here, because isintance(False, int) == True. if type(key_or_value) is _type: return f"{prefix}{key_or_value}" + raise TypeError(f"RedisCache._to_typestring only supports the following: {prefixes}.") @staticmethod @@ -138,7 +143,7 @@ class RedisCache: # For booleans, we need special handling because bool("False") is True. if prefix == "b|": value = key_or_value[len(prefix):] - return bool(strtobool(value)) + return bool(int(value)) # Otherwise we can just convert normally. return _type(key_or_value[len(prefix):]) -- cgit v1.2.3 From 2d7c8e8238179ece54b388deeeb0734ce330b707 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 6 Jun 2020 12:17:24 +0200 Subject: Apply suggestions from review --- bot/cogs/moderation/silence.py | 25 ++++++++++--------------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 398a70e51..94c560c8e 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,9 +1,7 @@ import asyncio -import datetime import logging -from collections import namedtuple from contextlib import suppress -from typing import Optional +from typing import Optional, NamedTuple from discord import TextChannel from discord.ext import commands, tasks @@ -12,14 +10,13 @@ from discord.ext.commands import Context from bot.bot import Bot from bot.constants import Channels, Emojis, Guild, MODERATION_ROLES, Roles from bot.converters import HushDurationConverter -from bot.utils import time from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -SilencedChannel = namedtuple( - "SilencedChannel", ("id", "ctx", "stop") +SilencedChannel = NamedTuple( + "SilencedChannel", [("ctx", Context), ("delay", int)] ) @@ -65,11 +62,11 @@ class Silence(Scheduler, commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" def __init__(self, bot: Bot): + super().__init__() self.bot = bot self.muted_channels = set() self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() - super().__init__() async def schedule_unsilence(self, channel: SilencedChannel) -> None: """Schedule expiration for silenced channels.""" @@ -79,10 +76,10 @@ class Silence(Scheduler, commands.Cog): async def _scheduled_task(self, channel: SilencedChannel) -> None: """Calls `self.unsilence` on expired silenced channel to unsilence it.""" - await time.wait_until(channel.stop) + await asyncio.sleep(channel.delay) log.info("Unsilencing channel after set delay.") - # Because `self.unsilence` explicitly cancels this scheduled tas, it is shielded + # Because `self.unsilence` explicitly cancels this scheduled task, it is shielded # to avoid prematurely cancelling itself await asyncio.shield(channel.ctx.invoke(self.unsilence)) @@ -116,12 +113,11 @@ class Silence(Scheduler, commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") channel = SilencedChannel( - id=ctx.channel.id, ctx=ctx, - stop=datetime.datetime.now() + datetime.timedelta(minutes=duration), + stop=duration*60, ) - await self.schedule_unsilence(channel) + await self.schedule_task(ctx.channel.id, channel) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -134,9 +130,8 @@ class Silence(Scheduler, commands.Cog): log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if not await self._unsilence(ctx.channel): await ctx.send(f"{Emojis.cross_mark} current channel is not silenced.") - return - - await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") + else: + await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") async def _silence(self, channel: TextChannel, persistent: bool, duration: Optional[int]) -> bool: """ -- cgit v1.2.3 From 1859ae7a43bc794b735c3445b1873420da5a7001 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 6 Jun 2020 12:20:26 +0200 Subject: Fix import order --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 94c560c8e..c451cea0b 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,7 +1,7 @@ import asyncio import logging from contextlib import suppress -from typing import Optional, NamedTuple +from typing import NamedTuple, Optional from discord import TextChannel from discord.ext import commands, tasks -- cgit v1.2.3 From 3c3c8c210507170a2502a4906265af6a2b2525ac Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 6 Jun 2020 12:26:14 +0200 Subject: Remove unnecessary schedule_unsilence - As suggested, this function is not necessary - Also fixed no longer valid`stop`in SilencedChannel NamedTuple --- bot/cogs/moderation/silence.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c451cea0b..8223df491 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -68,12 +68,6 @@ class Silence(Scheduler, commands.Cog): self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() - async def schedule_unsilence(self, channel: SilencedChannel) -> None: - """Schedule expiration for silenced channels.""" - await self.bot.wait_until_guild_available() - log.debug("Scheduling unsilencer") - self.schedule_task(channel.id, channel) - async def _scheduled_task(self, channel: SilencedChannel) -> None: """Calls `self.unsilence` on expired silenced channel to unsilence it.""" await asyncio.sleep(channel.delay) @@ -114,7 +108,7 @@ class Silence(Scheduler, commands.Cog): channel = SilencedChannel( ctx=ctx, - stop=duration*60, + delay=duration*60, ) await self.schedule_task(ctx.channel.id, channel) -- cgit v1.2.3 From 858e2e301234862e66ce03ccd71f46518dcc953f Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 6 Jun 2020 12:31:00 +0200 Subject: Do not await self.schedule_task - self.schedule_task shouldn't be awaited as it isn't a coroutine --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 8223df491..13f84009f 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -111,7 +111,7 @@ class Silence(Scheduler, commands.Cog): delay=duration*60, ) - await self.schedule_task(ctx.channel.id, channel) + self.schedule_task(ctx.channel.id, channel) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: -- cgit v1.2.3 From 94f096fab3bde10ba0da767c568c7a8c3ff3259f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 6 Jun 2020 12:33:06 +0200 Subject: Store epoch timestamps instead of strings. We're also switching from datetime.now() to datetime.utcnow(). --- bot/cogs/help_channels.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 8c01e5dc4..dd3e3cb8b 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -9,7 +9,6 @@ from contextlib import suppress from datetime import datetime from pathlib import Path -import dateutil import discord import discord.abc from discord.ext import commands @@ -550,9 +549,9 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr(f"help.dormant_calls.{caller}") if await self.claim_times.contains(channel.id): - claimed_datestring = await self.claim_times.get(channel.id) - claimed = dateutil.parser.parse(claimed_datestring) - in_use_time = datetime.now() - claimed + claimed_timestamp = await self.claim_times.get(channel.id) + claimed = datetime.fromtimestamp(claimed_timestamp) + in_use_time = datetime.utcnow() - claimed self.bot.stats.timing("help.in_use_time", in_use_time) if await self.unanswered.contains(channel.id): @@ -688,7 +687,7 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr("help.claimed") - await self.claim_times.set(channel.id, str(datetime.now())) + await self.claim_times.set(channel.id, datetime.utcnow().timestamp()) await self.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") -- cgit v1.2.3 From 40a774e0bb6ed8947a17fe0116e2f1dc0cf89156 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 6 Jun 2020 12:37:01 +0200 Subject: Fix potential race condition. Instead of first checking if the channel.id exists and then checking what it is, we just do a single API call, to prevent cases where something fucky might happen inbetween the first and the second call. --- bot/cogs/help_channels.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index dd3e3cb8b..01c38b408 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -548,20 +548,20 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr(f"help.dormant_calls.{caller}") - if await self.claim_times.contains(channel.id): - claimed_timestamp = await self.claim_times.get(channel.id) + claimed_timestamp = await self.claim_times.get(channel.id) + if claimed_timestamp: claimed = datetime.fromtimestamp(claimed_timestamp) in_use_time = datetime.utcnow() - claimed self.bot.stats.timing("help.in_use_time", in_use_time) - if await self.unanswered.contains(channel.id): - if await self.unanswered.get(channel.id): + unanswered = await self.unanswered.get(channel.id) + if unanswered is not None: + if unanswered: self.bot.stats.incr("help.sessions.unanswered") else: self.bot.stats.incr("help.sessions.answered") log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") - log.trace(f"Sending dormant message for #{channel} ({channel.id}).") embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) -- cgit v1.2.3 From 6e34de6397e6ab7d23c6e6abb74a2156375b2c2d Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 6 Jun 2020 21:29:33 +0200 Subject: Move cancel_task before notifier.remove_channel - as sugested notifier.remove_channel and muted_channels.discard should be together --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 13f84009f..d5b9621d2 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -161,8 +161,8 @@ class Silence(Scheduler, commands.Cog): if current_overwrite.send_messages is False: await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") - self.notifier.remove_channel(channel) self.cancel_task(channel.id) + self.notifier.remove_channel(channel) self.muted_channels.discard(channel) return True log.info(f"Tried to unsilence channel #{channel} ({channel.id}) but the channel was not silenced.") -- cgit v1.2.3 From 27e269b874fcd038388031959186ffe682c777c0 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 6 Jun 2020 21:36:02 +0200 Subject: Change `is` to `was` for unsilenced channel message - As suggested, `was` is more fitting in the message than `is` --- bot/cogs/moderation/silence.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index d5b9621d2..08f3973b0 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -123,7 +123,7 @@ class Silence(Scheduler, commands.Cog): await self._get_instance_vars_event.wait() log.debug(f"Unsilencing channel #{ctx.channel} from {ctx.author}'s command.") if not await self._unsilence(ctx.channel): - await ctx.send(f"{Emojis.cross_mark} current channel is not silenced.") + await ctx.send(f"{Emojis.cross_mark} current channel was not silenced.") else: await ctx.send(f"{Emojis.check_mark} unsilenced current channel.") -- cgit v1.2.3 From be4902cbd66c2f7223608ddbfee4aa4f0e1a011a Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 6 Jun 2020 22:11:53 +0200 Subject: Test for channel not silenced message --- tests/bot/cogs/moderation/test_silence.py | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/tests/bot/cogs/moderation/test_silence.py b/tests/bot/cogs/moderation/test_silence.py index 3fd149f04..ab3d0742a 100644 --- a/tests/bot/cogs/moderation/test_silence.py +++ b/tests/bot/cogs/moderation/test_silence.py @@ -127,10 +127,20 @@ class SilenceTests(unittest.IsolatedAsyncioTestCase): self.ctx.reset_mock() async def test_unsilence_sent_correct_discord_message(self): - """Proper reply after a successful unsilence.""" - with mock.patch.object(self.cog, "_unsilence", return_value=True): - await self.cog.unsilence.callback(self.cog, self.ctx) - self.ctx.send.assert_called_once_with(f"{Emojis.check_mark} unsilenced current channel.") + """Check if proper message was sent when unsilencing channel.""" + test_cases = ( + (True, f"{Emojis.check_mark} unsilenced current channel."), + (False, f"{Emojis.cross_mark} current channel was not silenced.") + ) + for _unsilence_patch_return, result_message in test_cases: + with self.subTest( + starting_silenced_state=_unsilence_patch_return, + result_message=result_message + ): + with mock.patch.object(self.cog, "_unsilence", return_value=_unsilence_patch_return): + await self.cog.unsilence.callback(self.cog, self.ctx) + self.ctx.send.assert_called_once_with(result_message) + self.ctx.reset_mock() async def test_silence_private_for_false(self): """Permissions are not set and `False` is returned in an already silenced channel.""" -- cgit v1.2.3 From 73f258488d32be6c00aaea2cfa20ff2b24d48b30 Mon Sep 17 00:00:00 2001 From: ItsDrike Date: Sat, 6 Jun 2020 23:41:59 +0200 Subject: Use class instead of NamedTuple - Using a class is more readable than using a NamedTuple --- bot/cogs/moderation/silence.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index 08f3973b0..c8ab6443b 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -15,9 +15,12 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -SilencedChannel = NamedTuple( - "SilencedChannel", [("ctx", Context), ("delay", int)] -) + +class TaskData(NamedTuple): + """Data for a scheduled task.""" + + delay: int + ctx: Context class SilenceNotifier(tasks.Loop): @@ -68,14 +71,14 @@ class Silence(Scheduler, commands.Cog): self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() - async def _scheduled_task(self, channel: SilencedChannel) -> None: + async def _scheduled_task(self, task: TaskData) -> None: """Calls `self.unsilence` on expired silenced channel to unsilence it.""" - await asyncio.sleep(channel.delay) + await asyncio.sleep(task.delay) log.info("Unsilencing channel after set delay.") # Because `self.unsilence` explicitly cancels this scheduled task, it is shielded # to avoid prematurely cancelling itself - await asyncio.shield(channel.ctx.invoke(self.unsilence)) + await asyncio.shield(task.ctx.invoke(self.unsilence)) async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" @@ -106,12 +109,12 @@ class Silence(Scheduler, commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") - channel = SilencedChannel( - ctx=ctx, + task_data = TaskData( delay=duration*60, + ctx=ctx ) - self.schedule_task(ctx.channel.id, channel) + self.schedule_task(ctx.channel.id, task_data) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: -- cgit v1.2.3 From 8570bd2c9d644c82e69e4c3bbae3af24f95180e2 Mon Sep 17 00:00:00 2001 From: Daniel Nash <22755628+crazygmr101@users.noreply.github.com> Date: Sun, 7 Jun 2020 03:15:51 -0500 Subject: Create cooldown.md --- bot/resources/tags/cooldown.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 bot/resources/tags/cooldown.md diff --git a/bot/resources/tags/cooldown.md b/bot/resources/tags/cooldown.md new file mode 100644 index 000000000..a4e237872 --- /dev/null +++ b/bot/resources/tags/cooldown.md @@ -0,0 +1,22 @@ +**Cooldowns** + +Cooldowns are used in discord.py to rate-limit. + +```python +from discord.ext import commands + +class SomeCog(commands.Cog): + def __init__(self): + self._cd = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) + + async def cog_check(self, ctx): + bucket = self._cd.get_bucket(ctx.message) + retry_after = bucket.update_rate_limit() + if retry_after: + # you're rate limited + # helpful message here + pass + # you're not rate limited +``` + +`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). -- cgit v1.2.3 From 97710d5bb8145d10983187bebe554b845a9c0ef1 Mon Sep 17 00:00:00 2001 From: Daniel Nash <22755628+crazygmr101@users.noreply.github.com> Date: Sun, 7 Jun 2020 03:40:59 -0500 Subject: Update cooldown.md --- bot/resources/tags/cooldown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/cooldown.md b/bot/resources/tags/cooldown.md index a4e237872..3d34c078b 100644 --- a/bot/resources/tags/cooldown.md +++ b/bot/resources/tags/cooldown.md @@ -1,7 +1,7 @@ **Cooldowns** Cooldowns are used in discord.py to rate-limit. - + ```python from discord.ext import commands -- cgit v1.2.3 From 15dbbcf865dd24f5f8697fd85bd60d53d9450fcf Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 7 Jun 2020 12:37:31 +0200 Subject: Remove pointless suppress. Since help_channel_claimants.delete will never raise a KeyError, it's not necessary to suppress one. --- bot/cogs/help_channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 01c38b408..e521e3301 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -5,7 +5,6 @@ import logging import random import typing as t from collections import deque -from contextlib import suppress from datetime import datetime from pathlib import Path @@ -224,10 +223,11 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("close command invoked; checking if the channel is in-use.") if ctx.channel.category == self.in_use_category: if await self.dormant_check(ctx): - with suppress(KeyError): - await self.help_channel_claimants.delete(ctx.channel.id) + # Remove the claimant and the cooldown role + await self.help_channel_claimants.delete(ctx.channel.id) await self.remove_cooldown_role(ctx.author) + # Ignore missing task when cooldown has passed but the channel still isn't dormant. self.cancel_task(ctx.author.id, ignore_missing=True) -- cgit v1.2.3 From 780ed87ef3d4f24e45d2cba8020342c1195f7801 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Mon, 8 Jun 2020 18:31:09 +0200 Subject: Incidents: add incidents module & new ext boilerplate --- bot/cogs/moderation/__init__.py | 4 +++- bot/cogs/moderation/incidents.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 bot/cogs/moderation/incidents.py diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 6880ca1bd..4455705f7 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -1,4 +1,5 @@ from bot.bot import Bot +from .incidents import Incidents from .infractions import Infractions from .management import ModManagement from .modlog import ModLog @@ -7,7 +8,8 @@ from .superstarify import Superstarify def setup(bot: Bot) -> None: - """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" + """Load the Incidents, Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" + bot.add_cog(Incidents(bot)) bot.add_cog(Infractions(bot)) bot.add_cog(ModLog(bot)) bot.add_cog(ModManagement(bot)) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py new file mode 100644 index 000000000..2ff1e949a --- /dev/null +++ b/bot/cogs/moderation/incidents.py @@ -0,0 +1,14 @@ +import logging + +from discord.ext.commands import Cog + +from bot.bot import Bot + +log = logging.getLogger(__name__) + + +class Incidents(Cog): + """Automation for the #incidents channel.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot -- cgit v1.2.3 From 29ab6dc350f0063bcac2218aee7c9170e83f980a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Mon, 8 Jun 2020 23:33:58 +0200 Subject: Incidents: add new emoji constants --- bot/constants.py | 4 ++++ config-default.yml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index b31a9c99e..02b82cf23 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -271,6 +271,10 @@ class Emojis(metaclass=YAMLGetter): status_idle: str status_dnd: str + incident_actioned: str + incident_unactioned: str + incident_investigating: str + failmail: str trashcan: str diff --git a/config-default.yml b/config-default.yml index 2c85f5ef3..c59abdc39 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,10 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" + incident_actioned: "<:incident_actioned:719645530128646266>" + incident_unactioned: "<:incident_unactioned:719645583245180960>" + incident_investigating: "<:incident_investigating:719645658671480924>" + failmail: "<:failmail:633660039931887616>" trashcan: "<:trashcan:637136429717389331>" -- cgit v1.2.3 From 0d3af0d52b23b3390aadf37a82e905e8ee529a90 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Mon, 8 Jun 2020 23:36:20 +0200 Subject: Incidents: create Signal enum & link members with emojis --- bot/cogs/moderation/incidents.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2ff1e949a..baceddf0c 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,12 +1,22 @@ import logging +from enum import Enum from discord.ext.commands import Cog from bot.bot import Bot +from bot.constants import Emojis log = logging.getLogger(__name__) +class Signal(Enum): + """Recognized incident status signals.""" + + ACTIONED = Emojis.incident_actioned + NOT_ACTIONED = Emojis.incident_unactioned + INVESTIGATING = Emojis.incident_investigating + + class Incidents(Cog): """Automation for the #incidents channel.""" -- cgit v1.2.3 From 378ef81383050cf4c477afc2c23abb51b700ea68 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 8 Jun 2020 18:41:25 -0700 Subject: Help channels: fix claim timestamp being local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The datetime module returns a local timestamp for naïve datetimes. It has to be timezone-aware to ensure it will always be in UTC. --- bot/cogs/help_channels.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index e521e3301..40e625338 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -5,7 +5,7 @@ import logging import random import typing as t from collections import deque -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path import discord @@ -110,7 +110,7 @@ class HelpChannels(Scheduler, commands.Cog): unanswered = RedisCache() # This dictionary maps a help channel to the time it was claimed - # RedisCache[discord.TextChannel.id, datetime.datetime] + # RedisCache[discord.TextChannel.id, UtcPosixTimestamp] claim_times = RedisCache() def __init__(self, bot: Bot): @@ -550,7 +550,7 @@ class HelpChannels(Scheduler, commands.Cog): claimed_timestamp = await self.claim_times.get(channel.id) if claimed_timestamp: - claimed = datetime.fromtimestamp(claimed_timestamp) + claimed = datetime.utcfromtimestamp(claimed_timestamp) in_use_time = datetime.utcnow() - claimed self.bot.stats.timing("help.in_use_time", in_use_time) @@ -687,7 +687,10 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr("help.claimed") - await self.claim_times.set(channel.id, datetime.utcnow().timestamp()) + # Must use a timezone-aware datetime to ensure a correct POSIX timestamp. + timestamp = datetime.now(timezone.utc).timestamp() + await self.claim_times.set(channel.id, timestamp) + await self.unanswered.set(channel.id, True) log.trace(f"Releasing on_message lock for {message.id}.") -- cgit v1.2.3 From 62cb3f7d9ecd861a13b594f3c63aed83dead2e0e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 8 Jun 2020 19:01:58 -0700 Subject: Help channels: add a function to get in use time Future code will also need to get this time, so moving it out to a separate function reduces redundancy. --- bot/cogs/help_channels.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 40e625338..13dee8e80 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -5,7 +5,7 @@ import logging import random import typing as t from collections import deque -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from pathlib import Path import discord @@ -286,6 +286,15 @@ class HelpChannels(Scheduler, commands.Cog): if channel.category_id == category.id and not self.is_excluded_channel(channel): yield channel + async def get_in_use_time(self, channel_id: int) -> t.Optional[timedelta]: + """Return the duration `channel_id` has been in use. Return None if it's not in use.""" + log.trace(f"Calculating in use time for channel {channel_id}.") + + claimed_timestamp = await self.claim_times.get(channel_id) + if claimed_timestamp: + claimed = datetime.utcfromtimestamp(claimed_timestamp) + return datetime.utcnow() - claimed + @staticmethod def get_names() -> t.List[str]: """ @@ -548,10 +557,8 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.incr(f"help.dormant_calls.{caller}") - claimed_timestamp = await self.claim_times.get(channel.id) - if claimed_timestamp: - claimed = datetime.utcfromtimestamp(claimed_timestamp) - in_use_time = datetime.utcnow() - claimed + in_use_time = await self.get_in_use_time(channel.id) + if in_use_time: self.bot.stats.timing("help.in_use_time", in_use_time) unanswered = await self.unanswered.get(channel.id) -- cgit v1.2.3 From 04b37cd054565368e5af42deec5d5ca14fc94199 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 8 Jun 2020 19:37:37 -0700 Subject: Help channels: add a function to schedule cooldown expiration Moving this code into a separate function reduces redundancy down the line. This will also get used to re-scheduled cooldowns after a restart. --- bot/cogs/help_channels.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 13dee8e80..f2785c932 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -794,11 +794,14 @@ class HelpChannels(Scheduler, commands.Cog): # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). self.cancel_task(member.id, ignore_missing=True) - timeout = constants.HelpChannels.claim_minutes * 60 - callback = self.remove_cooldown_role(member) + await self.schedule_cooldown_expiration(member, constants.HelpChannels.claim_minutes * 60) + + async def schedule_cooldown_expiration(self, member: discord.Member, seconds: int) -> None: + """Schedule the cooldown role for `member` to be removed after a duration of `seconds`.""" + log.trace(f"Scheduling removal of {member}'s ({member.id}) cooldown.") - log.trace(f"Scheduling {member}'s ({member.id}) send message permissions to be reinstated.") - self.schedule_task(member.id, TaskData(timeout, callback)) + callback = self.remove_cooldown_role(member) + self.schedule_task(member.id, TaskData(seconds, callback)) async def send_available_message(self, channel: discord.TextChannel) -> None: """Send the available message by editing a dormant message or sending a new message.""" -- cgit v1.2.3 From b49f3e5f4e707dece2de38882be44405563d82e4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 8 Jun 2020 19:52:46 -0700 Subject: Help channels: use cache to remove cooldowns or re-schedule them Using the cache is more efficient since it can check only the users it expects to have a cooldown rather than searching all guild members. Furthermore, re-scheduling the cooldowns ensures members experience the full duration of the cooldown. Previously, all cooldowns were removed, regardless of whether they were expired. --- bot/cogs/help_channels.py | 27 ++++++++++++++++++++------- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index f2785c932..098634e96 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -397,7 +397,7 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Initialising the cog.") await self.init_categories() - await self.reset_send_permissions() + await self.check_cooldowns() self.channel_queue = self.create_channel_queue() self.name_queue = self.create_name_queue() @@ -733,15 +733,28 @@ class HelpChannels(Scheduler, commands.Cog): msg = await self.get_last_message(channel) return self.match_bot_embed(msg, AVAILABLE_MSG) - async def reset_send_permissions(self) -> None: - """Reset send permissions in the Available category for claimants.""" - log.trace("Resetting send permissions in the Available category.") + async def check_cooldowns(self) -> None: + """Remove expired cooldowns and re-schedule active ones.""" + log.trace("Checking all cooldowns to remove or re-schedule them.") guild = self.bot.get_guild(constants.Guild.id) + cooldown = constants.HelpChannels.claim_minutes * 60 - # TODO: replace with a persistent cache cause checking every member is quite slow - for member in guild.members: - if self.is_claimant(member): + for channel_id, member_id in await self.help_channel_claimants.items(): + member = guild.get_member(member_id) + if not member: + continue # Member probably left the guild. + + in_use_time = await self.get_in_use_time(channel_id) + + if not in_use_time or in_use_time.seconds > cooldown: + # Remove the role if no claim time could be retrieved or if the cooldown expired. + # Since the channel is in the claimants cache, it is definitely strange for a time + # to not exist. However, it isn't a reason to keep the user stuck with a cooldown. await self.remove_cooldown_role(member) + else: + # The member is still on a cooldown; re-schedule it for the remaining time. + remaining = cooldown - in_use_time.seconds + await self.schedule_cooldown_expiration(member, remaining) async def add_cooldown_role(self, member: discord.Member) -> None: """Add the help cooldown role to `member`.""" -- cgit v1.2.3 From be78a86abea68e05ac80c8a07085cb7f0fa6d3c1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 8 Jun 2020 20:11:38 -0700 Subject: Help channels: revise inaccurate comment --- bot/cogs/help_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 098634e96..86579e940 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -647,7 +647,7 @@ class HelpChannels(Scheduler, commands.Cog): if self.is_in_category(channel, constants.Categories.help_in_use): log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") - # Check if there is an entry in unanswered (does not persist across restarts) + # Check if there is an entry in unanswered if await self.unanswered.contains(channel.id): claimant_id = await self.help_channel_claimants.get(channel.id) if not claimant_id: -- cgit v1.2.3 From 06d8ab2f7203d4ee92a040444bbb1999a36accb3 Mon Sep 17 00:00:00 2001 From: Daniel Nash Date: Wed, 10 Jun 2020 15:49:14 -0500 Subject: Rename to customcooldown.md --- bot/resources/tags/cooldown.md | 22 ---------------------- bot/resources/tags/customcooldown.md | 22 ++++++++++++++++++++++ 2 files changed, 22 insertions(+), 22 deletions(-) delete mode 100644 bot/resources/tags/cooldown.md create mode 100644 bot/resources/tags/customcooldown.md diff --git a/bot/resources/tags/cooldown.md b/bot/resources/tags/cooldown.md deleted file mode 100644 index 3d34c078b..000000000 --- a/bot/resources/tags/cooldown.md +++ /dev/null @@ -1,22 +0,0 @@ -**Cooldowns** - -Cooldowns are used in discord.py to rate-limit. - -```python -from discord.ext import commands - -class SomeCog(commands.Cog): - def __init__(self): - self._cd = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) - - async def cog_check(self, ctx): - bucket = self._cd.get_bucket(ctx.message) - retry_after = bucket.update_rate_limit() - if retry_after: - # you're rate limited - # helpful message here - pass - # you're not rate limited -``` - -`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md new file mode 100644 index 000000000..3d34c078b --- /dev/null +++ b/bot/resources/tags/customcooldown.md @@ -0,0 +1,22 @@ +**Cooldowns** + +Cooldowns are used in discord.py to rate-limit. + +```python +from discord.ext import commands + +class SomeCog(commands.Cog): + def __init__(self): + self._cd = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) + + async def cog_check(self, ctx): + bucket = self._cd.get_bucket(ctx.message) + retry_after = bucket.update_rate_limit() + if retry_after: + # you're rate limited + # helpful message here + pass + # you're not rate limited +``` + +`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). -- cgit v1.2.3 From b71ff0f2bbd7be64d1a0009b9e6530ba3c179926 Mon Sep 17 00:00:00 2001 From: Daniel Nash Date: Wed, 10 Jun 2020 15:53:33 -0500 Subject: Update example to not be in a cog --- bot/resources/tags/customcooldown.md | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index 3d34c078b..35f28a1e5 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -1,22 +1,20 @@ **Cooldowns** -Cooldowns are used in discord.py to rate-limit. +Cooldowns can be used in discord.py to rate-limit. In this example, we're using it in an on_message. ```python from discord.ext import commands -class SomeCog(commands.Cog): - def __init__(self): - self._cd = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) +_cd = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) - async def cog_check(self, ctx): - bucket = self._cd.get_bucket(ctx.message) - retry_after = bucket.update_rate_limit() - if retry_after: - # you're rate limited - # helpful message here - pass - # you're not rate limited +@bot.event +async def on_message(message): + bucket = _cd.get_bucket(message) + retry_after = bucket.update_rate_limit() + if retry_after: + await message.channel.send("Slow down! You're sending messages too fast") + pass + # you're not rate limited ``` `from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). -- cgit v1.2.3 From b2195f1990c6720dad6819cc118920b89e24beba Mon Sep 17 00:00:00 2001 From: Daniel Nash Date: Wed, 10 Jun 2020 15:56:00 -0500 Subject: Move the not rate-limited message into else --- bot/resources/tags/customcooldown.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index 35f28a1e5..b44d6b12f 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -13,8 +13,10 @@ async def on_message(message): retry_after = bucket.update_rate_limit() if retry_after: await message.channel.send("Slow down! You're sending messages too fast") + else: + # you're not rate limited pass - # you're not rate limited + # more code here ``` `from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). -- cgit v1.2.3 From 76da8f09c7c8d62f8451bc561f020f489b3a0970 Mon Sep 17 00:00:00 2001 From: Daniel Nash <22755628+crazygmr101@users.noreply.github.com> Date: Wed, 10 Jun 2020 17:09:17 -0500 Subject: change _cd to message_cooldown Apply suggestions from code review Co-authored-by: Joseph Banks --- bot/resources/tags/customcooldown.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index b44d6b12f..f304de246 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -5,11 +5,11 @@ Cooldowns can be used in discord.py to rate-limit. In this example, we're using ```python from discord.ext import commands -_cd = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) +message_cooldown = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.BucketType.user) @bot.event async def on_message(message): - bucket = _cd.get_bucket(message) + bucket = message_cooldown.get_bucket(message) retry_after = bucket.update_rate_limit() if retry_after: await message.channel.send("Slow down! You're sending messages too fast") -- cgit v1.2.3 From b1b1765596469b76d6321e81732a54e46d3b5865 Mon Sep 17 00:00:00 2001 From: Daniel Nash <22755628+crazygmr101@users.noreply.github.com> Date: Wed, 10 Jun 2020 17:35:44 -0500 Subject: Update bot/resources/tags/customcooldown.md Co-authored-by: Joseph Banks --- bot/resources/tags/customcooldown.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index f304de246..f7987556d 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -14,9 +14,7 @@ async def on_message(message): if retry_after: await message.channel.send("Slow down! You're sending messages too fast") else: - # you're not rate limited - pass - # more code here + await message.channel.send("Not ratelimited!") ``` `from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). -- cgit v1.2.3 From 5407e7832f668d23c2539743eceea487cf40b99c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 07:51:34 +0300 Subject: Filtering: Fix some comments Co-authored-by: Joseph Banks --- bot/cogs/filtering.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index caf204561..45e712626 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -57,7 +57,7 @@ def expand_spoilers(text: str) -> str: class Filtering(Cog): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" - # Redis cache for last bad words in nickname alert sent per user. + # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent name_alerts = RedisCache() def __init__(self, bot: Bot): @@ -161,7 +161,7 @@ class Filtering(Cog): """Send a mod alert every 3 days if a username still matches a watchlist pattern.""" # Use lock to avoid race conditions async with self.name_lock: - # Check does nickname have match in filters. + # Check whether the users display name contains any words in our blacklist matches = self.get_name_matches(member.display_name) if not matches or not await self.check_send_alert(member): -- cgit v1.2.3 From 55263370183b516198d8986cc22c6bfe5d7693c9 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 07:53:19 +0300 Subject: Filtering: Fix nickname filter alert sending spaces Co-authored-by: Joseph Banks --- bot/cogs/filtering.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 45e712626..ff915ea2c 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -168,11 +168,13 @@ class Filtering(Cog): return log.info(f"Sending bad nickname alert for '{member.display_name}' ({member.id}).") + log_string = ( f"**User:** {member.mention} (`{member.id}`)\n" f"**Display Name:** {member.display_name}\n" f"**Bad Matches:** {', '.join(match.group() for match in matches)}" ) + await self.mod_log.send_log_message( icon_url=Icons.token_removed, colour=Colours.soft_red, -- cgit v1.2.3 From c4acae166fa04b0e47a6faa5e454a1de8beba6b7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 08:22:01 +0300 Subject: Filtering: Use walrus for better looking of code --- bot/cogs/filtering.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index ff915ea2c..841f735e3 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -141,15 +141,13 @@ class Filtering(Cog): """Check bad words from passed string (name). Return list of matches.""" matches = [] for pattern in WATCHLIST_PATTERNS: - match = pattern.search(name) - if match: + if match := pattern.search(name): matches.append(match) return matches async def check_send_alert(self, member: Member) -> bool: """When there is less than 3 days after last alert, return `False`, otherwise `True`.""" - last_alert = await self.name_alerts.get(member.id) - if last_alert: + if last_alert := await self.name_alerts.get(member.id): last_alert = datetime.utcfromtimestamp(last_alert) if datetime.utcnow() - timedelta(days=DAYS_BETWEEN_ALERTS) < last_alert: log.trace(f"Last alert was too recent for {member}'s nickname.") -- cgit v1.2.3 From f26f70c3433b5043c73986c51f6c2f18ffa60761 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 11 Jun 2020 08:32:34 +0300 Subject: Filtering: Add user avatar thumbnail to nickname alert embed --- bot/cogs/filtering.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 841f735e3..4ebc831e1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -178,7 +178,8 @@ class Filtering(Cog): colour=Colours.soft_red, title="Username filtering alert", text=log_string, - channel_id=Channels.mod_alerts + channel_id=Channels.mod_alerts, + thumbnail=member.avatar_url ) # Update time when alert sent -- cgit v1.2.3 From efa452830e6f6db1e775371e8f7549772aa11702 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Thu, 11 Jun 2020 11:43:12 +0100 Subject: Create codeql-analysis.yml --- .github/workflows/codeql-analysis.yml | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 .github/workflows/codeql-analysis.yml diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 000000000..34ba4a679 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,32 @@ +name: "Code scanning - action" + +on: + push: + pull_request: + schedule: + - cron: '0 12 * * *' + +jobs: + CodeQL-Build: + + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + with: + fetch-depth: 2 + + - run: git checkout HEAD^2 + if: ${{ github.event_name == 'pull_request' }} + + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: python + + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 -- cgit v1.2.3 From b0d92ba56bdf8aad14cf09061213ee64a6f2f142 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Thu, 11 Jun 2020 11:46:36 +0100 Subject: Fix trailing whitespace in Action file --- .github/workflows/codeql-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 34ba4a679..8760b35ec 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -19,7 +19,7 @@ jobs: - run: git checkout HEAD^2 if: ${{ github.event_name == 'pull_request' }} - + - name: Initialize CodeQL uses: github/codeql-action/init@v1 with: -- cgit v1.2.3 From 16f160fda34c67c9840ed753b593d93d460a0d97 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Thu, 11 Jun 2020 12:44:17 +0100 Subject: Add cooldown channel to config-default.yml --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 3a1bdae54..3388e5f78 100644 --- a/config-default.yml +++ b/config-default.yml @@ -142,6 +142,7 @@ guild: # Python Help: Available how_to_get_help: 704250143020417084 + cooldown: 720603994149486673 # Logs attachment_log: &ATTACH_LOG 649243850006855680 -- cgit v1.2.3 From 1412d0157227526323d0ab332daa503301b6041e Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Thu, 11 Jun 2020 12:47:18 +0100 Subject: Add cooldown channel to EXCLUDED_CHANNELS tuple --- bot/cogs/help_channels.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 70cef339a..6ff285c37 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -22,7 +22,7 @@ log = logging.getLogger(__name__) ASKING_GUIDE_URL = "https://pythondiscord.com/pages/asking-good-questions/" MAX_CHANNELS_PER_CATEGORY = 50 -EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help,) +EXCLUDED_CHANNELS = (constants.Channels.how_to_get_help, constants.Channels.cooldown) HELP_CHANNEL_TOPIC = """ This is a Python help channel. You can claim your own help channel in the Python Help: Available category. -- cgit v1.2.3 From ab63cffa31be9e3d2a225a52fc7192c651614175 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Thu, 11 Jun 2020 12:55:39 +0100 Subject: Add cooldown to Channels in constants.py --- bot/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/constants.py b/bot/constants.py index b31a9c99e..470221369 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -389,6 +389,7 @@ class Channels(metaclass=YAMLGetter): attachment_log: int big_brother_logs: int bot_commands: int + cooldown: int defcon: int dev_contrib: int dev_core: int -- cgit v1.2.3 From f4767769afc8c6dfe4ac81d4e9b9e02f2f58054c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 11 Jun 2020 18:06:59 +0200 Subject: Incidents: add #incidents-archive channel constant --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 02b82cf23..02c8adf43 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -401,6 +401,7 @@ class Channels(metaclass=YAMLGetter): helpers: int how_to_get_help: int incidents: int + incidents_archive: int message_log: int meta: int mod_alerts: int diff --git a/config-default.yml b/config-default.yml index c59abdc39..a68647f72 100644 --- a/config-default.yml +++ b/config-default.yml @@ -176,6 +176,7 @@ guild: organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 incidents: 714214212200562749 + incidents_archive: 720668923636351037 # Voice admins_voice: &ADMINS_VOICE 500734494840717332 -- cgit v1.2.3 From 5db3a82de9f37d769ed8983c83063dfdd6878fee Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 11 Jun 2020 18:26:21 +0200 Subject: Incidents: add #incidents-archive webhook constant --- bot/constants.py | 1 + config-default.yml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 02c8adf43..c663db333 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -430,6 +430,7 @@ class Webhooks(metaclass=YAMLGetter): reddit: int duck_pond: int dev_log: int + incidents_archive: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index a68647f72..974ce508d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -255,7 +255,7 @@ guild: duck_pond: 637821475327311927 dev_log: 680501655111729222 python_news: &PYNEWS_WEBHOOK 704381182279942324 - + incidents_archive: 720671599790915702 filter: -- cgit v1.2.3 From d520203717b8aaa6358071978a1ac9a23418d1c9 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 11 Jun 2020 18:45:50 +0200 Subject: Incidents: define allowed roles and emoji These serve as whitelists, i.e. any reaction using an emoji not explicitly allowed, or from a user not specifically allowed, will be rejected. Such reactions will be removed by the bot. --- bot/cogs/moderation/incidents.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index baceddf0c..49180da7c 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -4,7 +4,7 @@ from enum import Enum from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Emojis +from bot.constants import Emojis, Roles log = logging.getLogger(__name__) @@ -17,6 +17,10 @@ class Signal(Enum): INVESTIGATING = Emojis.incident_investigating +ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} +ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} + + class Incidents(Cog): """Automation for the #incidents channel.""" -- cgit v1.2.3 From df2b40ef8ac8cb69a7af6602ab77025c1549dbe1 Mon Sep 17 00:00:00 2001 From: Mark Date: Thu, 11 Jun 2020 22:07:39 -0700 Subject: Replace mention of Flask with Django The site's description still stated that it was built with Flask, which is no longer accurate due to the move to Django. --- bot/cogs/site.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/site.py b/bot/cogs/site.py index e61cd5003..ac29daa1d 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -33,7 +33,7 @@ class Site(Cog): embed.colour = Colour.blurple() embed.description = ( f"[Our official website]({url}) is an open-source community project " - "created with Python and Flask. It contains information about the server " + "created with Python and Django. It contains information about the server " "itself, lets you sign up for upcoming events, has its own wiki, contains " "a list of valuable learning resources, and much more." ) -- cgit v1.2.3 From 3195d16cf16f80dca6b66b87bc7b954d10d60e7a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 13:25:02 +0200 Subject: Incidents: define method stubs for message event handling --- bot/cogs/moderation/incidents.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 49180da7c..c85a68a14 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,6 +1,7 @@ import logging from enum import Enum +import discord from discord.ext.commands import Cog from bot.bot import Bot @@ -26,3 +27,12 @@ class Incidents(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot + + async def add_signals(self, incident: discord.Message) -> None: + """Add `Signal` member emoji to `incident` as reactions.""" + ... + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Pass each incident sent in #incidents to `add_signals`.""" + ... -- cgit v1.2.3 From 781d8f8d4bc76cb2ca9db4f3b7149d11892e714b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 13:28:32 +0200 Subject: Incidents: implement `add_signals` helper Looks like it can be static, at least for now. --- bot/cogs/moderation/incidents.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index c85a68a14..2424c008d 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,4 +1,5 @@ import logging +import typing as t from enum import Enum import discord @@ -28,9 +29,12 @@ class Incidents(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - async def add_signals(self, incident: discord.Message) -> None: + @staticmethod + async def add_signals(incident: discord.Message) -> None: """Add `Signal` member emoji to `incident` as reactions.""" - ... + for signal_emoji in Signal: + log.debug(f"Adding reaction: {signal_emoji.value}") + await incident.add_reaction(signal_emoji.value) @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From e8bb1aa59dece803a920efb5ebbdd6098025bdc6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 13:34:34 +0200 Subject: Incidents: implement `on_message` listener & guards --- bot/cogs/moderation/incidents.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2424c008d..91b949173 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -6,7 +6,7 @@ import discord from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Emojis, Roles +from bot.constants import Channels, Emojis, Roles log = logging.getLogger(__name__) @@ -38,5 +38,22 @@ class Incidents(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: - """Pass each incident sent in #incidents to `add_signals`.""" - ... + """ + Pass each incident sent in #incidents to `add_signals`. + + We recognize several exceptions. The following will be ignored: + * Messages sent outside of #incidents + * Messages Sent by bots + * Messages starting with the hash symbol # + + Prefix message with # in situations where a verbal response is necessary. + Each such message must be deleted manually. + """ + if message.channel.id != Channels.incidents or message.author.bot: + return + + if message.content.startswith("#"): + log.debug(f"Ignoring comment message: {message.content=}") + return + + await self.add_signals(message) -- cgit v1.2.3 From 5b6b2de2fdfb9b2893b4e9321e4a46b19b4bfb20 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:01:47 +0200 Subject: Incidents: implement & schedule `crawl_incidents` task See docstring for further information. This will run on start-up to retroactively add missing emoji. Ratelimit-wise this should be fine, as there should never be too many missing emoji. --- bot/cogs/moderation/incidents.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 91b949173..e773636e7 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,3 +1,4 @@ +import asyncio import logging import typing as t from enum import Enum @@ -27,7 +28,38 @@ class Incidents(Cog): """Automation for the #incidents channel.""" def __init__(self, bot: Bot) -> None: + """Schedule `crawl_task` on start-up.""" self.bot = bot + self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) + + async def crawl_incidents(self) -> None: + """ + Crawl #incidents and add missing emoji where necessary. + + This is to catch-up should an incident be reported while the bot wasn't listening. + Internally, we simply walk the channel history and pass each message to `on_message`. + + In order to avoid drowning in ratelimits, we take breaks after each message. + + Once this task is scheduled, listeners should await it. The crawl assumes that + the channel history doesn't change as we go over it. + """ + await self.bot.wait_until_guild_available() + incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) + + # Limit the query at 50 as in practice, there should never be this many messages, + # and if there are, something has likely gone very wrong + limit = 50 + + # Seconds to sleep after each message + sleep = 2 + + log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") + async for message in incidents.history(limit=limit): + await self.on_message(message) + await asyncio.sleep(sleep) + + log.debug("Crawl task finished!") @staticmethod async def add_signals(incident: discord.Message) -> None: -- cgit v1.2.3 From 1aaaee1144f660af7a69d12f814d0073451da7be Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:02:58 +0200 Subject: Incidents: make `on_message` ignore pinned messages This is now necessary as we call the listener ourselves from the crawl task. An already existing, pinned message, can be received. --- bot/cogs/moderation/incidents.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index e773636e7..1b9d26522 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -77,6 +77,7 @@ class Incidents(Cog): * Messages sent outside of #incidents * Messages Sent by bots * Messages starting with the hash symbol # + * Pinned (header) messages Prefix message with # in situations where a verbal response is necessary. Each such message must be deleted manually. @@ -88,4 +89,8 @@ class Incidents(Cog): log.debug(f"Ignoring comment message: {message.content=}") return + if message.pinned: + log.debug(f"Ignoring header message: {message.pinned=}") + return + await self.add_signals(message) -- cgit v1.2.3 From 1f6cd4313c91ed114a1de04de14355648fe88bf9 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:06:12 +0200 Subject: Incidents: only `add_signals` if missing --- bot/cogs/moderation/incidents.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 1b9d26522..43b1106ad 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -64,9 +64,17 @@ class Incidents(Cog): @staticmethod async def add_signals(incident: discord.Message) -> None: """Add `Signal` member emoji to `incident` as reactions.""" + existing_reacts = {str(reaction.emoji) for reaction in incident.reactions if reaction.me} + for signal_emoji in Signal: - log.debug(f"Adding reaction: {signal_emoji.value}") - await incident.add_reaction(signal_emoji.value) + + # This will not raise, but it is a superfluous API call that can be avoided + if signal_emoji.value in existing_reacts: + log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") + + else: + log.debug(f"Adding reaction: {signal_emoji}") + await incident.add_reaction(signal_emoji.value) @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 0f9f25e703325bae172148bb6a30c1118b905fcb Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:22:02 +0200 Subject: Incidents: add `event_lock` for simple event synchronization --- bot/cogs/moderation/incidents.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 43b1106ad..1cfa45dc4 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -28,8 +28,10 @@ class Incidents(Cog): """Automation for the #incidents channel.""" def __init__(self, bot: Bot) -> None: - """Schedule `crawl_task` on start-up.""" + """Prepare `event_lock` and schedule `crawl_task` on start-up.""" self.bot = bot + + self.event_lock = asyncio.Lock() self.crawl_task = self.bot.loop.create_task(self.crawl_incidents()) async def crawl_incidents(self) -> None: -- cgit v1.2.3 From 5762e57696978843991058f7bbfa826e3020dbba Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 14:53:00 +0200 Subject: Incidents: abstract incident checking into a helper method The code is now basically self-documenting, the docstring is no longer necessary. The ultimate goal is to allow `crawl_incidents` to be more smart about which messages need to be passed to `add_signals`, so that it doesn't need to sleep after each message. --- bot/cogs/moderation/incidents.py | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 1cfa45dc4..e3c3922a1 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -24,6 +24,17 @@ ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} +def is_incident(message: discord.Message) -> bool: + """True if `message` qualifies as an incident, False otherwise.""" + conditions = ( + message.channel.id == Channels.incidents, # Message sent in #incidents + not message.author.bot, # Not by a bot + not message.content.startswith("#"), # Doesn't start with a hash + not message.pinned, # And isn't header + ) + return all(conditions) + + class Incidents(Cog): """Automation for the #incidents channel.""" @@ -80,27 +91,6 @@ class Incidents(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: - """ - Pass each incident sent in #incidents to `add_signals`. - - We recognize several exceptions. The following will be ignored: - * Messages sent outside of #incidents - * Messages Sent by bots - * Messages starting with the hash symbol # - * Pinned (header) messages - - Prefix message with # in situations where a verbal response is necessary. - Each such message must be deleted manually. - """ - if message.channel.id != Channels.incidents or message.author.bot: - return - - if message.content.startswith("#"): - log.debug(f"Ignoring comment message: {message.content=}") - return - - if message.pinned: - log.debug(f"Ignoring header message: {message.pinned=}") - return - - await self.add_signals(message) + """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" + if is_incident(message): + await self.add_signals(message) -- cgit v1.2.3 From 166fc5f441a56d86202e857059011fdc75ce2740 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:04:24 +0200 Subject: Incidents: implement `own_reactions` helper --- bot/cogs/moderation/incidents.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index e3c3922a1..8a49ec8b1 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -35,6 +35,11 @@ def is_incident(message: discord.Message) -> bool: return all(conditions) +def own_reactions(message: discord.Message) -> t.Set[str]: + """Get the set of reactions placed on `message` by the bot itself.""" + return {str(reaction.emoji) for reaction in message.reactions if reaction.me} + + class Incidents(Cog): """Automation for the #incidents channel.""" @@ -77,7 +82,7 @@ class Incidents(Cog): @staticmethod async def add_signals(incident: discord.Message) -> None: """Add `Signal` member emoji to `incident` as reactions.""" - existing_reacts = {str(reaction.emoji) for reaction in incident.reactions if reaction.me} + existing_reacts = own_reactions(incident) for signal_emoji in Signal: -- cgit v1.2.3 From f7756b0246dec293f9918f3ea3ac4d6139affddd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:05:48 +0200 Subject: Incidents: implement `has_signals` helper --- bot/cogs/moderation/incidents.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 8a49ec8b1..0d146bdc5 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -40,6 +40,12 @@ def own_reactions(message: discord.Message) -> t.Set[str]: return {str(reaction.emoji) for reaction in message.reactions if reaction.me} +def has_signals(message: discord.Message) -> bool: + """True if `message` already has all `Signal` reactions, False otherwise.""" + missing_signals = ALLOWED_EMOJI - own_reactions(message) + return not missing_signals + + class Incidents(Cog): """Automation for the #incidents channel.""" -- cgit v1.2.3 From b7f61a4bf92b19a42dd1f72336d67a092b5d8029 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:10:21 +0200 Subject: Incidents: move `add_signals` to module namespace Looks like we'll need quite a few helpers, and I think it's cleaner to keep them at module level. It helps avoid the question of: what do I do if a staticmethod depends on another staticmethod? --- bot/cogs/moderation/incidents.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 0d146bdc5..f7ef86836 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -46,6 +46,21 @@ def has_signals(message: discord.Message) -> bool: return not missing_signals +async def add_signals(incident: discord.Message) -> None: + """Add `Signal` member emoji to `incident` as reactions.""" + existing_reacts = own_reactions(incident) + + for signal_emoji in Signal: + + # This will not raise, but it is a superfluous API call that can be avoided + if signal_emoji.value in existing_reacts: + log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") + + else: + log.debug(f"Adding reaction: {signal_emoji}") + await incident.add_reaction(signal_emoji.value) + + class Incidents(Cog): """Automation for the #incidents channel.""" @@ -85,23 +100,8 @@ class Incidents(Cog): log.debug("Crawl task finished!") - @staticmethod - async def add_signals(incident: discord.Message) -> None: - """Add `Signal` member emoji to `incident` as reactions.""" - existing_reacts = own_reactions(incident) - - for signal_emoji in Signal: - - # This will not raise, but it is a superfluous API call that can be avoided - if signal_emoji.value in existing_reacts: - log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") - - else: - log.debug(f"Adding reaction: {signal_emoji}") - await incident.add_reaction(signal_emoji.value) - @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" if is_incident(message): - await self.add_signals(message) + await add_signals(message) -- cgit v1.2.3 From 9a540a344ad79cd5766389d36e75536d751862b0 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:19:15 +0200 Subject: Incidents: make `crawl_incidents` smarter The crawler now avoids making API calls for messages which: * Are not incidents * Already have all signals As a result, we can sleep only after making actual calls. This speeds up the task completion considerable, while also making it lighter on the API. Victory! --- bot/cogs/moderation/incidents.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index f7ef86836..d2b4581e7 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -76,12 +76,10 @@ class Incidents(Cog): Crawl #incidents and add missing emoji where necessary. This is to catch-up should an incident be reported while the bot wasn't listening. - Internally, we simply walk the channel history and pass each message to `on_message`. + After adding reactions, we take a short break to avoid drowning in ratelimits. - In order to avoid drowning in ratelimits, we take breaks after each message. - - Once this task is scheduled, listeners should await it. The crawl assumes that - the channel history doesn't change as we go over it. + Once this task is scheduled, listeners that change messages should await it. + The crawl assumes that the channel history doesn't change as we go over it. """ await self.bot.wait_until_guild_available() incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) @@ -95,7 +93,16 @@ class Incidents(Cog): log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") async for message in incidents.history(limit=limit): - await self.on_message(message) + + if not is_incident(message): + log.debug("Skipping message: not an incident") + continue + + if has_signals(message): + log.debug("Skipping message: already has all signals") + continue + + await add_signals(message) await asyncio.sleep(sleep) log.debug("Crawl task finished!") -- cgit v1.2.3 From ccd3a17d6a8723cabb21a4b2c7e0d8a835dc9e99 Mon Sep 17 00:00:00 2001 From: Daniel Nash <22755628+crazygmr101@users.noreply.github.com> Date: Fri, 12 Jun 2020 08:42:30 -0500 Subject: Make title more specific Co-authored-by: Mark --- bot/resources/tags/customcooldown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index f7987556d..4060a9827 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -1,4 +1,4 @@ -**Cooldowns** +**Cooldowns in discord.py** Cooldowns can be used in discord.py to rate-limit. In this example, we're using it in an on_message. -- cgit v1.2.3 From 844726f7bedc8c1d772c5f866a29a52cda6f3a9f Mon Sep 17 00:00:00 2001 From: Daniel Nash <22755628+crazygmr101@users.noreply.github.com> Date: Fri, 12 Jun 2020 08:48:16 -0500 Subject: Update customcooldown.md --- bot/resources/tags/customcooldown.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index 4060a9827..3f6db0ec6 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -10,6 +10,8 @@ message_cooldown = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.Bu @bot.event async def on_message(message): bucket = message_cooldown.get_bucket(message) + # update_rate_limit returns a time you need to wait before + # trying again retry_after = bucket.update_rate_limit() if retry_after: await message.channel.send("Slow down! You're sending messages too fast") -- cgit v1.2.3 From 08db3df18711ff57a56fef99d0ad725669448f3b Mon Sep 17 00:00:00 2001 From: Daniel Nash <22755628+crazygmr101@users.noreply.github.com> Date: Fri, 12 Jun 2020 08:50:30 -0500 Subject: Add scheme to URL --- bot/resources/tags/customcooldown.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index 3f6db0ec6..e877e4dae 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -19,4 +19,4 @@ async def on_message(message): await message.channel.send("Not ratelimited!") ``` -`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). +`from_cooldown` takes the amount of `update_rate_limit()`s needed to trigger the cooldown, the time in which the cooldown is triggered, and a [`BucketType`](https://discordpy.readthedocs.io/en/latest/ext/commands/api.html#discord.discord.ext.commands.BucketType). -- cgit v1.2.3 From 9e6835cef2210910db4ad110c0906a09fd5c5411 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:40:39 +0200 Subject: Incidents: implement `resolve_message` See docstring. The exception log is DEBUG level as failure does not necessarily indicate that we have done something wrong. We rely on the API to tell us that the message no longer exists in situations where we have 2 coroutines racing to archive the same message. --- bot/cogs/moderation/incidents.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index d2b4581e7..d994054c8 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -107,6 +107,37 @@ class Incidents(Cog): log.debug("Crawl task finished!") + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: + """ + Get `discord.Message` for `message_id` from cache, or API. + + We first look into the local cache to see if the message is present. + + If not, we try to fetch the message from the API. This is necessary for messages + which were sent before the bot's current session. + + However, in an edge-case, it is also possible that the message was already deleted, + and the API will return a 404. In such a case, None will be returned. This signals + that the event for `message_id` should be ignored. + """ + await self.bot.wait_until_guild_available() # First make sure that the cache is ready + log.debug(f"Resolving message for: {message_id=}") + message: discord.Message = self.bot._connection._get_message(message_id) # noqa: Private attribute + + if message is not None: + log.debug("Message was found in cache") + return message + + log.debug("Message not found, attempting to fetch") + try: + message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) + except Exception as exc: + log.debug(f"Failed to fetch message: {exc}") + return None + else: + log.debug("Message fetched successfully!") + return message + @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" -- cgit v1.2.3 From 3c699936bf3cfa076f7791d6b8fe16ad4dd94aa6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 15:59:15 +0200 Subject: Incidents: implement reaction listener See docstring! --- bot/cogs/moderation/incidents.py | 46 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index d994054c8..88ed04f45 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -138,6 +138,52 @@ class Incidents(Cog): log.debug("Message fetched successfully!") return message + async def process_event(self, reaction: str, message: discord.Message, member: discord.Member) -> None: + log.debug("Processing event...") + + @Cog.listener() + async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: + """ + Pre-process `payload` and pass it to `process_event` if appropriate. + + We abort instantly if `payload` doesn't relate to a message sent in #incidents. + + If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has + finished, to make sure we don't mutate channel state as we're crawling it. + + Next, we acquire `event_lock` - to prevent racing, events are processed one at a time. + + Once we have the lock, the `discord.Message` object for this event must be resolved. + If the lock was previously held by an event which successfully relayed the incident, + this will fail and we abort the current event. + + Finally, with both the lock and the `discord.Message` instance in our hands, we delegate + to `process_event` to handle the event. + + The justification for using a raw listener is the need to receive events for messages + which were not cached in the current session. As a result, a certain amount of + complexity is introduced, but at the moment this doesn't appear to be avoidable. + """ + if payload.channel_id != Channels.incidents: + return + + log.debug(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") + await self.crawl_task + + log.debug(f"Acquiring event lock: {self.event_lock.locked()=}") + async with self.event_lock: + message = await self.resolve_message(payload.message_id) + + if message is None: + log.debug("Listener will abort as related message does not exist!") + return + + if not is_incident(message): + log.debug("Ignoring event for a non-incident message") + return + + await self.process_event(str(payload.emoji), message, payload.member) + @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Pass `message` to `add_signals` if and only if it satisfies `is_incident`.""" -- cgit v1.2.3 From d7165bf5547340242cb99460a35cabd753d60c42 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:17:36 +0200 Subject: Incidents: implement `archive` method --- bot/cogs/moderation/incidents.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 88ed04f45..8781d6749 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -7,7 +7,7 @@ import discord from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels, Emojis, Roles +from bot.constants import Channels, Emojis, Roles, Webhooks log = logging.getLogger(__name__) @@ -107,6 +107,44 @@ class Incidents(Cog): log.debug("Crawl task finished!") + async def archive(self, incident: discord.Message, outcome: Signal) -> bool: + """ + Relay `incident` to the #incidents-archive channel. + + The following pieces of information are relayed: + * Incident message content (clean, pingless) + * Incident author name (as webhook author) + * Incident author avatar (as webhook avatar) + * Resolution signal (`outcome`) + + Return True if the relay finishes successfully. If anything goes wrong, meaning + not all information was relayed, return False. This signals that the original + message is not safe to be deleted, as we will lose some information. + """ + log.debug(f"Archiving incident: {incident.id} with outcome: {outcome}") + try: + # First we try to grab the webhook + webhook: discord.Webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) + + # Now relay the incident + message: discord.Message = await webhook.send( + content=incident.clean_content, # Clean content will prevent mentions from pinging + username=incident.author.name, + avatar_url=incident.author.avatar_url, + wait=True, # This makes the method return the sent Message object + ) + + # Finally add the `outcome` emoji + await message.add_reaction(outcome.value) + + except Exception as exc: + log.exception("Failed to archive incident to #incidents-archive", exc_info=exc) + return False + + else: + log.debug("Message archived successfully!") + return True + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. -- cgit v1.2.3 From 44e30289d9682a92ef6e6d2ca8e9cf9b669ad65c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:23:26 +0200 Subject: Incidents: implement `make_confirmation_task` method --- bot/cogs/moderation/incidents.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 8781d6749..2186530d9 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -145,6 +145,21 @@ class Incidents(Cog): log.debug("Message archived successfully!") return True + def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task: + """ + Create a task to wait `timeout` seconds for `incident` to be deleted. + + If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't + been able to confirm that the message was deleted. + """ + log.debug(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + coroutine = self.bot.wait_for( + event="raw_message_delete", + check=lambda payload: payload.message_id == incident.id, + timeout=timeout, + ) + return self.bot.loop.create_task(coroutine) + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. -- cgit v1.2.3 From 7bc6aff14c5a78b708b11cafbd4eba431b3fe52b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:28:56 +0200 Subject: Incidents: implement `process_event` coroutine This contains the main logic for handling reactions and glues all the helpers together. Unfortunately, gracefully handling everything that can go wrong in the process requires quite a lot of code ~ but, at least to me, it seems like this all should now be fairly safe. The idea to await the message delete event before releasing the lock was conceived by Ves, while Mark helped me refine it. Co-authored-by: Sebastiaan Zeeff Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 55 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2186530d9..00cceca7d 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -160,6 +160,58 @@ class Incidents(Cog): ) return self.bot.loop.create_task(coroutine) + async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: + """ + Process a valid `reaction_add` event in #incidents. + + First, we check that the reaction is a recognized `Signal` member, and that it was sent by + a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed. + + If the reaction was either `Signal.ACTIONED` or `Signal.NOT_ACTIONED`, we attempt to relay + the report to #incidents-archive. If successful, the original message is deleted. + + We do not release `event_lock` until we receive the corresponding `message_delete` event. + This ensures that if there is a racing event awaiting the lock, it will fail to find the + message, and will abort. + """ + members_roles: t.Set[int] = {role.id for role in member.roles} + if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element + log.debug(f"Removing invalid reaction: user {member} is not permitted to send signals") + await incident.remove_reaction(reaction, member) + return + + if reaction not in ALLOWED_EMOJI: + log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") + await incident.remove_reaction(reaction, member) + return + + # If we reach this point, we know that `emoji` is a `Signal` member + signal = Signal(reaction) + log.debug(f"Received signal: {signal}") + + if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): + log.debug("Reaction was valid, but no action is currently defined for it") + return + + relay_successful = await self.archive(incident, signal) + if not relay_successful: + log.debug("Original message will not be deleted as we failed to relay it to the archive") + return + + timeout = 5 # Seconds + confirmation_task = self.make_confirmation_task(incident, timeout) + + log.debug("Deleting original message") + await incident.delete() + + log.debug(f"Awaiting deletion confirmation: {timeout=} seconds") + try: + await confirmation_task + except asyncio.TimeoutError: + log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") + else: + log.debug("Deletion was confirmed") + async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ Get `discord.Message` for `message_id` from cache, or API. @@ -191,9 +243,6 @@ class Incidents(Cog): log.debug("Message fetched successfully!") return message - async def process_event(self, reaction: str, message: discord.Message, member: discord.Member) -> None: - log.debug("Processing event...") - @Cog.listener() async def on_raw_reaction_add(self, payload: discord.RawReactionActionEvent) -> None: """ -- cgit v1.2.3 From 506f91a77d3dd1bb92222bd3fce4a7316677ddbb Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:37:27 +0200 Subject: Incidents: do not process reaction events from bots --- bot/cogs/moderation/incidents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 00cceca7d..f19bdb41f 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -248,7 +248,8 @@ class Incidents(Cog): """ Pre-process `payload` and pass it to `process_event` if appropriate. - We abort instantly if `payload` doesn't relate to a message sent in #incidents. + We abort instantly if `payload` doesn't relate to a message sent in #incidents, + or if it was sent by a bot. If `payload` relates to a message in #incidents, we first ensure that `crawl_task` has finished, to make sure we don't mutate channel state as we're crawling it. @@ -266,7 +267,7 @@ class Incidents(Cog): which were not cached in the current session. As a result, a certain amount of complexity is introduced, but at the moment this doesn't appear to be avoidable. """ - if payload.channel_id != Channels.incidents: + if payload.channel_id != Channels.incidents or payload.member.bot: return log.debug(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") -- cgit v1.2.3 From f41794d31135209a7a38cc9c17ac62d3e06f6279 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 17:56:05 +0200 Subject: Incidents: log `event_lock` release --- bot/cogs/moderation/incidents.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index f19bdb41f..d69439dc3 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -286,6 +286,7 @@ class Incidents(Cog): return await self.process_event(str(payload.emoji), message, payload.member) + log.debug("Releasing event lock") @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 0eb62724baba42fffffcd47ff4fe5451dc521593 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 18:01:10 +0200 Subject: Incidents: avoid lambda check; make regular function --- bot/cogs/moderation/incidents.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index d69439dc3..16af17f99 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -153,11 +153,11 @@ class Incidents(Cog): been able to confirm that the message was deleted. """ log.debug(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") - coroutine = self.bot.wait_for( - event="raw_message_delete", - check=lambda payload: payload.message_id == incident.id, - timeout=timeout, - ) + + def check(payload: discord.RawReactionActionEvent) -> bool: + return payload.message_id == incident.id + + coroutine = self.bot.wait_for(event="raw_message_delete", check=check, timeout=timeout) return self.bot.loop.create_task(coroutine) async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: -- cgit v1.2.3 From 39691c052d50907c049f3294cc5eef6536461656 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 21:08:14 +0200 Subject: Incidents: extend documentation This adds a proper class docstring & small touch-ups to local comments where necessary. --- bot/cogs/moderation/incidents.py | 60 ++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 11 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 16af17f99..151584d38 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -13,13 +13,19 @@ log = logging.getLogger(__name__) class Signal(Enum): - """Recognized incident status signals.""" + """ + Recognized incident status signals. + + This binds emoji to actions. The bot will only react to emoji linked here. + All other signals are seen as invalid. + """ ACTIONED = Emojis.incident_actioned NOT_ACTIONED = Emojis.incident_unactioned INVESTIGATING = Emojis.incident_investigating +# Reactions from roles not listed here, or using emoji not listed here, will be removed ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} @@ -42,12 +48,16 @@ def own_reactions(message: discord.Message) -> t.Set[str]: def has_signals(message: discord.Message) -> bool: """True if `message` already has all `Signal` reactions, False otherwise.""" - missing_signals = ALLOWED_EMOJI - own_reactions(message) + missing_signals = ALLOWED_EMOJI - own_reactions(message) # In `ALLOWED_EMOJI` but not in `own_reactions(message)` return not missing_signals async def add_signals(incident: discord.Message) -> None: - """Add `Signal` member emoji to `incident` as reactions.""" + """ + Add `Signal` member emoji to `incident` as reactions. + + If the emoji has already been placed on `incident` by the bot, it will be skipped. + """ existing_reacts = own_reactions(incident) for signal_emoji in Signal: @@ -62,7 +72,34 @@ async def add_signals(incident: discord.Message) -> None: class Incidents(Cog): - """Automation for the #incidents channel.""" + """ + Automation for the #incidents channel. + + This cog does not provide a command API, it only reacts to the following events. + + On start-up: + * Crawl #incidents and add missing `Signal` emoji where appropriate + * This is to retro-actively add the available options for messages which + were sent while the bot wasn't listening + * Pinned messages and message starting with # do not qualify as incidents + * See: `crawl_incidents` + + On message: + * Add `Signal` member emoji if message qualifies as an incident + * Ignore messages starting with # + * Use this if verbal communication is necessary + * Each such message must be deleted manually once appropriate + * See: `on_message` + + On reaction: + * Remove reaction if not permitted (`ALLOWED_EMOJI`, `ALLOWED_ROLES`) + * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to + relay the incident message to #incidents-archive + * If relay successful, delete original message + * See: `on_raw_reaction_add` + + Please refer to function docstrings for implementation details. + """ def __init__(self, bot: Bot) -> None: """Prepare `event_lock` and schedule `crawl_task` on start-up.""" @@ -76,7 +113,7 @@ class Incidents(Cog): Crawl #incidents and add missing emoji where necessary. This is to catch-up should an incident be reported while the bot wasn't listening. - After adding reactions, we take a short break to avoid drowning in ratelimits. + After adding each reaction, we take a short break to avoid drowning in ratelimits. Once this task is scheduled, listeners that change messages should await it. The crawl assumes that the channel history doesn't change as we go over it. @@ -88,7 +125,7 @@ class Incidents(Cog): # and if there are, something has likely gone very wrong limit = 50 - # Seconds to sleep after each message + # Seconds to sleep after adding reactions to a message sleep = 2 log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") @@ -162,7 +199,7 @@ class Incidents(Cog): async def process_event(self, reaction: str, incident: discord.Message, member: discord.Member) -> None: """ - Process a valid `reaction_add` event in #incidents. + Process a `reaction_add` event in #incidents. First, we check that the reaction is a recognized `Signal` member, and that it was sent by a permitted user (at least one role in `ALLOWED_ROLES`). If not, the reaction is removed. @@ -172,7 +209,8 @@ class Incidents(Cog): We do not release `event_lock` until we receive the corresponding `message_delete` event. This ensures that if there is a racing event awaiting the lock, it will fail to find the - message, and will abort. + message, and will abort. There is a `timeout` to ensure that this doesn't hold the lock + forever should something go wrong. """ members_roles: t.Set[int] = {role.id for role in member.roles} if not members_roles & ALLOWED_ROLES: # Intersection is truthy on at least 1 common element @@ -221,9 +259,9 @@ class Incidents(Cog): If not, we try to fetch the message from the API. This is necessary for messages which were sent before the bot's current session. - However, in an edge-case, it is also possible that the message was already deleted, - and the API will return a 404. In such a case, None will be returned. This signals - that the event for `message_id` should be ignored. + In an edge-case, it is also possible that the message was already deleted, and + the API will respond with a 404. In such a case, None will be returned. + This signals that the event for `message_id` should be ignored. """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.debug(f"Resolving message for: {message_id=}") -- cgit v1.2.3 From 5c70a7dad3ee59e865df08affe7905a843a823ce Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 22:05:15 +0200 Subject: Incidents tests: create new test module --- tests/bot/cogs/moderation/test_incidents.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/bot/cogs/moderation/test_incidents.py diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py new file mode 100644 index 000000000..e69de29bb -- cgit v1.2.3 From ae5028d5966ba126f902783db8ad685646f45f37 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 12 Jun 2020 23:14:41 +0200 Subject: Incidents tests: write tests for module-level helpers --- tests/bot/cogs/moderation/test_incidents.py | 135 ++++++++++++++++++++++++++++ 1 file changed, 135 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index e69de29bb..4c1f9bc07 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -0,0 +1,135 @@ +import enum +import unittest +from unittest.mock import AsyncMock, MagicMock, call, patch + +import discord + +from bot.cogs.moderation import incidents + + +@patch("bot.constants.Channels.incidents", 123) +class TestIsIncident(unittest.TestCase): + """ + Collection of tests for the `is_incident` helper function. + + In `setUp`, we will create a mock message which should qualify as an incident. Each + test case will then mutate this instance to make it **not** qualify, in various ways. + + Notice that we patch the #incidents channel id globally for this class. + """ + + def setUp(self) -> None: + """Prepare a mock message which should qualify as an incident.""" + self.incident = MagicMock( + discord.Message, + channel=MagicMock(discord.TextChannel, id=123), + content="this is an incident", + author=MagicMock(discord.User, bot=False), + pinned=False, + ) + + def test_is_incident_true(self): + """Message qualifies as an incident if unchanged.""" + self.assertTrue(incidents.is_incident(self.incident)) + + def check_false(self): + """Assert that `self.incident` does **not** qualify as an incident.""" + self.assertFalse(incidents.is_incident(self.incident)) + + def test_is_incident_false_channel(self): + """Message doesn't qualify if sent outside of #incidents.""" + self.incident.channel = MagicMock(discord.TextChannel, id=456) + self.check_false() + + def test_is_incident_false_content(self): + """Message doesn't qualify if content begins with hash symbol.""" + self.incident.content = "# this is a comment message" + self.check_false() + + def test_is_incident_false_author(self): + """Message doesn't qualify if author is a bot.""" + self.incident.author = MagicMock(discord.User, bot=True) + self.check_false() + + def test_is_incident_false_pinned(self): + """Message doesn't qualify if it is pinned.""" + self.incident.pinned = True + self.check_false() + + +class TestOwnReactions(unittest.TestCase): + """Assertions for the `own_reactions` function.""" + + def test_own_reactions(self): + """Only bot's own emoji are extracted from the input incident.""" + reactions = ( + MagicMock(discord.Reaction, emoji="A", me=True), + MagicMock(discord.Reaction, emoji="B", me=True), + MagicMock(discord.Reaction, emoji="C", me=False), + ) + message = MagicMock(discord.Message, reactions=reactions) + self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) + + +@patch("bot.cogs.moderation.incidents.ALLOWED_EMOJI", {"A", "B"}) +class TestHasSignals(unittest.TestCase): + """ + Assertions for the `has_signals` function. + + We patch `ALLOWED_EMOJI` globally. Each test function then patches `own_reactions` + as appropriate. + """ + + def test_has_signals_true(self): + """True when `own_reactions` returns all emoji in `ALLOWED_EMOJI`.""" + message = MagicMock(discord.Message) + own_reactions = MagicMock(return_value={"A", "B"}) + + with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): + self.assertTrue(incidents.has_signals(message)) + + def test_has_signals_false(self): + """False when `own_reactions` does not return all emoji in `ALLOWED_EMOJI`.""" + message = MagicMock(discord.Message) + own_reactions = MagicMock(return_value={"A", "C"}) + + with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): + self.assertFalse(incidents.has_signals(message)) + + +class Signal(enum.Enum): + A = "A" + B = "B" + + +@patch("bot.cogs.moderation.incidents.Signal", Signal) +class TestAddSignals(unittest.IsolatedAsyncioTestCase): + """ + Assertions for the `add_signals` coroutine. + + These are all fairly similar and could go into a single test function, but I found the + patching & sub-testing fairly awkward in that case and decided to split them up + to avoid unnecessary syntax noise. + """ + + def setUp(self): + """Prepare a mock incident message for tests to use.""" + self.incident = MagicMock(discord.Message, add_reaction=AsyncMock()) + + @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set())) + async def test_add_signals_missing(self): + """All emoji are added when none are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_has_calls([call("A"), call("B")]) + + @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A"})) + async def test_add_signals_partial(self): + """Only missing emoji are added when some are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_has_calls([call("B")]) + + @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value={"A", "B"})) + async def test_add_signals_present(self): + """No emoji are added when all are present.""" + await incidents.add_signals(self.incident) + self.incident.add_reaction.assert_not_called() -- cgit v1.2.3 From 46e770ba772e3c7048903efff41a5b969717e0d4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 12 Jun 2020 15:32:25 -0700 Subject: Escape markdown in charinfo embed The embed displays the original character. If it's a markdown char, it would interfere with the embed's actual markdown. The backtick was especially troublesome. Fixes #996 --- bot/cogs/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 73b4a1c0a..697bf60ce 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -6,7 +6,7 @@ from email.parser import HeaderParser from io import StringIO from typing import Tuple, Union -from discord import Colour, Embed +from discord import Colour, Embed, utils from discord.ext.commands import BadArgument, Cog, Context, command from bot.bot import Bot @@ -145,7 +145,7 @@ class Utils(Cog): u_code = f"\\U{digit:>08}" url = f"https://www.compart.com/en/unicode/U+{digit:>04}" name = f"[{unicodedata.name(char, '')}]({url})" - info = f"`{u_code.ljust(10)}`: {name} - {char}" + info = f"`{u_code.ljust(10)}`: {name} - {utils.escape_markdown(char)}" return info, u_code charlist, rawlist = zip(*(get_info(c) for c in characters)) -- cgit v1.2.3 From e9724dad79e7dab3bb801f50770bb06cf8461019 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 15:12:38 +0200 Subject: Incidents tests: use our own helper mocks No reason to build own MagicMocks as we already have helpers that more accurately mimic the mocked behaviour. --- tests/bot/cogs/moderation/test_incidents.py | 30 ++++++++++++++--------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 4c1f9bc07..d7cc84734 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,10 +1,9 @@ import enum import unittest -from unittest.mock import AsyncMock, MagicMock, call, patch - -import discord +from unittest.mock import MagicMock, call, patch from bot.cogs.moderation import incidents +from tests.helpers import MockMessage, MockReaction, MockTextChannel, MockUser @patch("bot.constants.Channels.incidents", 123) @@ -20,11 +19,10 @@ class TestIsIncident(unittest.TestCase): def setUp(self) -> None: """Prepare a mock message which should qualify as an incident.""" - self.incident = MagicMock( - discord.Message, - channel=MagicMock(discord.TextChannel, id=123), + self.incident = MockMessage( + channel=MockTextChannel(id=123), content="this is an incident", - author=MagicMock(discord.User, bot=False), + author=MockUser(bot=False), pinned=False, ) @@ -38,7 +36,7 @@ class TestIsIncident(unittest.TestCase): def test_is_incident_false_channel(self): """Message doesn't qualify if sent outside of #incidents.""" - self.incident.channel = MagicMock(discord.TextChannel, id=456) + self.incident.channel = MockTextChannel(id=456) self.check_false() def test_is_incident_false_content(self): @@ -48,7 +46,7 @@ class TestIsIncident(unittest.TestCase): def test_is_incident_false_author(self): """Message doesn't qualify if author is a bot.""" - self.incident.author = MagicMock(discord.User, bot=True) + self.incident.author = MockUser(bot=True) self.check_false() def test_is_incident_false_pinned(self): @@ -63,11 +61,11 @@ class TestOwnReactions(unittest.TestCase): def test_own_reactions(self): """Only bot's own emoji are extracted from the input incident.""" reactions = ( - MagicMock(discord.Reaction, emoji="A", me=True), - MagicMock(discord.Reaction, emoji="B", me=True), - MagicMock(discord.Reaction, emoji="C", me=False), + MockReaction(emoji="A", me=True), + MockReaction(emoji="B", me=True), + MockReaction(emoji="C", me=False), ) - message = MagicMock(discord.Message, reactions=reactions) + message = MockMessage(reactions=reactions) self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) @@ -82,7 +80,7 @@ class TestHasSignals(unittest.TestCase): def test_has_signals_true(self): """True when `own_reactions` returns all emoji in `ALLOWED_EMOJI`.""" - message = MagicMock(discord.Message) + message = MockMessage() own_reactions = MagicMock(return_value={"A", "B"}) with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): @@ -90,7 +88,7 @@ class TestHasSignals(unittest.TestCase): def test_has_signals_false(self): """False when `own_reactions` does not return all emoji in `ALLOWED_EMOJI`.""" - message = MagicMock(discord.Message) + message = MockMessage() own_reactions = MagicMock(return_value={"A", "C"}) with patch("bot.cogs.moderation.incidents.own_reactions", own_reactions): @@ -114,7 +112,7 @@ class TestAddSignals(unittest.IsolatedAsyncioTestCase): def setUp(self): """Prepare a mock incident message for tests to use.""" - self.incident = MagicMock(discord.Message, add_reaction=AsyncMock()) + self.incident = MockMessage() @patch("bot.cogs.moderation.incidents.own_reactions", MagicMock(return_value=set())) async def test_add_signals_missing(self): -- cgit v1.2.3 From 00a44226cb659319b9df5f568b0f67f9a0ed3360 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 15:51:34 +0200 Subject: Incidents tests: improve mock `Signal` name & move def Let's make it clear that this is our own mock. We also move the definition to the top of the module. --- tests/bot/cogs/moderation/test_incidents.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index d7cc84734..a349c1cb7 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -6,6 +6,11 @@ from bot.cogs.moderation import incidents from tests.helpers import MockMessage, MockReaction, MockTextChannel, MockUser +class MockSignal(enum.Enum): + A = "A" + B = "B" + + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ @@ -95,12 +100,7 @@ class TestHasSignals(unittest.TestCase): self.assertFalse(incidents.has_signals(message)) -class Signal(enum.Enum): - A = "A" - B = "B" - - -@patch("bot.cogs.moderation.incidents.Signal", Signal) +@patch("bot.cogs.moderation.incidents.Signal", MockSignal) class TestAddSignals(unittest.IsolatedAsyncioTestCase): """ Assertions for the `add_signals` coroutine. -- cgit v1.2.3 From c66b4a618503352803f73e9272a1d27b6e0a4d52 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 17:24:31 +0200 Subject: Incidents tests: set up base class for `Incidents` For cleanliness, I've decided to make a separate class for each method. Since most tests will want to have an `Incident` instance ready, they can inherit the `setUp` from `TestIncidents`, which does not make any assertions on its own. --- tests/bot/cogs/moderation/test_incidents.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index a349c1cb7..d52932e0a 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -2,8 +2,8 @@ import enum import unittest from unittest.mock import MagicMock, call, patch -from bot.cogs.moderation import incidents -from tests.helpers import MockMessage, MockReaction, MockTextChannel, MockUser +from bot.cogs.moderation import Incidents, incidents +from tests.helpers import MockBot, MockMessage, MockReaction, MockTextChannel, MockUser class MockSignal(enum.Enum): @@ -131,3 +131,22 @@ class TestAddSignals(unittest.IsolatedAsyncioTestCase): """No emoji are added when all are present.""" await incidents.add_signals(self.incident) self.incident.add_reaction.assert_not_called() + + +class TestIncidents(unittest.IsolatedAsyncioTestCase): + """ + Tests for bound methods of the `Incidents` cog. + + Use this as a base class for `Incidents` tests - it will prepare a fresh instance + for each test function, but not make any assertions on its own. Tests can mutate + the instance as they wish. + """ + + def setUp(self): + """ + Prepare a fresh `Incidents` instance for each test. + + Note that this will not schedule `crawl_incidents` in the background, as everything + is being mocked. The `crawl_task` attribute will end up being None. + """ + self.cog_instance = Incidents(MockBot()) -- cgit v1.2.3 From 3c2d227cd067466668e3089f63a6548736edf8ab Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 17:56:31 +0200 Subject: Incidents tests: write tests for `archive` --- tests/bot/cogs/moderation/test_incidents.py | 65 ++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index d52932e0a..7500235cf 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,9 +1,12 @@ import enum import unittest -from unittest.mock import MagicMock, call, patch +from unittest.mock import AsyncMock, MagicMock, call, patch + +import aiohttp +import discord from bot.cogs.moderation import Incidents, incidents -from tests.helpers import MockBot, MockMessage, MockReaction, MockTextChannel, MockUser +from tests.helpers import MockAsyncWebhook, MockBot, MockMessage, MockReaction, MockTextChannel, MockUser class MockSignal(enum.Enum): @@ -150,3 +153,61 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): is being mocked. The `crawl_task` attribute will end up being None. """ self.cog_instance = Incidents(MockBot()) + + +class TestArchive(TestIncidents): + """Tests for the `Incidents.archive` coroutine.""" + + async def test_archive_webhook_not_found(self): + """ + Method recovers and returns False when the webhook is not found. + + Implicitly, this also tests that the error is handled internally and doesn't + propagate out of the method, which is just as important. + """ + mock_404 = discord.NotFound( + response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response + message="Webhook not found", + ) + + self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) + self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) + + async def test_archive_relays_incident(self): + """ + If webhook is found, method relays `incident` properly. + + This test will assert the following: + * The fetched webhook's `send` method is fed the correct arguments + * The message returned by `send` will have `outcome` reaction added + * Finally, the `archive` method returns True + + Assertions are made specifically in this order. + """ + webhook_message = MockMessage() # The message that will be returned by the webhook's `send` method + webhook = MockAsyncWebhook(send=AsyncMock(return_value=webhook_message)) + + self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook + + # Now we'll pas our own `incident` to `archive` and capture the return value + incident = MockMessage( + clean_content="pingless message", + content="pingful message", + author=MockUser(name="author_name", avatar_url="author_avatar"), + id=123, + ) + archive_return = await self.cog_instance.archive(incident, outcome=MagicMock(value="A")) + + # Check that the webhook was dispatched correctly + webhook.send.assert_called_once_with( + content="pingless message", + username="author_name", + avatar_url="author_avatar", + wait=True, + ) + + # Now check that the correct emoji was added to the relayed message + webhook_message.add_reaction.assert_called_once_with("A") + + # Finally check that the method returned True + self.assertTrue(archive_return) -- cgit v1.2.3 From 39dc3cd229888acac2782237db4b9389c0788478 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 21:52:20 +0200 Subject: Incidents tests: move `mock_404` into module namespace This will be useful for others tests as well. --- tests/bot/cogs/moderation/test_incidents.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 7500235cf..e51bda114 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -14,6 +14,12 @@ class MockSignal(enum.Enum): B = "B" +mock_404 = discord.NotFound( + response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response + message="Not found", +) + + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ @@ -165,11 +171,6 @@ class TestArchive(TestIncidents): Implicitly, this also tests that the error is handled internally and doesn't propagate out of the method, which is just as important. """ - mock_404 = discord.NotFound( - response=MagicMock(aiohttp.ClientResponse), # Mock the erroneous response - message="Webhook not found", - ) - self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) -- cgit v1.2.3 From 8ed5cc7ef5e38885a8e439602b59e56449d3633c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 21:52:34 +0200 Subject: Incidents tests: write tests for `resolve_message` --- tests/bot/cogs/moderation/test_incidents.py | 56 +++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index e51bda114..b3beec3ab 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -212,3 +212,59 @@ class TestArchive(TestIncidents): # Finally check that the method returned True self.assertTrue(archive_return) + + +class TestResolveMessage(TestIncidents): + """Tests for the `Incidents.resolve_message` coroutine.""" + + async def test_resolve_message_pass_message_id(self): + """Method will call `_get_message` with the passed `message_id`.""" + await self.cog_instance.resolve_message(123) + self.cog_instance.bot._connection._get_message.assert_called_once_with(123) + + async def test_resolve_message_in_cache(self): + """ + No API call is made if the queried message exists in the cache. + + We mock the `_get_message` return value regardless of input. Whether it finds the message + internally is considered d.py's responsibility, not ours. + """ + cached_message = MockMessage(id=123) + self.cog_instance.bot._connection._get_message = MagicMock(return_value=cached_message) + + return_value = await self.cog_instance.resolve_message(123) + + self.assertIs(return_value, cached_message) + self.cog_instance.bot.get_channel.assert_not_called() # The `fetch_message` line was never hit + + async def test_resolve_message_not_in_cache(self): + """ + The message is retrieved from the API if it isn't cached. + + This is desired behaviour for messages which exist, but were sent before the bot's + current session. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + # API returns our message + uncached_message = MockMessage() + fetch_message = AsyncMock(return_value=uncached_message) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + retrieved_message = await self.cog_instance.resolve_message(123) + self.assertIs(retrieved_message, uncached_message) + + async def test_resolve_message_doesnt_exist(self): + """ + If the API returns a 404, the function handles it gracefully and returns None. + + This is an edge-case happening with racing events - event A will relay the message + to the archive and delete the original. Once event B acquires the `event_lock`, + it will not find the message in the cache, and will ask the API. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + fetch_message = AsyncMock(side_effect=mock_404) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + self.assertIsNone(await self.cog_instance.resolve_message(123)) -- cgit v1.2.3 From 7c43eff17a07471799174c0a0e8813b9f58d2ab5 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 22:09:44 +0200 Subject: Incidents: log error on non-404 response We do not wish to log 404 exceptions as those are expected, however, if something else goes wrong, we shouldn't silence it. This also removes the explicit None return as it only adds syntax noise. --- bot/cogs/moderation/incidents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 151584d38..16286bdab 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -274,9 +274,10 @@ class Incidents(Cog): log.debug("Message not found, attempting to fetch") try: message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) + except discord.NotFound: + log.debug("Message doesn't exist, it was likely already relayed") except Exception as exc: - log.debug(f"Failed to fetch message: {exc}") - return None + log.exception("Failed to fetch message!", exc_info=exc) else: log.debug("Message fetched successfully!") return message -- cgit v1.2.3 From bbedcb377c4c31973f43f076c3f62646f25733b3 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 22:15:38 +0200 Subject: Incidents tests: test non-404 error response --- tests/bot/cogs/moderation/test_incidents.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index b3beec3ab..cbeb3342c 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,4 +1,5 @@ import enum +import logging import unittest from unittest.mock import AsyncMock, MagicMock, call, patch @@ -268,3 +269,22 @@ class TestResolveMessage(TestIncidents): self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) self.assertIsNone(await self.cog_instance.resolve_message(123)) + + async def test_resolve_message_fetch_fails(self): + """ + Non-404 errors are handled, logged & None is returned. + + In contrast with a 404, this should make an error-level log. We assert that at least + one such log was made - we do not make any assertions about the log's message. + """ + self.cog_instance.bot._connection._get_message = MagicMock(return_value=None) # Cache returns None + + arbitrary_error = discord.HTTPException( + response=MagicMock(aiohttp.ClientResponse), + message="Arbitrary error", + ) + fetch_message = AsyncMock(side_effect=arbitrary_error) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(fetch_message=fetch_message)) + + with self.assertLogs(logger=incidents.log, level=logging.ERROR): + self.assertIsNone(await self.cog_instance.resolve_message(123)) -- cgit v1.2.3 From 14b7fee42ddf6a2cc75526506ef2028bdc742c9a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 22:42:58 +0200 Subject: Incidents tests: write tests for `on_message` --- tests/bot/cogs/moderation/test_incidents.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index cbeb3342c..0eb13df70 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -288,3 +288,30 @@ class TestResolveMessage(TestIncidents): with self.assertLogs(logger=incidents.log, level=logging.ERROR): self.assertIsNone(await self.cog_instance.resolve_message(123)) + + +class TestOnMessage(TestIncidents): + """ + Tests for the `Incidents.on_message` listener. + + Notice the decorators mocking the `is_incident` return value. The `is_incidents` + function is tested in `TestIsIncident` - here we do not worry about it. + """ + + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) + async def test_on_message_incident(self): + """Messages qualifying as incidents are passed to `add_signals`.""" + incident = MockMessage() + + with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: + await self.cog_instance.on_message(incident) + + mock_add_signals.assert_called_once_with(incident) + + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) + async def test_on_message_non_incident(self): + """Messages not qualifying as incidents are ignored.""" + with patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) as mock_add_signals: + await self.cog_instance.on_message(MockMessage()) + + mock_add_signals.assert_not_called() -- cgit v1.2.3 From 9d35846a67c2bf9ed9e935f8b5e3500ae4b49327 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 13 Jun 2020 23:24:14 +0200 Subject: Incidents tests: write tests for `make_confirmation_task` --- tests/bot/cogs/moderation/test_incidents.py | 35 +++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 0eb13df70..c093afc8a 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -215,6 +215,41 @@ class TestArchive(TestIncidents): self.assertTrue(archive_return) +class TestMakeConfirmationTask(TestIncidents): + """ + Tests for the `Incidents.make_confirmation_task` method. + + Writing tests for this method is difficult, as it mostly just delegates the provided + information elsewhere. There is very little internal logic. Whether our approach + works conceptually is difficult to prove using unit tests. + """ + + def test_make_confirmation_task_check(self): + """ + The internal check will recognize the passed incident. + + This is a little tricky - we first pass a message with a specific `id` in, and then + retrieve the built check from the `call_args` of the `wait_for` method. This relies + on the check being passed as a kwarg. + + Once the check is retrieved, we assert that it gives True for our incident's `id`, + and False for any other. + + If this function begins to fail, first check that `created_check` is being retrieved + correctly. It should be the function that is built locally in the tested method. + """ + self.cog_instance.make_confirmation_task(MockMessage(id=123)) + + self.cog_instance.bot.wait_for.assert_called_once() + created_check = self.cog_instance.bot.wait_for.call_args.kwargs["check"] + + # The `message_id` matches the `id` of our incident + self.assertTrue(created_check(payload=MagicMock(message_id=123))) + + # This `message_id` does not match + self.assertFalse(created_check(payload=MagicMock(message_id=0))) + + class TestResolveMessage(TestIncidents): """Tests for the `Incidents.resolve_message` coroutine.""" -- cgit v1.2.3 From d9ed643c41c8cf96ec208d6fc096882fc64c5d15 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 21:06:39 -0700 Subject: ModLog: fix AttributeError in on_member_update `iterable_item_removed` and `iterable_item_added` lack `new_value` and `old_value`. Instead, they just contain the actual value added or removed. The code was incorrectly trying to access old and new values for the iterable changes. The iterable changes are only useful for the role diff, but they aren't even needed for that. The role diff calculation has been refactored to always get the diff rather than doing it only if it sees there has been a change to the `_roles` attribute. To be clear, `_roles` only has IDs, which is why its diff isn't that useful anyway. To use it, the code would have to get the Role objects, which is basically what the `roles` property already does. `_cs_roles` seems to be some Role object cache, but its reliability is unclear. --- bot/cogs/moderation/modlog.py | 42 ++++++++++++++++++++---------------------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 41472c64c..02396e1c5 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -452,6 +452,21 @@ class ModLog(Cog, name="ModLog"): channel_id=Channels.mod_log ) + @staticmethod + def get_role_diff(before: t.List[discord.Role], after: t.List[discord.Role]) -> t.List[str]: + """Return a list of strings describing the roles added and removed.""" + changes = [] + before_roles = set(before) + after_roles = set(after) + + for role in (before_roles - after_roles): + changes.append(f"**Role removed:** {role.name} (`{role.id}`)") + + for role in (after_roles - before_roles): + changes.append(f"**Role added:** {role.name} (`{role.id}`)") + + return changes + @Cog.listener() async def on_member_update(self, before: discord.Member, after: discord.Member) -> None: """Log member update event to user log.""" @@ -463,22 +478,18 @@ class ModLog(Cog, name="ModLog"): return diff = DeepDiff(before, after) - changes = [] + changes = self.get_role_diff(before.roles, after.roles) done = [] diff_values = {} diff_values.update(diff.get("values_changed", {})) diff_values.update(diff.get("type_changes", {})) - diff_values.update(diff.get("iterable_item_removed", {})) - diff_values.update(diff.get("iterable_item_added", {})) diff_user = DeepDiff(before._user, after._user) diff_values.update(diff_user.get("values_changed", {})) diff_values.update(diff_user.get("type_changes", {})) - diff_values.update(diff_user.get("iterable_item_removed", {})) - diff_values.update(diff_user.get("iterable_item_added", {})) for key, value in diff_values.items(): if not key: # Not sure why, but it happens @@ -495,24 +506,11 @@ class ModLog(Cog, name="ModLog"): if key in done or key in MEMBER_CHANGES_SUPPRESSED: continue - if key == "_roles": - new_roles = after.roles - old_roles = before.roles + new = value.get("new_value") + old = value.get("old_value") - for role in old_roles: - if role not in new_roles: - changes.append(f"**Role removed:** {role.name} (`{role.id}`)") - - for role in new_roles: - if role not in old_roles: - changes.append(f"**Role added:** {role.name} (`{role.id}`)") - - else: - new = value.get("new_value") - old = value.get("old_value") - - if new and old: - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") + if new and old: + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) -- cgit v1.2.3 From 1d0cbbeb46a811b5a049d712aae1a90a1f3a7359 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Mon, 15 Jun 2020 00:36:53 -0400 Subject: Add the C# guild to the whitelist --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 3388e5f78..aff5fb2e1 100644 --- a/config-default.yml +++ b/config-default.yml @@ -299,6 +299,7 @@ filter: - 185590609631903755 # Blender Hub - 420324994703163402 # /r/FlutterDev - 488751051629920277 # Python Atlanta + - 143867839282020352 # C# domain_blacklist: - pornhub.com -- cgit v1.2.3 From 9133c4a7b79020d507b9cecbb9ce6d957b52fd9d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 21:57:53 -0700 Subject: ModLog: remove user diff in on_member_update The correct event for user changes is on_user_update, so this code does nothing in the on_member_update event. --- bot/cogs/moderation/modlog.py | 22 +--------------------- 1 file changed, 1 insertion(+), 21 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 02396e1c5..703da4ee7 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -24,7 +24,7 @@ GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.Vo CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status", "nick") +MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") VOICE_STATE_ATTRIBUTES = { @@ -486,11 +486,6 @@ class ModLog(Cog, name="ModLog"): diff_values.update(diff.get("values_changed", {})) diff_values.update(diff.get("type_changes", {})) - diff_user = DeepDiff(before._user, after._user) - - diff_values.update(diff_user.get("values_changed", {})) - diff_values.update(diff_user.get("type_changes", {})) - for key, value in diff_values.items(): if not key: # Not sure why, but it happens continue @@ -514,21 +509,6 @@ class ModLog(Cog, name="ModLog"): done.append(key) - if before.name != after.name: - changes.append( - f"**Username:** `{before.name}` **→** `{after.name}`" - ) - - if before.discriminator != after.discriminator: - changes.append( - f"**Discriminator:** `{before.discriminator}` **→** `{after.discriminator}`" - ) - - if before.display_name != after.display_name: - changes.append( - f"**Display name:** `{before.display_name}` **→** `{after.display_name}`" - ) - if not changes: return -- cgit v1.2.3 From 17858e4d65d5592d1da6178cb80415de615f21ab Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 22:05:18 -0700 Subject: ModLog: fix excluded None values in on_member_update This was preventing diffs for added nicknames from showing, among other things. --- bot/cogs/moderation/modlog.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 703da4ee7..163721e1c 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -504,8 +504,7 @@ class ModLog(Cog, name="ModLog"): new = value.get("new_value") old = value.get("old_value") - if new and old: - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") + changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") done.append(key) -- cgit v1.2.3 From 35fc846e671192199bde7e98e43b2ac21513f629 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 21:16:02 -0700 Subject: ModLog: refactor on_member_update * Exclude all sequences/mapping types rather than excluding by name * Replace MEMBER_CHANGES_SUPPRESSED with excludes as DeepDiff args * Don't keep track of "done" attributes - there shouldn't be dupes --- bot/cogs/moderation/modlog.py | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 163721e1c..bd805f590 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -24,7 +24,6 @@ GUILD_CHANNEL = t.Union[discord.CategoryChannel, discord.TextChannel, discord.Vo CHANNEL_CHANGES_UNSUPPORTED = ("permissions",) CHANNEL_CHANGES_SUPPRESSED = ("_overwrites", "position") -MEMBER_CHANGES_SUPPRESSED = ("status", "activities", "_client_status") ROLE_CHANGES_UNSUPPORTED = ("colour", "permissions") VOICE_STATE_ATTRIBUTES = { @@ -477,36 +476,27 @@ class ModLog(Cog, name="ModLog"): self._ignored[Event.member_update].remove(before.id) return - diff = DeepDiff(before, after) changes = self.get_role_diff(before.roles, after.roles) - done = [] - diff_values = {} + # The regex is a simple way to exclude all sequence and mapping types. + diff = DeepDiff(before, after, exclude_regex_paths=r".*\[.*") - diff_values.update(diff.get("values_changed", {})) - diff_values.update(diff.get("type_changes", {})) + # A type change seems to always take precedent over a value change. Furthermore, it will + # include the value change along with the type change anyway. Therefore, it's OK to + # "overwrite" values_changed; in practice there will never even be anything to overwrite. + diff_values = {**diff.get("values_changed", {}), **diff.get("type_changes", {})} - for key, value in diff_values.items(): - if not key: # Not sure why, but it happens + for attr, value in diff_values.items(): + if not attr: # Not sure why, but it happens. continue - key = key[5:] # Remove "root." prefix - - if "[" in key: - key = key.split("[", 1)[0] - - if "." in key: - key = key.split(".", 1)[0] - - if key in done or key in MEMBER_CHANGES_SUPPRESSED: - continue + attr = attr[len("root."):] # Remove "root." prefix. + attr = attr.replace("_", " ").replace(".", " ").capitalize() new = value.get("new_value") old = value.get("old_value") - changes.append(f"**{key.title()}:** `{old}` **→** `{new}`") - - done.append(key) + changes.append(f"**{attr}:** `{old}` **→** `{new}`") if not changes: return @@ -520,8 +510,10 @@ class ModLog(Cog, name="ModLog"): message = f"**{member_str}** (`{after.id}`)\n{message}" await self.send_log_message( - Icons.user_update, Colour.blurple(), - "Member updated", message, + icon_url=Icons.user_update, + colour=Colour.blurple(), + title="Member updated", + text=message, thumbnail=after.avatar_url_as(static_format="png"), channel_id=Channels.user_log ) -- cgit v1.2.3 From 09f53ca77ae79ceccad91da5e0d44d7013757f0e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 22:36:28 -0700 Subject: Check infraction reason isn't None before shortening it --- bot/cogs/moderation/infractions.py | 10 +++++++--- bot/cogs/moderation/scheduler.py | 3 +-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 5bfaad796..f685f6991 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -226,7 +226,10 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_remove, user.id) - action = user.kick(reason=textwrap.shorten(reason, width=512, placeholder="...")) + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + + action = user.kick(reason=reason) await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy() @@ -259,9 +262,10 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_remove, user.id) - truncated_reason = textwrap.shorten(reason, width=512, placeholder="...") + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") - action = ctx.guild.ban(user, reason=truncated_reason, delete_message_days=0) + action = ctx.guild.ban(user, reason=reason, delete_message_days=0) await self.apply_infraction(ctx, infraction, user, action) if infraction.get('expires_at') is not None: diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index b03d89537..beb201b8c 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -127,11 +127,10 @@ class InfractionScheduler(Scheduler): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" - if infraction["actor"] == self.bot.user.id: + if reason and infraction["actor"] == self.bot.user.id: log.trace( f"Infraction #{id_} actor is bot; including the reason in the confirmation message." ) - end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" elif ctx.channel.id not in STAFF_CHANNELS: log.trace( -- cgit v1.2.3 From 08c96f9eb07a2a86e68fb0e0837b9d07c40dab5e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 22:41:09 -0700 Subject: Fix check for bot actor in infractions The reason None check should be nested to avoid affecting the else/elif statements that follow. --- bot/cogs/moderation/scheduler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index beb201b8c..d75a72ddb 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -127,17 +127,17 @@ class InfractionScheduler(Scheduler): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" - if reason and infraction["actor"] == self.bot.user.id: + end_msg = "" + if infraction["actor"] == self.bot.user.id: log.trace( f"Infraction #{id_} actor is bot; including the reason in the confirmation message." ) - end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" + if reason: + end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" elif ctx.channel.id not in STAFF_CHANNELS: log.trace( f"Infraction #{id_} context is not in a staff channel; omitting infraction count." ) - - end_msg = "" else: log.trace(f"Fetching total infraction count for {user}.") -- cgit v1.2.3 From 89d8798e93d0b04f7964eca05f5d66c89c2a2f86 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 23:33:33 -0700 Subject: Sync: ignore events from other guilds --- bot/cogs/sync/cog.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 7cc3726b2..97ea31ba5 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -46,6 +46,9 @@ class Sync(Cog): @Cog.listener() async def on_guild_role_create(self, role: Role) -> None: """Adds newly create role to the database table over the API.""" + if role.guild != constants.Guild.id: + return + await self.bot.api_client.post( 'bot/roles', json={ @@ -60,11 +63,17 @@ class Sync(Cog): @Cog.listener() async def on_guild_role_delete(self, role: Role) -> None: """Deletes role from the database when it's deleted from the guild.""" + if role.guild != constants.Guild.id: + return + await self.bot.api_client.delete(f'bot/roles/{role.id}') @Cog.listener() async def on_guild_role_update(self, before: Role, after: Role) -> None: """Syncs role with the database if any of the stored attributes were updated.""" + if after.guild != constants.Guild.id: + return + was_updated = ( before.name != after.name or before.colour != after.colour @@ -93,6 +102,9 @@ class Sync(Cog): previously left), it will update the user's information. If the user is not yet known by the database, the user is added. """ + if member.guild != constants.Guild.id: + return + packed = { 'discriminator': int(member.discriminator), 'id': member.id, @@ -122,11 +134,17 @@ class Sync(Cog): @Cog.listener() async def on_member_remove(self, member: Member) -> None: """Set the in_guild field to False when a member leaves the guild.""" + if member.guild != constants.Guild.id: + return + await self.patch_user(member.id, updated_information={"in_guild": False}) @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: """Update the roles of the member in the database if a change is detected.""" + if after.guild != constants.Guild.id: + return + if before.roles != after.roles: updated_information = {"roles": sorted(role.id for role in after.roles)} await self.patch_user(after.id, updated_information=updated_information) -- cgit v1.2.3 From 81e50cb2c970fc5c203e135434f897b6a3f7e52a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 23:34:28 -0700 Subject: Sync tests: test listeners ignore events from other guilds --- tests/bot/cogs/sync/test_cog.py | 64 ++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index 14fd909c4..d7d60e961 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -131,6 +131,12 @@ class SyncCogListenerTests(SyncCogTestCase): super().setUp() self.cog.patch_user = mock.AsyncMock(spec_set=self.cog.patch_user) + self.guild_id_patcher = mock.patch("bot.cogs.sync.cog.constants.Guild.id", 5) + self.guild_id = self.guild_id_patcher.start() + + def tearDown(self): + self.guild_id_patcher.stop() + async def test_sync_cog_on_guild_role_create(self): """A POST request should be sent with the new role's data.""" self.assertTrue(self.cog.on_guild_role_create.__cog_listener__) @@ -142,20 +148,32 @@ class SyncCogListenerTests(SyncCogTestCase): "permissions": 8, "position": 23, } - role = helpers.MockRole(**role_data) + role = helpers.MockRole(**role_data, guild=self.guild_id) await self.cog.on_guild_role_create(role) self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) + async def test_sync_cog_on_guild_role_create_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=0) + await self.cog.on_guild_role_create(role) + self.bot.api_client.post.assert_not_awaited() + async def test_sync_cog_on_guild_role_delete(self): """A DELETE request should be sent.""" self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) - role = helpers.MockRole(id=99) + role = helpers.MockRole(id=99, guild=self.guild_id) await self.cog.on_guild_role_delete(role) self.bot.api_client.delete.assert_called_once_with("bot/roles/99") + async def test_sync_cog_on_guild_role_delete_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=0) + await self.cog.on_guild_role_delete(role) + self.bot.api_client.delete.assert_not_awaited() + async def test_sync_cog_on_guild_role_update(self): """A PUT request should be sent if the colour, name, permissions, or position changes.""" self.assertTrue(self.cog.on_guild_role_update.__cog_listener__) @@ -180,8 +198,8 @@ class SyncCogListenerTests(SyncCogTestCase): after_role_data = role_data.copy() after_role_data[attribute] = 876 - before_role = helpers.MockRole(**role_data) - after_role = helpers.MockRole(**after_role_data) + before_role = helpers.MockRole(**role_data, guild=self.guild_id) + after_role = helpers.MockRole(**after_role_data, guild=self.guild_id) await self.cog.on_guild_role_update(before_role, after_role) @@ -193,11 +211,17 @@ class SyncCogListenerTests(SyncCogTestCase): else: self.bot.api_client.put.assert_not_called() + async def test_sync_cog_on_guild_role_update_ignores_guilds(self): + """Events from other guilds should be ignored.""" + role = helpers.MockRole(guild=0) + await self.cog.on_guild_role_update(role, role) + self.bot.api_client.put.assert_not_awaited() + async def test_sync_cog_on_member_remove(self): - """Member should patched to set in_guild as False.""" + """Member should be patched to set in_guild as False.""" self.assertTrue(self.cog.on_member_remove.__cog_listener__) - member = helpers.MockMember() + member = helpers.MockMember(guild=self.guild_id) await self.cog.on_member_remove(member) self.cog.patch_user.assert_called_once_with( @@ -205,14 +229,20 @@ class SyncCogListenerTests(SyncCogTestCase): updated_information={"in_guild": False} ) + async def test_sync_cog_on_member_remove_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=0) + await self.cog.on_member_remove(member) + self.cog.patch_user.assert_not_awaited() + async def test_sync_cog_on_member_update_roles(self): """Members should be patched if their roles have changed.""" self.assertTrue(self.cog.on_member_update.__cog_listener__) # Roles are intentionally unsorted. before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] - before_member = helpers.MockMember(roles=before_roles) - after_member = helpers.MockMember(roles=before_roles[1:]) + before_member = helpers.MockMember(roles=before_roles, guild=self.guild_id) + after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild_id) await self.cog.on_member_update(before_member, after_member) @@ -233,13 +263,19 @@ class SyncCogListenerTests(SyncCogTestCase): with self.subTest(attribute=attribute): self.cog.patch_user.reset_mock() - before_member = helpers.MockMember(**{attribute: old_value}) - after_member = helpers.MockMember(**{attribute: new_value}) + before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild_id) + after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild_id) await self.cog.on_member_update(before_member, after_member) self.cog.patch_user.assert_not_called() + async def test_sync_cog_on_member_update_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=0) + await self.cog.on_member_update(member, member) + self.cog.patch_user.assert_not_awaited() + async def test_sync_cog_on_user_update(self): """A user should be patched only if the name, discriminator, or avatar changes.""" self.assertTrue(self.cog.on_user_update.__cog_listener__) @@ -290,6 +326,7 @@ class SyncCogListenerTests(SyncCogTestCase): member = helpers.MockMember( discriminator="1234", roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], + guild=self.guild_id, ) data = { @@ -334,6 +371,13 @@ class SyncCogListenerTests(SyncCogTestCase): self.bot.api_client.post.assert_not_called() + async def test_sync_cog_on_member_join_ignores_guilds(self): + """Events from other guilds should be ignored.""" + member = helpers.MockMember(guild=0) + await self.cog.on_member_join(member) + self.bot.api_client.post.assert_not_awaited() + self.bot.api_client.put.assert_not_awaited() + class SyncCogCommandTests(SyncCogTestCase, CommandTestCase): """Tests for the commands in the Sync cog.""" -- cgit v1.2.3 From 4d6acdf32a323de8b88fed464358d70faf35c9d1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 14 Jun 2020 23:47:40 -0700 Subject: Sync: ignore 404s in on_user_update 404s probably mean the user is from another guild. --- bot/cogs/sync/cog.py | 14 ++++++++------ tests/bot/cogs/sync/test_cog.py | 17 ++++++++++------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 97ea31ba5..578cccfc9 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -34,14 +34,15 @@ class Sync(Cog): for syncer in (self.role_syncer, self.user_syncer): await syncer.sync(guild) - async def patch_user(self, user_id: int, updated_information: Dict[str, Any]) -> None: + async def patch_user(self, user_id: int, json: Dict[str, Any], ignore_404: bool = False) -> None: """Send a PATCH request to partially update a user in the database.""" try: - await self.bot.api_client.patch(f"bot/users/{user_id}", json=updated_information) + await self.bot.api_client.patch(f"bot/users/{user_id}", json=json) except ResponseCodeError as e: if e.response.status != 404: raise - log.warning("Unable to update user, got 404. Assuming race condition from join event.") + if not ignore_404: + log.warning("Unable to update user, got 404. Assuming race condition from join event.") @Cog.listener() async def on_guild_role_create(self, role: Role) -> None: @@ -137,7 +138,7 @@ class Sync(Cog): if member.guild != constants.Guild.id: return - await self.patch_user(member.id, updated_information={"in_guild": False}) + await self.patch_user(member.id, json={"in_guild": False}) @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: @@ -147,7 +148,7 @@ class Sync(Cog): if before.roles != after.roles: updated_information = {"roles": sorted(role.id for role in after.roles)} - await self.patch_user(after.id, updated_information=updated_information) + await self.patch_user(after.id, json=updated_information) @Cog.listener() async def on_user_update(self, before: User, after: User) -> None: @@ -158,7 +159,8 @@ class Sync(Cog): "name": after.name, "discriminator": int(after.discriminator), } - await self.patch_user(after.id, updated_information=updated_information) + # A 404 likely means the user is in another guild. + await self.patch_user(after.id, json=updated_information, ignore_404=True) @commands.group(name='sync') @commands.has_permissions(administrator=True) diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index d7d60e961..e5be14391 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -226,7 +226,7 @@ class SyncCogListenerTests(SyncCogTestCase): self.cog.patch_user.assert_called_once_with( member.id, - updated_information={"in_guild": False} + json={"in_guild": False} ) async def test_sync_cog_on_member_remove_ignores_guilds(self): @@ -247,7 +247,7 @@ class SyncCogListenerTests(SyncCogTestCase): await self.cog.on_member_update(before_member, after_member) data = {"roles": sorted(role.id for role in after_member.roles)} - self.cog.patch_user.assert_called_once_with(after_member.id, updated_information=data) + self.cog.patch_user.assert_called_once_with(after_member.id, json=data) async def test_sync_cog_on_member_update_other(self): """Members should not be patched if other attributes have changed.""" @@ -308,12 +308,15 @@ class SyncCogListenerTests(SyncCogTestCase): # Don't care if *all* keys are present; only the changed one is required call_args = self.cog.patch_user.call_args - self.assertEqual(call_args[0][0], after_user.id) - self.assertIn("updated_information", call_args[1]) + self.assertEqual(call_args.args[0], after_user.id) + self.assertIn("json", call_args.kwargs) - updated_information = call_args[1]["updated_information"] - self.assertIn(api_field, updated_information) - self.assertEqual(updated_information[api_field], api_value) + self.assertIn("ignore_404", call_args.kwargs) + self.assertTrue(call_args.kwargs["ignore_404"]) + + json = call_args.kwargs["json"] + self.assertIn(api_field, json) + self.assertEqual(json[api_field], api_value) else: self.cog.patch_user.assert_not_called() -- cgit v1.2.3 From efd27cf29f726627b9ba630e257a2e3e89d3a286 Mon Sep 17 00:00:00 2001 From: Daniel Nash <22755628+crazygmr101@users.noreply.github.com> Date: Mon, 15 Jun 2020 13:18:36 -0400 Subject: Update bot/resources/tags/customcooldown.md Co-authored-by: Mark --- bot/resources/tags/customcooldown.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/resources/tags/customcooldown.md b/bot/resources/tags/customcooldown.md index e877e4dae..ac7e70aee 100644 --- a/bot/resources/tags/customcooldown.md +++ b/bot/resources/tags/customcooldown.md @@ -10,11 +10,9 @@ message_cooldown = commands.CooldownMapping.from_cooldown(1.0, 60.0, commands.Bu @bot.event async def on_message(message): bucket = message_cooldown.get_bucket(message) - # update_rate_limit returns a time you need to wait before - # trying again retry_after = bucket.update_rate_limit() if retry_after: - await message.channel.send("Slow down! You're sending messages too fast") + await message.channel.send(f"Slow down! Try again in {retry_after} seconds.") else: await message.channel.send("Not ratelimited!") ``` -- cgit v1.2.3 From c7373fa1143a2d2f2d784a59d40bcb40ee765bfb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 15 Jun 2020 10:26:23 -0700 Subject: Token remover: ignore DMs It's a private channel so there's no risk of a token "leaking". Furthermore, messages cannot be deleted in DMs. --- bot/cogs/token_remover.py | 3 +++ tests/bot/cogs/test_token_remover.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index d55e079e9..493479df9 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -63,6 +63,9 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ + if not msg.guild: + return # Ignore DMs; can't delete messages in there anyway. + found_token = self.find_token_in_message(msg) if found_token: await self.take_action(msg, found_token) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index a10124d2d..22c31d7b1 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -121,6 +121,16 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): find_token_in_message.assert_called_once_with(self.msg) take_action.assert_not_awaited() + @autospec(TokenRemover, "find_token_in_message") + async def test_on_message_ignores_dms(self, find_token_in_message): + """Shouldn't parse a message if it is a DM.""" + cog = TokenRemover(self.bot) + self.msg.guild = None + + await cog.on_message(self.msg) + + find_token_in_message.assert_not_called() + @autospec("bot.cogs.token_remover", "TOKEN_RE") def test_find_token_ignores_bot_messages(self, token_re): """The token finder should ignore messages authored by bots.""" -- cgit v1.2.3 From 2fa7429327e787a65803c16609da21463723bfeb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 15 Jun 2020 10:38:46 -0700 Subject: Token remover: move bot check to on_message It just makes more sense to me to filter out messages at an earlier stage. --- bot/cogs/token_remover.py | 8 +++----- tests/bot/cogs/test_token_remover.py | 23 +++++++---------------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 493479df9..1f7517501 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -63,8 +63,9 @@ class TokenRemover(Cog): See: https://discordapp.com/developers/docs/reference#snowflakes """ - if not msg.guild: - return # Ignore DMs; can't delete messages in there anyway. + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return found_token = self.find_token_in_message(msg) if found_token: @@ -115,9 +116,6 @@ class TokenRemover(Cog): @classmethod def find_token_in_message(cls, msg: Message) -> t.Optional[Token]: """Return a seemingly valid token found in `msg` or `None` if no token is found.""" - if msg.author.bot: - return - # Use finditer rather than search to guard against method calls prematurely returning the # token check (e.g. `message.channel.send` also matches our token pattern) for match in TOKEN_RE.finditer(msg.content): diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 22c31d7b1..98ea9f823 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -122,24 +122,15 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): take_action.assert_not_awaited() @autospec(TokenRemover, "find_token_in_message") - async def test_on_message_ignores_dms(self, find_token_in_message): - """Shouldn't parse a message if it is a DM.""" + async def test_on_message_ignores_dms_bots(self, find_token_in_message): + """Shouldn't parse a message if it is a DM or authored by a bot.""" cog = TokenRemover(self.bot) - self.msg.guild = None + dm_msg = MockMessage(guild=None) + bot_msg = MockMessage(author=MagicMock(bot=True)) - await cog.on_message(self.msg) - - find_token_in_message.assert_not_called() - - @autospec("bot.cogs.token_remover", "TOKEN_RE") - def test_find_token_ignores_bot_messages(self, token_re): - """The token finder should ignore messages authored by bots.""" - self.msg.author.bot = True - - return_value = TokenRemover.find_token_in_message(self.msg) - - self.assertIsNone(return_value) - token_re.finditer.assert_not_called() + for msg in (dm_msg, bot_msg): + await cog.on_message(msg) + find_token_in_message.assert_not_called() @autospec("bot.cogs.token_remover", "TOKEN_RE") def test_find_token_no_matches(self, token_re): -- cgit v1.2.3 From 3aecf14419c87e533d47fe082abeb54ca9edb73c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 15 Jun 2020 10:49:18 -0700 Subject: Token remover: exit early if message already deleted --- bot/cogs/token_remover.py | 10 ++++++++-- tests/bot/cogs/test_token_remover.py | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/bot/cogs/token_remover.py b/bot/cogs/token_remover.py index 1f7517501..ef979f222 100644 --- a/bot/cogs/token_remover.py +++ b/bot/cogs/token_remover.py @@ -4,7 +4,7 @@ import logging import re import typing as t -from discord import Colour, Message +from discord import Colour, Message, NotFound from discord.ext.commands import Cog from bot import utils @@ -83,7 +83,13 @@ class TokenRemover(Cog): async def take_action(self, msg: Message, found_token: Token) -> None: """Remove the `msg` containing the `found_token` and send a mod log message.""" self.mod_log.ignore(Event.message_delete, msg.id) - await msg.delete() + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove token in message {msg.id}: message already deleted.") + return + await msg.channel.send(DELETION_MESSAGE_TEMPLATE.format(mention=msg.author.mention)) log_message = self.format_log_message(msg, found_token) diff --git a/tests/bot/cogs/test_token_remover.py b/tests/bot/cogs/test_token_remover.py index 98ea9f823..3349caa73 100644 --- a/tests/bot/cogs/test_token_remover.py +++ b/tests/bot/cogs/test_token_remover.py @@ -3,7 +3,7 @@ from re import Match from unittest import mock from unittest.mock import MagicMock -from discord import Colour +from discord import Colour, NotFound from bot import constants from bot.cogs import token_remover @@ -282,6 +282,19 @@ class TokenRemoverTests(unittest.IsolatedAsyncioTestCase): channel_id=constants.Channels.mod_alerts ) + @mock.patch.object(TokenRemover, "mod_log", new_callable=mock.PropertyMock) + async def test_take_action_delete_failure(self, mod_log_property): + """Shouldn't send any messages if the token message can't be deleted.""" + cog = TokenRemover(self.bot) + mod_log_property.return_value = mock.create_autospec(ModLog, spec_set=True, instance=True) + self.msg.delete.side_effect = NotFound(MagicMock(), MagicMock()) + + token = mock.create_autospec(Token, spec_set=True, instance=True) + await cog.take_action(self.msg, token) + + self.msg.delete.assert_called_once_with() + self.msg.channel.send.assert_not_awaited() + class TokenRemoverExtensionTests(unittest.TestCase): """Tests for the token_remover extension.""" -- cgit v1.2.3 From 0ad19a48680fe6bc729d0e893d32a517a21df7dc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 15 Jun 2020 10:51:47 -0700 Subject: Webhook remover: ignore DMs and bot messages Can't remove messages in DMs, so don't bother trying. --- bot/cogs/webhook_remover.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 1b5c3f821..74a353e98 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -59,6 +59,10 @@ class WebhookRemover(Cog): @Cog.listener() async def on_message(self, msg: Message) -> None: """Check if a Discord webhook URL is in `message`.""" + # Ignore DMs; can't delete messages in there anyway. + if not msg.guild or msg.author.bot: + return + matches = WEBHOOK_URL_RE.search(msg.content) if matches: await self.delete_and_respond(msg, matches[1] + "xxx") -- cgit v1.2.3 From 94a4f8e52f52e98ea50fb0233fedcbbe9ebe6266 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 15 Jun 2020 10:52:01 -0700 Subject: Webhook remover: exit early if message already deleted --- bot/cogs/webhook_remover.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/cogs/webhook_remover.py b/bot/cogs/webhook_remover.py index 74a353e98..543869215 100644 --- a/bot/cogs/webhook_remover.py +++ b/bot/cogs/webhook_remover.py @@ -1,7 +1,7 @@ import logging import re -from discord import Colour, Message +from discord import Colour, Message, NotFound from discord.ext.commands import Cog from bot.bot import Bot @@ -35,7 +35,13 @@ class WebhookRemover(Cog): """Delete `msg` and send a warning that it contained the Discord webhook `redacted_url`.""" # Don't log this, due internal delete, not by user. Will make different entry. self.mod_log.ignore(Event.message_delete, msg.id) - await msg.delete() + + try: + await msg.delete() + except NotFound: + log.debug(f"Failed to remove webhook in message {msg.id}: message already deleted.") + return + await msg.channel.send(ALERT_MESSAGE_TEMPLATE.format(user=msg.author.mention)) message = ( -- cgit v1.2.3 From ae44563fe132436d98f50e074e5eb4421eda5538 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 15 Jun 2020 17:41:34 -0700 Subject: Log exception info for failed attachment uploads --- bot/utils/messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index de8e186f3..23519a514 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -97,7 +97,7 @@ async def send_attachments( if link_large and e.status == 413: large.append(attachment) else: - log.warning(f"{failure_msg} with status {e.status}.") + log.warning(f"{failure_msg} with status {e.status}.", exc_info=e) if link_large and large: desc = "\n".join(f"[{attachment.filename}]({attachment.url})" for attachment in large) -- cgit v1.2.3 From bcf6993de7de726683e6ca9b0f102b6ad1a732fa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 15 Jun 2020 18:10:52 -0700 Subject: Fix 400 when "clyde" is in webhook username Discord just disallows this name. --- bot/cogs/duck_pond.py | 4 ++-- bot/cogs/python_news.py | 3 ++- bot/cogs/reddit.py | 8 +++++--- bot/cogs/watchchannels/watchchannel.py | 1 + bot/utils/messages.py | 16 ++++++++++++++-- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 37d1786a2..5b6a7fd62 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -7,7 +7,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot -from bot.utils.messages import send_attachments +from bot.utils.messages import send_attachments, sub_clyde log = logging.getLogger(__name__) @@ -58,7 +58,7 @@ class DuckPond(Cog): try: await self.webhook.send( content=content, - username=username, + username=sub_clyde(username), avatar_url=avatar_url, embed=embed ) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index d15d0371e..adefd5c7c 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -10,6 +10,7 @@ from discord.ext.tasks import loop from bot import constants from bot.bot import Bot +from bot.utils.messages import sub_clyde PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" @@ -208,7 +209,7 @@ class PythonNews(Cog): return await self.webhook.send( embed=embed, - username=webhook_profile_name, + username=sub_clyde(webhook_profile_name), avatar_url=AVATAR_URL, wait=True ) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 3b77538a0..d853ab2ea 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -16,6 +16,7 @@ from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfi from bot.converters import Subreddit from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.messages import sub_clyde log = logging.getLogger(__name__) @@ -218,7 +219,8 @@ class Reddit(Cog): for subreddit in RedditConfig.subreddits: top_posts = await self.get_top_posts(subreddit=subreddit, time="day") - message = await self.webhook.send(username=f"{subreddit} Top Daily Posts", embed=top_posts, wait=True) + username = sub_clyde(f"{subreddit} Top Daily Posts") + message = await self.webhook.send(username=username, embed=top_posts, wait=True) if message.channel.is_news(): await message.publish() @@ -228,8 +230,8 @@ class Reddit(Cog): for subreddit in RedditConfig.subreddits: # Send and pin the new weekly posts. top_posts = await self.get_top_posts(subreddit=subreddit, time="week") - - message = await self.webhook.send(wait=True, username=f"{subreddit} Top Weekly Posts", embed=top_posts) + username = sub_clyde(f"{subreddit} Top Weekly Posts") + message = await self.webhook.send(wait=True, username=username, embed=top_posts) if subreddit.lower() == "r/python": if not self.channel: diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 436778c46..7c58a0fb5 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -204,6 +204,7 @@ class WatchChannel(metaclass=CogABCMeta): embed: Optional[Embed] = None, ) -> None: """Sends a message to the webhook with the specified kwargs.""" + username = messages.sub_clyde(username) try: await self.webhook.send(content=content, username=username, avatar_url=avatar_url, embed=embed) except discord.HTTPException as exc: diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 23519a514..6ad9351cc 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,6 +1,7 @@ import asyncio import contextlib import logging +import re from io import BytesIO from typing import List, Optional, Sequence, Union @@ -86,7 +87,7 @@ async def send_attachments( else: await destination.send( file=attachment_file, - username=message.author.display_name, + username=sub_clyde(message.author.display_name), avatar_url=message.author.avatar_url ) elif link_large: @@ -109,8 +110,19 @@ async def send_attachments( else: await destination.send( embed=embed, - username=message.author.display_name, + username=sub_clyde(message.author.display_name), avatar_url=message.author.avatar_url ) return urls + + +def sub_clyde(username: Optional[str]) -> Optional[str]: + """ + Replace "e" in any "clyde" in `username` with a similar Unicode char and return the new string. + + Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400. + Return None only if `username` is None. + """ + if username: + return re.sub(r"(clyd)e", r"\1𝖾", username, flags=re.I) -- cgit v1.2.3 From 2020df342d3aa43acdf7ead026d593f779264002 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 16 Jun 2020 20:03:15 +0800 Subject: Refactor nested if-statement --- bot/cogs/help_channels.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 86579e940..4c464a7d2 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -562,11 +562,10 @@ class HelpChannels(Scheduler, commands.Cog): self.bot.stats.timing("help.in_use_time", in_use_time) unanswered = await self.unanswered.get(channel.id) - if unanswered is not None: - if unanswered: - self.bot.stats.incr("help.sessions.unanswered") - else: - self.bot.stats.incr("help.sessions.answered") + if unanswered: + self.bot.stats.incr("help.sessions.unanswered") + elif unanswered is not None: + self.bot.stats.incr("help.sessions.answered") log.trace(f"Position of #{channel} ({channel.id}) is actually {channel.position}.") log.trace(f"Sending dormant message for #{channel} ({channel.id}).") -- cgit v1.2.3 From 433f6b6843006aff57cf1e125d340e703c85669f Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 16 Jun 2020 20:10:23 +0800 Subject: Revise inaccurate docstring in RedisCache --- bot/utils/redis_cache.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index 347a0e54a..f342bbb62 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -48,8 +48,8 @@ class RedisCache: behaves, and should be familiar to Python users. The biggest difference is that all the public methods in this class are coroutines, and must be awaited. - Because of limitations in Redis, this cache will only accept strings, integers and - floats both for keys and values. + Because of limitations in Redis, this cache will only accept strings and integers for keys, + and strings, integers, floats and booleans for values. Please note that this class MUST be created as a class attribute, and that that class must also contain an attribute with an instance of our Bot. See `__get__` and `__set_name__` -- cgit v1.2.3 From 2426ea2141dd75aba21562d925244c1a43af94fc Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 16 Jun 2020 21:55:14 +0800 Subject: Revise inaccurate typehint for Optional reason --- bot/cogs/moderation/infractions.py | 49 ++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index f685f6991..3db788eb9 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -53,7 +53,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent infractions @command() - async def warn(self, ctx: Context, user: Member, *, reason: str = None) -> None: + async def warn(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: """Warn a user for the given reason.""" infraction = await utils.post_infraction(ctx, user, "warning", reason, active=False) if infraction is None: @@ -62,12 +62,12 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command() - async def kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: + async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason.""" await self.apply_kick(ctx, user, reason, active=False) @command() - async def ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: + async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: """Permanently ban a user for the given reason and stop watching them with Big Brother.""" await self.apply_ban(ctx, user, reason) @@ -75,7 +75,9 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary infractions @command(aliases=["mute"]) - async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None: + async def tempmute( + self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None + ) -> None: """ Temporarily mute a user for the given reason and duration. @@ -94,7 +96,9 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_mute(ctx, user, reason, expires_at=duration) @command() - async def tempban(self, ctx: Context, user: FetchedMember, duration: Expiry, *, reason: str = None) -> None: + async def tempban( + self, ctx: Context, user: FetchedMember, duration: Expiry, *, reason: t.Optional[str] = None + ) -> None: """ Temporarily ban a user for the given reason and duration. @@ -116,7 +120,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Permanent shadow infractions @command(hidden=True) - async def note(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: + async def note(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: """Create a private note for a user with the given reason without notifying the user.""" infraction = await utils.post_infraction(ctx, user, "note", reason, hidden=True, active=False) if infraction is None: @@ -125,12 +129,14 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user) @command(hidden=True, aliases=['shadowkick', 'skick']) - async def shadow_kick(self, ctx: Context, user: Member, *, reason: str = None) -> None: + async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason without notifying the user.""" await self.apply_kick(ctx, user, reason, hidden=True, active=False) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: str = None) -> None: + async def shadow_ban( + self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None + ) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -138,7 +144,14 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary shadow infractions @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) - async def shadow_tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: str = None) -> None: + async def shadow_tempmute( + self, + ctx: Context, + user: Member, + duration: Expiry, + *, + reason: t.Optional[str] = None + ) -> None: """ Temporarily mute a user for the given reason and duration without notifying the user. @@ -158,12 +171,12 @@ class Infractions(InfractionScheduler, commands.Cog): @command(hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: str = None + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None ) -> None: """ Temporarily ban a user for the given reason and duration without notifying the user. @@ -198,7 +211,7 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Base apply functions - async def apply_mute(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: + async def apply_mute(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a mute infraction with kwargs passed to `post_infraction`.""" if await utils.get_active_infraction(ctx, user, "mute"): return @@ -218,7 +231,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action()) @respect_role_hierarchy() - async def apply_kick(self, ctx: Context, user: Member, reason: str, **kwargs) -> None: + async def apply_kick(self, ctx: Context, user: Member, reason: t.Optional[str], **kwargs) -> None: """Apply a kick infraction with kwargs passed to `post_infraction`.""" infraction = await utils.post_infraction(ctx, user, "kick", reason, active=False, **kwargs) if infraction is None: @@ -233,7 +246,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_infraction(ctx, infraction, user, action) @respect_role_hierarchy() - async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: str, **kwargs) -> None: + async def apply_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """ Apply a ban infraction with kwargs passed to `post_infraction`. -- cgit v1.2.3 From 6fe0bc1c9ce35459b7d9b5bd2309b41dcc4c0dcc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 16 Jun 2020 12:40:12 -0700 Subject: Add optional type annotations to reason in pardon funcs --- bot/cogs/moderation/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3db788eb9..f7747e7f8 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -298,7 +298,7 @@ class Infractions(InfractionScheduler, commands.Cog): # endregion # region: Base pardon functions - async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]: + async def pardon_mute(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: """Remove a user's muted role, DM them a notification, and return a log dict.""" user = guild.get_member(user_id) log_text = {} @@ -324,7 +324,7 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text - async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: str) -> t.Dict[str, str]: + async def pardon_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: """Remove a user's ban on the Discord guild and return a log dict.""" user = discord.Object(user_id) log_text = {} -- cgit v1.2.3 From 20a8b6fe92c398fdc246d78591600bb7bde78bca Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 16 Jun 2020 12:48:13 -0700 Subject: Format parameters with a more consistent style --- bot/cogs/moderation/infractions.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index f7747e7f8..3b28526b2 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -75,9 +75,7 @@ class Infractions(InfractionScheduler, commands.Cog): # region: Temporary infractions @command(aliases=["mute"]) - async def tempmute( - self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None - ) -> None: + async def tempmute(self, ctx: Context, user: Member, duration: Expiry, *, reason: t.Optional[str] = None) -> None: """ Temporarily mute a user for the given reason and duration. @@ -97,7 +95,12 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def tempban( - self, ctx: Context, user: FetchedMember, duration: Expiry, *, reason: t.Optional[str] = None + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None ) -> None: """ Temporarily ban a user for the given reason and duration. @@ -134,9 +137,7 @@ class Infractions(InfractionScheduler, commands.Cog): await self.apply_kick(ctx, user, reason, hidden=True, active=False) @command(hidden=True, aliases=['shadowban', 'sban']) - async def shadow_ban( - self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None - ) -> None: + async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: """Permanently ban a user for the given reason without notifying the user.""" await self.apply_ban(ctx, user, reason, hidden=True) @@ -145,12 +146,11 @@ class Infractions(InfractionScheduler, commands.Cog): @command(hidden=True, aliases=["shadowtempmute, stempmute", "shadowmute", "smute"]) async def shadow_tempmute( - self, - ctx: Context, - user: Member, - duration: Expiry, - *, - reason: t.Optional[str] = None + self, ctx: Context, + user: Member, + duration: Expiry, + *, + reason: t.Optional[str] = None ) -> None: """ Temporarily mute a user for the given reason and duration without notifying the user. @@ -171,12 +171,12 @@ class Infractions(InfractionScheduler, commands.Cog): @command(hidden=True, aliases=["shadowtempban, stempban"]) async def shadow_tempban( - self, - ctx: Context, - user: FetchedMember, - duration: Expiry, - *, - reason: t.Optional[str] = None + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] = None ) -> None: """ Temporarily ban a user for the given reason and duration without notifying the user. -- cgit v1.2.3 From b2972e0f816c60395517412011e312a3040491a0 Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 16 Jun 2020 13:12:58 -0700 Subject: Use int literal instead of len for slice Co-authored-by: Kieran Siek --- bot/cogs/moderation/modlog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index bd805f590..ffbb87bbe 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -490,7 +490,7 @@ class ModLog(Cog, name="ModLog"): if not attr: # Not sure why, but it happens. continue - attr = attr[len("root."):] # Remove "root." prefix. + attr = attr[5:] # Remove "root." prefix. attr = attr.replace("_", " ").replace(".", " ").capitalize() new = value.get("new_value") -- cgit v1.2.3 From 778635241bf6c1a97f60f48a2bc9b40791a524e9 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Wed, 17 Jun 2020 19:15:31 +0100 Subject: Add LMGTFY to domain blacklist --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index aff5fb2e1..f111c64f5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -331,6 +331,7 @@ filter: - ssteam.site - steamwalletgift.com - discord.gift + - lmgtfy.com word_watchlist: - goo+ks* -- cgit v1.2.3 From 47b6f65e231305c2ceb4f48a2a772a734ae190db Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Wed, 17 Jun 2020 21:20:27 +0100 Subject: Update deletion scheduler to use latest watchlist configuration --- bot/cogs/filtering.py | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index f7cf4c3ea..76ea68660 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -7,7 +7,7 @@ from typing import List, Mapping, Optional, Union import dateutil import discord.errors from dateutil.relativedelta import relativedelta -from discord import Colour, DMChannel, HTTPException, Member, Message, NotFound, TextChannel +from discord import Colour, HTTPException, Member, Message, NotFound, TextChannel from discord.ext.commands import Cog from discord.utils import escape_markdown @@ -56,6 +56,7 @@ def expand_spoilers(text: str) -> str: split_text[0::2] + split_text[1::2] + split_text ) + OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) @@ -113,6 +114,7 @@ class Filtering(Cog, Scheduler): "function": self._has_watch_regex_match, "type": "watchlist", "content_only": True, + "schedule_deletion": True }, "watch_rich_embeds": { "enabled": Filter.watch_rich_embeds, @@ -120,21 +122,7 @@ class Filtering(Cog, Scheduler): "type": "watchlist", "content_only": False, "schedule_deletion": False - }, - "watch_words": { - "enabled": Filter.watch_words, - "function": self._has_watchlist_words, - "type": "watchlist", - "content_only": True, - "schedule_deletion": True - }, - "watch_tokens": { - "enabled": Filter.watch_tokens, - "function": self._has_watchlist_tokens, - "type": "watchlist", - "content_only": True, - "schedule_deletion": True - }, + } } self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) @@ -481,7 +469,7 @@ class Filtering(Cog, Scheduler): await self.bot.wait_until_ready() response = await self.bot.api_client.get('bot/offensive-messages',) - now = datetime.datetime.utcnow() + now = datetime.utcnow() for msg in response: delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) -- cgit v1.2.3 From ebc0eae42c1da67f61f040a67bc1b70e53a6f97e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 17 Jun 2020 16:46:43 -0700 Subject: Sync: fix guild ID check Need to compare the IDs against each other rather than the Guild object against the ID. --- bot/cogs/sync/cog.py | 12 ++++++------ tests/bot/cogs/sync/test_cog.py | 35 +++++++++++++++++++---------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/bot/cogs/sync/cog.py b/bot/cogs/sync/cog.py index 578cccfc9..5ace957e7 100644 --- a/bot/cogs/sync/cog.py +++ b/bot/cogs/sync/cog.py @@ -47,7 +47,7 @@ class Sync(Cog): @Cog.listener() async def on_guild_role_create(self, role: Role) -> None: """Adds newly create role to the database table over the API.""" - if role.guild != constants.Guild.id: + if role.guild.id != constants.Guild.id: return await self.bot.api_client.post( @@ -64,7 +64,7 @@ class Sync(Cog): @Cog.listener() async def on_guild_role_delete(self, role: Role) -> None: """Deletes role from the database when it's deleted from the guild.""" - if role.guild != constants.Guild.id: + if role.guild.id != constants.Guild.id: return await self.bot.api_client.delete(f'bot/roles/{role.id}') @@ -72,7 +72,7 @@ class Sync(Cog): @Cog.listener() async def on_guild_role_update(self, before: Role, after: Role) -> None: """Syncs role with the database if any of the stored attributes were updated.""" - if after.guild != constants.Guild.id: + if after.guild.id != constants.Guild.id: return was_updated = ( @@ -103,7 +103,7 @@ class Sync(Cog): previously left), it will update the user's information. If the user is not yet known by the database, the user is added. """ - if member.guild != constants.Guild.id: + if member.guild.id != constants.Guild.id: return packed = { @@ -135,7 +135,7 @@ class Sync(Cog): @Cog.listener() async def on_member_remove(self, member: Member) -> None: """Set the in_guild field to False when a member leaves the guild.""" - if member.guild != constants.Guild.id: + if member.guild.id != constants.Guild.id: return await self.patch_user(member.id, json={"in_guild": False}) @@ -143,7 +143,7 @@ class Sync(Cog): @Cog.listener() async def on_member_update(self, before: Member, after: Member) -> None: """Update the roles of the member in the database if a change is detected.""" - if after.guild != constants.Guild.id: + if after.guild.id != constants.Guild.id: return if before.roles != after.roles: diff --git a/tests/bot/cogs/sync/test_cog.py b/tests/bot/cogs/sync/test_cog.py index e5be14391..120bc991d 100644 --- a/tests/bot/cogs/sync/test_cog.py +++ b/tests/bot/cogs/sync/test_cog.py @@ -134,6 +134,9 @@ class SyncCogListenerTests(SyncCogTestCase): self.guild_id_patcher = mock.patch("bot.cogs.sync.cog.constants.Guild.id", 5) self.guild_id = self.guild_id_patcher.start() + self.guild = helpers.MockGuild(id=self.guild_id) + self.other_guild = helpers.MockGuild(id=0) + def tearDown(self): self.guild_id_patcher.stop() @@ -148,14 +151,14 @@ class SyncCogListenerTests(SyncCogTestCase): "permissions": 8, "position": 23, } - role = helpers.MockRole(**role_data, guild=self.guild_id) + role = helpers.MockRole(**role_data, guild=self.guild) await self.cog.on_guild_role_create(role) self.bot.api_client.post.assert_called_once_with("bot/roles", json=role_data) async def test_sync_cog_on_guild_role_create_ignores_guilds(self): """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=0) + role = helpers.MockRole(guild=self.other_guild) await self.cog.on_guild_role_create(role) self.bot.api_client.post.assert_not_awaited() @@ -163,14 +166,14 @@ class SyncCogListenerTests(SyncCogTestCase): """A DELETE request should be sent.""" self.assertTrue(self.cog.on_guild_role_delete.__cog_listener__) - role = helpers.MockRole(id=99, guild=self.guild_id) + role = helpers.MockRole(id=99, guild=self.guild) await self.cog.on_guild_role_delete(role) self.bot.api_client.delete.assert_called_once_with("bot/roles/99") async def test_sync_cog_on_guild_role_delete_ignores_guilds(self): """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=0) + role = helpers.MockRole(guild=self.other_guild) await self.cog.on_guild_role_delete(role) self.bot.api_client.delete.assert_not_awaited() @@ -198,8 +201,8 @@ class SyncCogListenerTests(SyncCogTestCase): after_role_data = role_data.copy() after_role_data[attribute] = 876 - before_role = helpers.MockRole(**role_data, guild=self.guild_id) - after_role = helpers.MockRole(**after_role_data, guild=self.guild_id) + before_role = helpers.MockRole(**role_data, guild=self.guild) + after_role = helpers.MockRole(**after_role_data, guild=self.guild) await self.cog.on_guild_role_update(before_role, after_role) @@ -213,7 +216,7 @@ class SyncCogListenerTests(SyncCogTestCase): async def test_sync_cog_on_guild_role_update_ignores_guilds(self): """Events from other guilds should be ignored.""" - role = helpers.MockRole(guild=0) + role = helpers.MockRole(guild=self.other_guild) await self.cog.on_guild_role_update(role, role) self.bot.api_client.put.assert_not_awaited() @@ -221,7 +224,7 @@ class SyncCogListenerTests(SyncCogTestCase): """Member should be patched to set in_guild as False.""" self.assertTrue(self.cog.on_member_remove.__cog_listener__) - member = helpers.MockMember(guild=self.guild_id) + member = helpers.MockMember(guild=self.guild) await self.cog.on_member_remove(member) self.cog.patch_user.assert_called_once_with( @@ -231,7 +234,7 @@ class SyncCogListenerTests(SyncCogTestCase): async def test_sync_cog_on_member_remove_ignores_guilds(self): """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=0) + member = helpers.MockMember(guild=self.other_guild) await self.cog.on_member_remove(member) self.cog.patch_user.assert_not_awaited() @@ -241,8 +244,8 @@ class SyncCogListenerTests(SyncCogTestCase): # Roles are intentionally unsorted. before_roles = [helpers.MockRole(id=12), helpers.MockRole(id=30), helpers.MockRole(id=20)] - before_member = helpers.MockMember(roles=before_roles, guild=self.guild_id) - after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild_id) + before_member = helpers.MockMember(roles=before_roles, guild=self.guild) + after_member = helpers.MockMember(roles=before_roles[1:], guild=self.guild) await self.cog.on_member_update(before_member, after_member) @@ -263,8 +266,8 @@ class SyncCogListenerTests(SyncCogTestCase): with self.subTest(attribute=attribute): self.cog.patch_user.reset_mock() - before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild_id) - after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild_id) + before_member = helpers.MockMember(**{attribute: old_value}, guild=self.guild) + after_member = helpers.MockMember(**{attribute: new_value}, guild=self.guild) await self.cog.on_member_update(before_member, after_member) @@ -272,7 +275,7 @@ class SyncCogListenerTests(SyncCogTestCase): async def test_sync_cog_on_member_update_ignores_guilds(self): """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=0) + member = helpers.MockMember(guild=self.other_guild) await self.cog.on_member_update(member, member) self.cog.patch_user.assert_not_awaited() @@ -329,7 +332,7 @@ class SyncCogListenerTests(SyncCogTestCase): member = helpers.MockMember( discriminator="1234", roles=[helpers.MockRole(id=22), helpers.MockRole(id=12)], - guild=self.guild_id, + guild=self.guild, ) data = { @@ -376,7 +379,7 @@ class SyncCogListenerTests(SyncCogTestCase): async def test_sync_cog_on_member_join_ignores_guilds(self): """Events from other guilds should be ignored.""" - member = helpers.MockMember(guild=0) + member = helpers.MockMember(guild=self.other_guild) await self.cog.on_member_join(member) self.bot.api_client.post.assert_not_awaited() self.bot.api_client.put.assert_not_awaited() -- cgit v1.2.3 From 311326b21fe887063f0d4f757b9624f41ed28418 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 17 Jun 2020 17:04:48 -0700 Subject: Make sub_clyde case-sensitive and use Cyrillic e's The Cyrillic characters are more likely to be rendered similarly to their Latin counterparts than the math sans-serif characters. --- bot/utils/messages.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 6ad9351cc..c7d756708 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -119,10 +119,14 @@ async def send_attachments( def sub_clyde(username: Optional[str]) -> Optional[str]: """ - Replace "e" in any "clyde" in `username` with a similar Unicode char and return the new string. + Replace "e"/"E" in any "clyde" in `username` with a Cyrillic "е"/"E" and return the new string. Discord disallows "clyde" anywhere in the username for webhooks. It will return a 400. Return None only if `username` is None. """ + def replace_e(match: re.Match) -> str: + char = "е" if match[2] == "e" else "Е" + return match[1] + char + if username: - return re.sub(r"(clyd)e", r"\1𝖾", username, flags=re.I) + return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) -- cgit v1.2.3 From 51d681654d9a9acc71763edffcea0d5eb1ef1b29 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 18 Jun 2020 08:13:15 +0300 Subject: Source: Simplify missing tag cog handling --- bot/cogs/source.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 32a78a0c0..d59371c6e 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -29,10 +29,7 @@ class SourceConverter(commands.Converter): tags_cog = ctx.bot.get_cog("Tags") - if not tags_cog: - await ctx.send("Unable to get `Tags` cog.") - return commands.ExtensionNotLoaded("bot.cogs.tags") - elif argument.lower() in tags_cog._cache: + if tags_cog and argument.lower() in tags_cog._cache: return argument.lower() raise commands.BadArgument(f"Unable to convert `{argument}` to valid command, tag, or Cog.") @@ -47,10 +44,6 @@ class BotSource(commands.Cog): @commands.command(name="source", aliases=("src",)) async def source_command(self, ctx: commands.Context, *, source_item: SourceConverter = None) -> None: """Display information and a GitHub link to the source code of a command, tag, or cog.""" - # When we have problem to get Tags cog, exit early - if isinstance(source_item, commands.ExtensionNotLoaded): - return - if not source_item: embed = Embed(title="Bot's GitHub Repository") embed.add_field(name="Repository", value=f"[Go to GitHub]({URLs.github_bot_repo})") -- cgit v1.2.3 From 31f53259fd73503399b904e5a7075ceeded4c742 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 18 Jun 2020 08:58:18 +0300 Subject: Source: Split handling tag and other source items file location --- bot/cogs/source.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index d59371c6e..2ca852af3 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -82,7 +82,12 @@ class BotSource(commands.Cog): first_line_no = None lines_extension = "" - file_location = Path(filename).relative_to("/bot/") + # Handle tag file location differently than others to avoid errors in some cases + if not first_line_no: + file_location = Path(filename).relative_to("/bot/") + else: + file_location = Path(filename).relative_to(Path.cwd()).as_posix() + url = f"{URLs.github_bot_repo}/blob/master/{file_location}{lines_extension}" return url, file_location, first_line_no or None -- cgit v1.2.3 From caa421054669f886750a54fb2fdbea3315c58a58 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 18 Jun 2020 09:07:47 +0300 Subject: Source: Exclude `tag` from error message when tags cog not loaded --- bot/cogs/source.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 2ca852af3..223552651 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -28,11 +28,14 @@ class SourceConverter(commands.Converter): return cmd tags_cog = ctx.bot.get_cog("Tags") + show_tag = True - if tags_cog and argument.lower() in tags_cog._cache: + if not tags_cog: + show_tag = False + elif argument.lower() in tags_cog._cache: return argument.lower() - raise commands.BadArgument(f"Unable to convert `{argument}` to valid command, tag, or Cog.") + raise commands.BadArgument(f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog.") class BotSource(commands.Cog): -- cgit v1.2.3 From 4c9a62f93fd7b92051dd40e4d799236d65e154ab Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Thu, 18 Jun 2020 09:10:55 +0300 Subject: Source: Split to multiple lines to fix too long line on error raising --- bot/cogs/source.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 223552651..f1db745cd 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -35,7 +35,9 @@ class SourceConverter(commands.Converter): elif argument.lower() in tags_cog._cache: return argument.lower() - raise commands.BadArgument(f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog.") + raise commands.BadArgument( + f"Unable to convert `{argument}` to valid command{', tag,' if show_tag else ''} or Cog." + ) class BotSource(commands.Cog): -- cgit v1.2.3 From 40e00ff17465fc5a5fe6b46487bfea37655cd7b9 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 18 Jun 2020 19:33:59 +0200 Subject: Incidents tests: write tests for `process_event` This also breaks the helpers import statement into a vertical list, as the amount of imports has grown too much. I still believe that this is a preferred alternative to accessing the helpers via module namespace, as we use them a lot, and the added visual noise would be annoying to read - their names are already descriptive enough. --- tests/bot/cogs/moderation/test_incidents.py | 102 +++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index c093afc8a..6158d5d20 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,3 +1,4 @@ +import asyncio import enum import logging import unittest @@ -7,7 +8,16 @@ import aiohttp import discord from bot.cogs.moderation import Incidents, incidents -from tests.helpers import MockAsyncWebhook, MockBot, MockMessage, MockReaction, MockTextChannel, MockUser +from tests.helpers import ( + MockAsyncWebhook, + MockBot, + MockMember, + MockMessage, + MockReaction, + MockRole, + MockTextChannel, + MockUser, +) class MockSignal(enum.Enum): @@ -250,6 +260,96 @@ class TestMakeConfirmationTask(TestIncidents): self.assertFalse(created_check(payload=MagicMock(message_id=0))) +@patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) +@patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", AsyncMock()) # Generic awaitable +class TestProcessEvent(TestIncidents): + """Tests for the `Incidents.process_event` coroutine.""" + + @patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) + async def test_process_event_bad_role(self): + """The reaction is removed when the author lacks all allowed roles.""" + incident = MockMessage() + member = MockMember(roles=[MockRole(id=0)]) # Must have role 1 or 2 + + await self.cog_instance.process_event("reaction", incident, member) + incident.remove_reaction.assert_called_once_with("reaction", member) + + async def test_process_event_bad_emoji(self): + """ + The reaction is removed when an invalid emoji is used. + + This requires that we pass in a `member` with valid roles, as we need the role check + to succeed. + """ + incident = MockMessage() + member = MockMember(roles=[MockRole(id=1)]) # Member has allowed role + + await self.cog_instance.process_event("invalid_signal", incident, member) + incident.remove_reaction.assert_called_once_with("invalid_signal", member) + + async def test_process_event_no_archive_on_investigating(self): + """Message is not archived on `Signal.INVESTIGATING`.""" + with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock()) as mocked_archive: + await self.cog_instance.process_event( + reaction=incidents.Signal.INVESTIGATING.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]), + ) + + mocked_archive.assert_not_called() + + async def test_process_event_no_delete_if_archive_fails(self): + """ + Original message is not deleted when `Incidents.archive` returns False. + + This is the way of signaling that the relay failed, and we should not remove the original, + as that would result in losing the incident record. + """ + incident = MockMessage() + + with patch("bot.cogs.moderation.incidents.Incidents.archive", AsyncMock(return_value=False)): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=incident, + member=MockMember(roles=[MockRole(id=1)]) + ) + + incident.delete.assert_not_called() + + async def test_process_event_confirmation_task_is_awaited(self): + """Task given by `Incidents.make_confirmation_task` is awaited before method exits.""" + mock_task = AsyncMock() + + with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]) + ) + + mock_task.assert_awaited() + + async def test_process_event_confirmation_task_timeout_is_handled(self): + """ + Confirmation task `asyncio.TimeoutError` is handled gracefully. + + We have `make_confirmation_task` return a mock with a side effect, and then catch the + exception should it propagate out of `process_event`. This is so that we can then manually + fail the test with a more informative message than just the plain traceback. + """ + mock_task = AsyncMock(side_effect=asyncio.TimeoutError()) + + try: + with patch("bot.cogs.moderation.incidents.Incidents.make_confirmation_task", mock_task): + await self.cog_instance.process_event( + reaction=incidents.Signal.ACTIONED.value, + incident=MockMessage(), + member=MockMember(roles=[MockRole(id=1)]) + ) + except asyncio.TimeoutError: + self.fail("TimeoutError was not handled gracefully, and propagated out of `process_event`!") + + class TestResolveMessage(TestIncidents): """Tests for the `Incidents.resolve_message` coroutine.""" -- cgit v1.2.3 From ed4097629601704f0c65fc40cceb5fd6757d4779 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 14:32:31 +0200 Subject: Incidents tests: add helper for mocking async for-loops See the docstring. This does not make the ambition to be powerful enough to be included in `tests.helpers`, and is only intended for local purposes. --- tests/bot/cogs/moderation/test_incidents.py | 37 +++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 6158d5d20..7fa8847ef 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -1,6 +1,7 @@ import asyncio import enum import logging +import typing as t import unittest from unittest.mock import AsyncMock, MagicMock, call, patch @@ -20,6 +21,42 @@ from tests.helpers import ( ) +class MockAsyncIterable: + """ + Helper for mocking asynchronous for loops. + + It does not appear that the `unittest` library currently provides anything that would + allow us to simply mock an async iterator, such as `discord.TextChannel.history`. + + We therefore write our own helper to wrap a regular synchronous iterable, and feed + its values via `__anext__` rather than `__next__`. + + This class was written for the purposes of testing the `Incidents` cog - it may not + be generic enough to be placed in the `tests.helpers` module. + """ + + def __init__(self, messages: t.Iterable): + """Take a sync iterable to be wrapped.""" + self.iter_messages = iter(messages) + + def __aiter__(self): + """Return `self` as we provide the `__anext__` method.""" + return self + + async def __anext__(self): + """ + Feed the next item, or raise `StopAsyncIteration`. + + Since we're wrapping a sync iterator, it will communicate that it has been depleted + by raising a `StopIteration`. The `async for` construct does not expect it, and we + therefore need to substitute it for the appropriate exception type. + """ + try: + return next(self.iter_messages) + except StopIteration: + raise StopAsyncIteration + + class MockSignal(enum.Enum): A = "A" B = "B" -- cgit v1.2.3 From d93ed5d801c08b7fb084427906e7ac484ac3563f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 14:37:44 +0200 Subject: Incidents tests: write tests for `crawl_incidents` --- tests/bot/cogs/moderation/test_incidents.py | 58 +++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 7fa8847ef..4e6dfd5f7 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -209,6 +209,64 @@ class TestIncidents(unittest.IsolatedAsyncioTestCase): self.cog_instance = Incidents(MockBot()) +@patch("asyncio.sleep", AsyncMock()) # Prevent the coro from sleeping to speed up the test +class TestCrawlIncidents(TestIncidents): + """ + Tests for the `Incidents.crawl_incidents` coroutine. + + Apart from `test_crawl_incidents_waits_until_cache_ready`, all tests in this class + will patch the return values of `is_incident` and `has_signal` and then observe + whether the `AsyncMock` for `add_signals` was awaited or not. + + The `add_signals` mock is added by each test separately to ensure it is clean (has not + been awaited by another test yet). The mock can be reset, but this appears to be the + cleaner way. + + For each test, we inject a mock channel with a history of 1 message only (see: `setUp`). + """ + + def setUp(self): + """For each test, ensure `bot.get_channel` returns a channel with 1 arbitrary message.""" + super().setUp() # First ensure we get `cog_instance` from parent + + incidents_history = MagicMock(return_value=MockAsyncIterable([MockMessage()])) + self.cog_instance.bot.get_channel = MagicMock(return_value=MockTextChannel(history=incidents_history)) + + async def test_crawl_incidents_waits_until_cache_ready(self): + """ + The coroutine will await the `wait_until_guild_available` event. + + Since this task is schedule in the `__init__`, it is critical that it waits for the + cache to be ready, so that it can safely get the #incidents channel. + """ + await self.cog_instance.crawl_incidents() + self.cog_instance.bot.wait_until_guild_available.assert_awaited() + + @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)) # Message doesn't qualify + @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) + async def test_crawl_incidents_noop_if_is_not_incident(self): + """Signals are not added for a non-incident message.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_not_awaited() + + @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies + @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=True)) # But already has signals + async def test_crawl_incidents_noop_if_message_already_has_signals(self): + """Signals are not added for messages which already have them.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_not_awaited() + + @patch("bot.cogs.moderation.incidents.add_signals", AsyncMock()) + @patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)) # Message qualifies + @patch("bot.cogs.moderation.incidents.has_signals", MagicMock(return_value=False)) # And doesn't have signals + async def test_crawl_incidents_add_signals_called(self): + """Message has signals added as it does not have them yet and qualifies as an incident.""" + await self.cog_instance.crawl_incidents() + incidents.add_signals.assert_awaited_once() + + class TestArchive(TestIncidents): """Tests for the `Incidents.archive` coroutine.""" -- cgit v1.2.3 From 9a58b45cad51c961ad34fa9de9aaa060446c54fd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 16:57:15 +0200 Subject: Incidents tests: write tests for `on_raw_reaction_add` --- tests/bot/cogs/moderation/test_incidents.py | 128 ++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 4e6dfd5f7..55b15ec9e 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -520,6 +520,134 @@ class TestResolveMessage(TestIncidents): self.assertIsNone(await self.cog_instance.resolve_message(123)) +@patch("bot.constants.Channels.incidents", 123) +class TestOnRawReactionAdd(TestIncidents): + """ + Tests for the `Incidents.on_raw_reaction_add` listener. + + Writing tests for this listener comes with additional complexity due to the listener + awaiting the `crawl_task` task. See `asyncSetUp` for further details, which attempts + to make unit testing this function possible. + """ + + def setUp(self): + """ + Prepare & assign `payload` attribute. + + This attribute represents an *ideal* payload which will not be rejected by the + listener. As each test will receive a fresh instance, it can be mutated to + observe how the listener's behaviour changes with different attributes on + the passed payload. + """ + super().setUp() # Ensure `cog_instance` is assigned + + self.payload = MagicMock( + discord.RawReactionActionEvent, + channel_id=123, # Patched at class level + message_id=456, + member=MockMember(bot=False), + emoji="reaction", + ) + + async def asyncSetUp(self): # noqa: N802 + """ + Prepare an empty task and assign it as `crawl_task`. + + It appears that the `unittest` framework does not provide anything for mocking + asyncio tasks. An `AsyncMock` instance can be called and then awaited, however, + it does not provide the `done` method or any other parts of the `asyncio.Task` + interface. + + Although we do not need to make any assertions about the task itself while + testing the listener, the code will still await it and call the `done` method, + and so we must inject something that will not fail on either action. + + Note that this is done in an `asyncSetUp`, which runs after `setUp`. + The justification is that creating an actual task requires the event + loop to be ready, which is not the case in the `setUp`. + """ + mock_task = asyncio.create_task(AsyncMock()()) # Mock async func, then a coro + self.cog_instance.crawl_task = mock_task + + async def test_on_raw_reaction_add_wrong_channel(self): + """ + Events outside of #incidents will be ignored. + + We check this by asserting that `resolve_message` was never queried. + """ + self.payload.channel_id = 0 + self.cog_instance.resolve_message = AsyncMock() + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.resolve_message.assert_not_called() + + async def test_on_raw_reaction_add_user_is_bot(self): + """ + Events dispatched by bot accounts will be ignored. + + We check this by asserting that `resolve_message` was never queried. + """ + self.payload.member = MockMember(bot=True) + self.cog_instance.resolve_message = AsyncMock() + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.resolve_message.assert_not_called() + + async def test_on_raw_reaction_add_message_doesnt_exist(self): + """ + Listener gracefully handles the case where `resolve_message` gives None. + + We check this by asserting that `process_event` was never called. + """ + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=None) + + await self.cog_instance.on_raw_reaction_add(self.payload) + self.cog_instance.process_event.assert_not_called() + + async def test_on_raw_reaction_add_message_is_not_an_incident(self): + """ + The event won't be processed if the related message is not an incident. + + This is an edge-case that can happen if someone manually leaves a reaction + on a pinned message, or a comment. + + We check this by asserting that `process_event` was never called. + """ + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=MockMessage()) + + with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=False)): + await self.cog_instance.on_raw_reaction_add(self.payload) + + self.cog_instance.process_event.assert_not_called() + + async def test_on_raw_reaction_add_valid_event_is_processed(self): + """ + If the reaction event is valid, it is passed to `process_event`. + + This is the case when everything goes right: + * The reaction was placed in #incidents, and not by a bot + * The message was found successfully + * The message qualifies as an incident + + Additionally, we check that all arguments were passed as expected. + """ + incident = MockMessage(id=1) + + self.cog_instance.process_event = AsyncMock() + self.cog_instance.resolve_message = AsyncMock(return_value=incident) + + with patch("bot.cogs.moderation.incidents.is_incident", MagicMock(return_value=True)): + await self.cog_instance.on_raw_reaction_add(self.payload) + + self.cog_instance.process_event.assert_called_with( + "reaction", # Defined in `self.payload` + incident, + self.payload.member, + ) + + class TestOnMessage(TestIncidents): """ Tests for the `Incidents.on_message` listener. -- cgit v1.2.3 From e760bd38b5d625011318a9ddfc98bb52570d1c3a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 17:08:50 +0200 Subject: Incidents: review log levels; use `trace` where appropriate Logs useful when observing the internals but too verbose for DEBUG are reduced to TRACE. --- bot/cogs/moderation/incidents.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 16286bdab..da04c7d0d 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -64,10 +64,10 @@ async def add_signals(incident: discord.Message) -> None: # This will not raise, but it is a superfluous API call that can be avoided if signal_emoji.value in existing_reacts: - log.debug(f"Skipping emoji as it's already been placed: {signal_emoji}") + log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") else: - log.debug(f"Adding reaction: {signal_emoji}") + log.trace(f"Adding reaction: {signal_emoji}") await incident.add_reaction(signal_emoji.value) @@ -132,11 +132,11 @@ class Incidents(Cog): async for message in incidents.history(limit=limit): if not is_incident(message): - log.debug("Skipping message: not an incident") + log.trace("Skipping message: not an incident") continue if has_signals(message): - log.debug("Skipping message: already has all signals") + log.trace("Skipping message: already has all signals") continue await add_signals(message) @@ -179,7 +179,7 @@ class Incidents(Cog): return False else: - log.debug("Message archived successfully!") + log.trace("Message archived successfully!") return True def make_confirmation_task(self, incident: discord.Message, timeout: int = 5) -> asyncio.Task: @@ -189,7 +189,7 @@ class Incidents(Cog): If `timeout` passes, this will raise `asyncio.TimeoutError`, signaling that we haven't been able to confirm that the message was deleted. """ - log.debug(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") + log.trace(f"Confirmation task will wait {timeout=} seconds for {incident.id=} to be deleted") def check(payload: discord.RawReactionActionEvent) -> bool: return payload.message_id == incident.id @@ -225,7 +225,7 @@ class Incidents(Cog): # If we reach this point, we know that `emoji` is a `Signal` member signal = Signal(reaction) - log.debug(f"Received signal: {signal}") + log.trace(f"Received signal: {signal}") if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): log.debug("Reaction was valid, but no action is currently defined for it") @@ -233,22 +233,22 @@ class Incidents(Cog): relay_successful = await self.archive(incident, signal) if not relay_successful: - log.debug("Original message will not be deleted as we failed to relay it to the archive") + log.trace("Original message will not be deleted as we failed to relay it to the archive") return timeout = 5 # Seconds confirmation_task = self.make_confirmation_task(incident, timeout) - log.debug("Deleting original message") + log.trace("Deleting original message") await incident.delete() - log.debug(f"Awaiting deletion confirmation: {timeout=} seconds") + log.trace(f"Awaiting deletion confirmation: {timeout=} seconds") try: await confirmation_task except asyncio.TimeoutError: log.warning(f"Did not receive incident deletion confirmation within {timeout} seconds!") else: - log.debug("Deletion was confirmed") + log.trace("Deletion was confirmed") async def resolve_message(self, message_id: int) -> t.Optional[discord.Message]: """ @@ -264,22 +264,22 @@ class Incidents(Cog): This signals that the event for `message_id` should be ignored. """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready - log.debug(f"Resolving message for: {message_id=}") + log.trace(f"Resolving message for: {message_id=}") message: discord.Message = self.bot._connection._get_message(message_id) # noqa: Private attribute if message is not None: - log.debug("Message was found in cache") + log.trace("Message was found in cache") return message - log.debug("Message not found, attempting to fetch") + log.trace("Message not found, attempting to fetch") try: message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) except discord.NotFound: - log.debug("Message doesn't exist, it was likely already relayed") + log.trace("Message doesn't exist, it was likely already relayed") except Exception as exc: log.exception("Failed to fetch message!", exc_info=exc) else: - log.debug("Message fetched successfully!") + log.trace("Message fetched successfully!") return message @Cog.listener() @@ -309,10 +309,10 @@ class Incidents(Cog): if payload.channel_id != Channels.incidents or payload.member.bot: return - log.debug(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") + log.trace(f"Received reaction add event in #incidents, waiting for crawler: {self.crawl_task.done()=}") await self.crawl_task - log.debug(f"Acquiring event lock: {self.event_lock.locked()=}") + log.trace(f"Acquiring event lock: {self.event_lock.locked()=}") async with self.event_lock: message = await self.resolve_message(payload.message_id) @@ -325,7 +325,7 @@ class Incidents(Cog): return await self.process_event(str(payload.emoji), message, payload.member) - log.debug("Releasing event lock") + log.trace("Releasing event lock") @Cog.listener() async def on_message(self, message: discord.Message) -> None: -- cgit v1.2.3 From 8a0263f5a591be51e74c2b26369f74c6d8dfee09 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 17:55:20 +0200 Subject: Incidents: remove broad noqa This was originally in place to silence a PyCharm warning regarding accessing the private attributes. However, since there is no corresponding error code to specify, the noqa will silence any linter warning, which is potentially dangerous, and seems to be bad practice. --- bot/cogs/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index da04c7d0d..c733607e6 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -265,7 +265,7 @@ class Incidents(Cog): """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.trace(f"Resolving message for: {message_id=}") - message: discord.Message = self.bot._connection._get_message(message_id) # noqa: Private attribute + message: discord.Message = self.bot._connection._get_message(message_id) if message is not None: log.trace("Message was found in cache") -- cgit v1.2.3 From c8cbd1e744c5c48490be321b19ef4f062443ed6d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 18:28:29 +0200 Subject: Pipenv: add script for html coverage report Similarly to the `report` script, this removes the need to invoke coverage when generating the html report. --- Pipfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Pipfile b/Pipfile index b42ca6d58..33be99587 100644 --- a/Pipfile +++ b/Pipfile @@ -50,4 +50,5 @@ precommit = "pre-commit install" build = "docker build -t pythondiscord/bot:latest -f Dockerfile ." push = "docker push pythondiscord/bot:latest" test = "coverage run -m unittest" +html = "coverage html" report = "coverage report" -- cgit v1.2.3 From 8a08ca3a29f6d6bda2ab71bc9fd70782be9869e4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 20:26:06 +0200 Subject: Incidents: annotate possible None type Caught during review by ks129. Co-authored-by: ks129 <45097959+ks129@users.noreply.github.com> --- bot/cogs/moderation/incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index c733607e6..c09d8e1a7 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -265,7 +265,7 @@ class Incidents(Cog): """ await self.bot.wait_until_guild_available() # First make sure that the cache is ready log.trace(f"Resolving message for: {message_id=}") - message: discord.Message = self.bot._connection._get_message(message_id) + message: t.Optional[discord.Message] = self.bot._connection._get_message(message_id) if message is not None: log.trace("Message was found in cache") -- cgit v1.2.3 From b563063c1a25a0a775ea1fb6cf31b7ef9725e14e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 19 Jun 2020 20:33:17 +0200 Subject: Incidents: reduce excessive whitespace This is way too spacious for how little is happening here. Suggested by ks129. Co-authored-by: ks129 <45097959+ks129@users.noreply.github.com> --- bot/cogs/moderation/incidents.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index c09d8e1a7..70921462d 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -61,11 +61,8 @@ async def add_signals(incident: discord.Message) -> None: existing_reacts = own_reactions(incident) for signal_emoji in Signal: - - # This will not raise, but it is a superfluous API call that can be avoided - if signal_emoji.value in existing_reacts: + if signal_emoji.value in existing_reacts: # This would not raise, but it is a superfluous API call log.trace(f"Skipping emoji as it's already been placed: {signal_emoji}") - else: log.trace(f"Adding reaction: {signal_emoji}") await incident.add_reaction(signal_emoji.value) -- cgit v1.2.3 From 55db81a8c089c96a2e5e96110e3c80f0d36ebb58 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 15:16:09 -0700 Subject: Preserve empty string when substituting clyde --- bot/utils/messages.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index c7d756708..a40a12e98 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -130,3 +130,5 @@ def sub_clyde(username: Optional[str]) -> Optional[str]: if username: return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) + else: + return username # Empty string or None -- cgit v1.2.3 From 581573f2ece96a9ec666795431ff21068e949a63 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 01:20:35 +0200 Subject: Write unit test for `sub_clyde` --- tests/bot/utils/test_messages.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 tests/bot/utils/test_messages.py diff --git a/tests/bot/utils/test_messages.py b/tests/bot/utils/test_messages.py new file mode 100644 index 000000000..9c22c9751 --- /dev/null +++ b/tests/bot/utils/test_messages.py @@ -0,0 +1,27 @@ +import unittest + +from bot.utils import messages + + +class TestMessages(unittest.TestCase): + """Tests for functions in the `bot.utils.messages` module.""" + + def test_sub_clyde(self): + """Uppercase E's and lowercase e's are substituted with their cyrillic counterparts.""" + sub_e = "\u0435" + sub_E = "\u0415" # noqa: N806: Uppercase E in variable name + + test_cases = ( + (None, None), + ("", ""), + ("clyde", f"clyd{sub_e}"), + ("CLYDE", f"CLYD{sub_E}"), + ("cLyDe", f"cLyD{sub_e}"), + ("BIGclyde", f"BIGclyd{sub_e}"), + ("small clydeus the unholy", f"small clyd{sub_e}us the unholy"), + ("BIGCLYDE, babyclyde", f"BIGCLYD{sub_E}, babyclyd{sub_e}"), + ) + + for username_in, username_out in test_cases: + with self.subTest(input=username_in, expected_output=username_out): + self.assertEqual(messages.sub_clyde(username_in), username_out) -- cgit v1.2.3 From 5e02d5ded0b9d1947e0e9d5455b134d9e2299a7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 21:48:18 -0700 Subject: Scheduler: use separate logger for each instance Each instance now requires a name to be specified, which will be used as the suffix of the logger's name. This removes the need to manually prepend every log message with the name. --- bot/utils/scheduling.py | 41 +++++++++++++++++------------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 8b778a093..002ef42cf 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -7,16 +7,14 @@ from functools import partial from bot.utils import CogABCMeta -log = logging.getLogger(__name__) - class Scheduler(metaclass=CogABCMeta): """Task scheduler.""" - def __init__(self): - # Keep track of the child cog's name so the logs are clear. - self.cog_name = self.__class__.__name__ + def __init__(self, name: str): + self.name = name + self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} @abstractmethod @@ -37,19 +35,17 @@ class Scheduler(metaclass=CogABCMeta): `task_data` is passed to the `Scheduler._scheduled_task()` coroutine. """ - log.trace(f"{self.cog_name}: scheduling task #{task_id}...") + self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: - log.debug( - f"{self.cog_name}: did not schedule task #{task_id}; task was already scheduled." - ) + self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return task = asyncio.create_task(self._scheduled_task(task_data)) task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task - log.debug(f"{self.cog_name}: scheduled task #{task_id} {id(task)}.") + self._log.debug(f"Scheduled task #{task_id} {id(task)}.") def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None: """ @@ -57,22 +53,22 @@ class Scheduler(metaclass=CogABCMeta): If `ignore_missing` is True, a warning will not be sent if a task isn't found. """ - log.trace(f"{self.cog_name}: cancelling task #{task_id}...") + self._log.trace(f"Cancelling task #{task_id}...") task = self._scheduled_tasks.get(task_id) if not task: if not ignore_missing: - log.warning(f"{self.cog_name}: failed to unschedule {task_id} (no task found).") + self._log.warning(f"Failed to unschedule {task_id} (no task found).") return del self._scheduled_tasks[task_id] task.cancel() - log.debug(f"{self.cog_name}: unscheduled task #{task_id} {id(task)}.") + self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") def cancel_all(self) -> None: """Unschedule all known tasks.""" - log.debug(f"{self.cog_name}: unscheduling all tasks") + self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): self.cancel_task(task_id, ignore_missing=True) @@ -84,24 +80,24 @@ class Scheduler(metaclass=CogABCMeta): If `done_task` and the task associated with `task_id` are different, then the latter will not be deleted. In this case, a new task was likely rescheduled with the same ID. """ - log.trace(f"{self.cog_name}: performing done callback for task #{task_id} {id(done_task)}.") + self._log.trace(f"Performing done callback for task #{task_id} {id(done_task)}.") scheduled_task = self._scheduled_tasks.get(task_id) if scheduled_task and done_task is scheduled_task: # A task for the ID exists and its the same as the done task. # Since this is the done callback, the task is already done so no need to cancel it. - log.trace(f"{self.cog_name}: deleting task #{task_id} {id(done_task)}.") + self._log.trace(f"Deleting task #{task_id} {id(done_task)}.") del self._scheduled_tasks[task_id] elif scheduled_task: # A new task was likely rescheduled with the same ID. - log.debug( - f"{self.cog_name}: the scheduled task #{task_id} {id(scheduled_task)} " + self._log.debug( + f"The scheduled task #{task_id} {id(scheduled_task)} " f"and the done task {id(done_task)} differ." ) elif not done_task.cancelled(): - log.warning( - f"{self.cog_name}: task #{task_id} not found while handling task {id(done_task)}! " + self._log.warning( + f"Task #{task_id} not found while handling task {id(done_task)}! " f"A task somehow got unscheduled improperly (i.e. deleted but not cancelled)." ) @@ -109,7 +105,4 @@ class Scheduler(metaclass=CogABCMeta): exception = done_task.exception() # Log the exception if one exists. if exception: - log.error( - f"{self.cog_name}: error in task #{task_id} {id(done_task)}!", - exc_info=exception - ) + self._log.error(f"Error in task #{task_id} {id(done_task)}!", exc_info=exception) -- cgit v1.2.3 From 5ded9651ab260c43053a660f2fc239aa722db5c7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 21:59:05 -0700 Subject: Scheduler: directly take the awaitable to schedule This is a major change which simplifies the interface. It removes the need to implement an abstract method, which means the class can now be instantiated rather than subclassed. --- bot/utils/scheduling.py | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 002ef42cf..70fb1972b 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,13 +2,10 @@ import asyncio import contextlib import logging import typing as t -from abc import abstractmethod from functools import partial -from bot.utils import CogABCMeta - -class Scheduler(metaclass=CogABCMeta): +class Scheduler: """Task scheduler.""" def __init__(self, name: str): @@ -17,31 +14,15 @@ class Scheduler(metaclass=CogABCMeta): self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} - @abstractmethod - async def _scheduled_task(self, task_object: t.Any) -> None: - """ - A coroutine which handles the scheduling. - - This is added to the scheduled tasks, and should wait the task duration, execute the desired - code, then clean up the task. - - For example, in Reminders this will wait for the reminder duration, send the reminder, - then make a site API request to delete the reminder from the database. - """ - - def schedule_task(self, task_id: t.Hashable, task_data: t.Any) -> None: - """ - Schedules a task. - - `task_data` is passed to the `Scheduler._scheduled_task()` coroutine. - """ + def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: + """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(self._scheduled_task(task_data)) + task = asyncio.create_task(task) task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From 4bb6bde1c79f3ffd3d452dd7ffe489d9b093f567 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 22:00:26 -0700 Subject: Scheduler: name tasks Makes them easier to identify when debugging. --- bot/utils/scheduling.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 70fb1972b..f2640ed5e 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -22,7 +22,7 @@ class Scheduler: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(task) + task = asyncio.create_task(task, name=f"{self.name}_{task_id}") task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From 5130611719735d8e58c1d0faeeeaffe4553843dd Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 22:49:41 -0700 Subject: Scheduler: add support for in operator --- bot/utils/scheduling.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index f2640ed5e..00fca4169 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -14,6 +14,10 @@ class Scheduler: self._log = logging.getLogger(f"{__name__}.{name}") self._scheduled_tasks: t.Dict[t.Hashable, asyncio.Task] = {} + def __contains__(self, task_id: t.Hashable) -> bool: + """Return True if a task with the given `task_id` is currently scheduled.""" + return task_id in self._scheduled_tasks + def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") -- cgit v1.2.3 From c81d3bdd1769a02ba02af18e52150629e655e3c9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 19 Jun 2020 23:02:24 -0700 Subject: Scheduler: use pop instead of get when cancelling --- bot/utils/scheduling.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 00fca4169..6f498348d 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -39,17 +39,17 @@ class Scheduler: If `ignore_missing` is True, a warning will not be sent if a task isn't found. """ self._log.trace(f"Cancelling task #{task_id}...") - task = self._scheduled_tasks.get(task_id) - if not task: + try: + task = self._scheduled_tasks.pop(task_id) + except KeyError: if not ignore_missing: self._log.warning(f"Failed to unschedule {task_id} (no task found).") - return - - del self._scheduled_tasks[task_id] - task.cancel() + else: + del self._scheduled_tasks[task_id] + task.cancel() - self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") + self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") def cancel_all(self) -> None: """Unschedule all known tasks.""" -- cgit v1.2.3 From 662ca588ac352fc346fae973dead5052c7b4af59 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 12:22:11 +0200 Subject: Incidents: remove redundant `exc_info` passing Pointed out by Mark during review that this is unnecessary, as logging using `exception` automatically appends the `exc_info` of the handled exception when done in an except block. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 70921462d..5f4291953 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -171,8 +171,8 @@ class Incidents(Cog): # Finally add the `outcome` emoji await message.add_reaction(outcome.value) - except Exception as exc: - log.exception("Failed to archive incident to #incidents-archive", exc_info=exc) + except Exception: + log.exception("Failed to archive incident to #incidents-archive") return False else: @@ -273,8 +273,8 @@ class Incidents(Cog): message = await self.bot.get_channel(Channels.incidents).fetch_message(message_id) except discord.NotFound: log.trace("Message doesn't exist, it was likely already relayed") - except Exception as exc: - log.exception("Failed to fetch message!", exc_info=exc) + except Exception: + log.exception("Failed to fetch message!") else: log.trace("Message fetched successfully!") return message -- cgit v1.2.3 From 20b27f32c68673b603a6e6e41859f7672b6e0133 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 12:28:18 +0200 Subject: Incidents: make logs contain the message id they pertain to Suggested by Mark during review. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 5f4291953..33c3bee51 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -129,11 +129,11 @@ class Incidents(Cog): async for message in incidents.history(limit=limit): if not is_incident(message): - log.trace("Skipping message: not an incident") + log.trace(f"Skipping message {message.id}: not an incident") continue if has_signals(message): - log.trace("Skipping message: already has all signals") + log.trace(f"Skipping message {message.id}: already has all signals") continue await add_signals(message) @@ -172,7 +172,7 @@ class Incidents(Cog): await message.add_reaction(outcome.value) except Exception: - log.exception("Failed to archive incident to #incidents-archive") + log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") return False else: @@ -274,7 +274,7 @@ class Incidents(Cog): except discord.NotFound: log.trace("Message doesn't exist, it was likely already relayed") except Exception: - log.exception("Failed to fetch message!") + log.exception(f"Failed to fetch message {message_id}!") else: log.trace("Message fetched successfully!") return message -- cgit v1.2.3 From 6d3a91cf5d51e6e2a2f10c08718a7c8de0d521ed Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 12:35:46 +0200 Subject: Incidents: make crawl limit & sleep module-level constants Requested during review. Co-authored-by: ks129 <45097959+ks129@users.noreply.github.com> Co-authored-by: Joseph Banks --- bot/cogs/moderation/incidents.py | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 33c3bee51..4e6743224 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -11,6 +11,14 @@ from bot.constants import Channels, Emojis, Roles, Webhooks log = logging.getLogger(__name__) +# Amount of messages for `crawl_task` to process at most on start-up - limited to 50 +# as in practice, there should never be this many messages, and if there are, +# something has likely gone very wrong +CRAWL_LIMIT = 50 + +# Seconds for `crawl_task` to sleep after adding reactions to a message +CRAWL_SLEEP = 2 + class Signal(Enum): """ @@ -114,19 +122,14 @@ class Incidents(Cog): Once this task is scheduled, listeners that change messages should await it. The crawl assumes that the channel history doesn't change as we go over it. + + Behaviour is configured by: `CRAWL_LIMIT`, `CRAWL_SLEEP`. """ await self.bot.wait_until_guild_available() incidents: discord.TextChannel = self.bot.get_channel(Channels.incidents) - # Limit the query at 50 as in practice, there should never be this many messages, - # and if there are, something has likely gone very wrong - limit = 50 - - # Seconds to sleep after adding reactions to a message - sleep = 2 - - log.debug(f"Crawling messages in #incidents: {limit=}, {sleep=}") - async for message in incidents.history(limit=limit): + log.debug(f"Crawling messages in #incidents: {CRAWL_LIMIT=}, {CRAWL_SLEEP=}") + async for message in incidents.history(limit=CRAWL_LIMIT): if not is_incident(message): log.trace(f"Skipping message {message.id}: not an incident") @@ -137,7 +140,7 @@ class Incidents(Cog): continue await add_signals(message) - await asyncio.sleep(sleep) + await asyncio.sleep(CRAWL_SLEEP) log.debug("Crawl task finished!") -- cgit v1.2.3 From b8ada89bd45e6b8efd17fba79e70ce91a59b24fc Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 12:42:53 +0200 Subject: Incidents: simplify set operation in `has_signals` Using `issubset` is a much simpler & more readable way of expressing the relationship between the two sets. Suggested by Mark during review. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 4e6743224..089a5bc9f 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -56,8 +56,7 @@ def own_reactions(message: discord.Message) -> t.Set[str]: def has_signals(message: discord.Message) -> bool: """True if `message` already has all `Signal` reactions, False otherwise.""" - missing_signals = ALLOWED_EMOJI - own_reactions(message) # In `ALLOWED_EMOJI` but not in `own_reactions(message)` - return not missing_signals + return ALLOWED_EMOJI.issubset(own_reactions(message)) async def add_signals(incident: discord.Message) -> None: -- cgit v1.2.3 From 98b8947ab7865e33f18da8e2a62b26405676e8e4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 13:13:45 +0200 Subject: Incidents: try-except Signal creation Suggested by Mark during review. This follows the "ask for forgiveness rather than permission" paradigm, ends up being less code to read, and may be seen as more logical / safer. The `ALLOWED_EMOJI` set was renamed to `ALL_SIGNALS` as this now better communicates the set's purpose. Tests adjusted as appropriate. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 18 +++++++++++------- tests/bot/cogs/moderation/test_incidents.py | 8 ++++---- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 089a5bc9f..41a98bcb7 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -33,9 +33,11 @@ class Signal(Enum): INVESTIGATING = Emojis.incident_investigating -# Reactions from roles not listed here, or using emoji not listed here, will be removed +# Reactions from roles not listed here will be removed ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} -ALLOWED_EMOJI: t.Set[str] = {signal.value for signal in Signal} + +# Message must have all of these emoji to pass the `has_signals` check +ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} def is_incident(message: discord.Message) -> bool: @@ -56,7 +58,7 @@ def own_reactions(message: discord.Message) -> t.Set[str]: def has_signals(message: discord.Message) -> bool: """True if `message` already has all `Signal` reactions, False otherwise.""" - return ALLOWED_EMOJI.issubset(own_reactions(message)) + return ALL_SIGNALS.issubset(own_reactions(message)) async def add_signals(incident: discord.Message) -> None: @@ -96,7 +98,9 @@ class Incidents(Cog): * See: `on_message` On reaction: - * Remove reaction if not permitted (`ALLOWED_EMOJI`, `ALLOWED_ROLES`) + * Remove reaction if not permitted + * User does not have any of the roles in `ALLOWED_ROLES` + * Used emoji is not a `Signal` member * If `Signal.ACTIONED` or `Signal.NOT_ACTIONED` were chosen, attempt to relay the incident message to #incidents-archive * If relay successful, delete original message @@ -217,13 +221,13 @@ class Incidents(Cog): await incident.remove_reaction(reaction, member) return - if reaction not in ALLOWED_EMOJI: + try: + signal = Signal(reaction) + except ValueError: log.debug(f"Removing invalid reaction: emoji {reaction} is not a valid signal") await incident.remove_reaction(reaction, member) return - # If we reach this point, we know that `emoji` is a `Signal` member - signal = Signal(reaction) log.trace(f"Received signal: {signal}") if signal not in (Signal.ACTIONED, Signal.NOT_ACTIONED): diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 55b15ec9e..862736785 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -131,17 +131,17 @@ class TestOwnReactions(unittest.TestCase): self.assertSetEqual(incidents.own_reactions(message), {"A", "B"}) -@patch("bot.cogs.moderation.incidents.ALLOWED_EMOJI", {"A", "B"}) +@patch("bot.cogs.moderation.incidents.ALL_SIGNALS", {"A", "B"}) class TestHasSignals(unittest.TestCase): """ Assertions for the `has_signals` function. - We patch `ALLOWED_EMOJI` globally. Each test function then patches `own_reactions` + We patch `ALL_SIGNALS` globally. Each test function then patches `own_reactions` as appropriate. """ def test_has_signals_true(self): - """True when `own_reactions` returns all emoji in `ALLOWED_EMOJI`.""" + """True when `own_reactions` returns all emoji in `ALL_SIGNALS`.""" message = MockMessage() own_reactions = MagicMock(return_value={"A", "B"}) @@ -149,7 +149,7 @@ class TestHasSignals(unittest.TestCase): self.assertTrue(incidents.has_signals(message)) def test_has_signals_false(self): - """False when `own_reactions` does not return all emoji in `ALLOWED_EMOJI`.""" + """False when `own_reactions` does not return all emoji in `ALL_SIGNALS`.""" message = MockMessage() own_reactions = MagicMock(return_value={"A", "C"}) -- cgit v1.2.3 From 20dbd177f227511b9c3cb678ab45a67558cd3d7f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 13:15:43 +0200 Subject: Incidents tests: remove unnecessary patch This is already being patched at class-level. --- tests/bot/cogs/moderation/test_incidents.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 862736785..9f0553216 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -360,7 +360,6 @@ class TestMakeConfirmationTask(TestIncidents): class TestProcessEvent(TestIncidents): """Tests for the `Incidents.process_event` coroutine.""" - @patch("bot.cogs.moderation.incidents.ALLOWED_ROLES", {1, 2}) async def test_process_event_bad_role(self): """The reaction is removed when the author lacks all allowed roles.""" incident = MockMessage() -- cgit v1.2.3 From a8b4e394d9da57287cd9497cd9bb0a97fa467e84 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 14:02:48 +0200 Subject: Incidents: de-clyde archive webhook username With PR #1009 merged, we now apply the same fix to our relay function. This prevents the "clyde" word from sneaking into the webhook username, which is forbidden and will return a 400. --- bot/cogs/moderation/incidents.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 41a98bcb7..040f2c0c8 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -8,6 +8,7 @@ from discord.ext.commands import Cog from bot.bot import Bot from bot.constants import Channels, Emojis, Roles, Webhooks +from bot.utils.messages import sub_clyde log = logging.getLogger(__name__) @@ -169,7 +170,7 @@ class Incidents(Cog): # Now relay the incident message: discord.Message = await webhook.send( content=incident.clean_content, # Clean content will prevent mentions from pinging - username=incident.author.name, + username=sub_clyde(incident.author.name), avatar_url=incident.author.avatar_url, wait=True, # This makes the method return the sent Message object ) -- cgit v1.2.3 From f240a970c6b97d201959d25a79a8babafed1c2b1 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 20 Jun 2020 14:15:34 +0200 Subject: Incidents tests: assert webhook username is de-clyded See: a8b4e394d9da57287cd9497cd9bb0a97fa467e84 --- tests/bot/cogs/moderation/test_incidents.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 9f0553216..2fc9180cf 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -319,6 +319,25 @@ class TestArchive(TestIncidents): # Finally check that the method returned True self.assertTrue(archive_return) + async def test_archive_clyde_username(self): + """ + The archive webhook username is cleansed using `sub_clyde`. + + Discord will reject any webhook with "clyde" in the username field, as it impersonates + the official Clyde bot. Since we do not control what the username will be (the incident + author name is used), we must ensure the name is cleansed, otherwise the relay may fail. + + This test assumes the username is passed as a kwarg. If this test fails, please review + whether the passed argument is being retrieved correctly. + """ + webhook = MockAsyncWebhook() + self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) + + message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) + await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal)) + + self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) + class TestMakeConfirmationTask(TestIncidents): """ -- cgit v1.2.3 From e09276191f5bcaa0dbf34fdbff51654027528688 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 09:37:21 -0700 Subject: Scheduler: remove ignore_missing param The ability to use the `in` operator makes this obsolete. Callers can check themselves if a task exists before they try to cancel it. --- bot/utils/scheduling.py | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 6f498348d..d9b48034b 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -32,19 +32,14 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") - def cancel_task(self, task_id: t.Hashable, ignore_missing: bool = False) -> None: - """ - Unschedule the task identified by `task_id`. - - If `ignore_missing` is True, a warning will not be sent if a task isn't found. - """ + def cancel_task(self, task_id: t.Hashable) -> None: + """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") try: task = self._scheduled_tasks.pop(task_id) except KeyError: - if not ignore_missing: - self._log.warning(f"Failed to unschedule {task_id} (no task found).") + self._log.warning(f"Failed to unschedule {task_id} (no task found).") else: del self._scheduled_tasks[task_id] task.cancel() @@ -56,7 +51,7 @@ class Scheduler: self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): - self.cancel_task(task_id, ignore_missing=True) + self.cancel_task(task_id) def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ @@ -70,7 +65,7 @@ class Scheduler: scheduled_task = self._scheduled_tasks.get(task_id) if scheduled_task and done_task is scheduled_task: - # A task for the ID exists and its the same as the done task. + # A task for the ID exists and is the same as the done task. # Since this is the done callback, the task is already done so no need to cancel it. self._log.trace(f"Deleting task #{task_id} {id(done_task)}.") del self._scheduled_tasks[task_id] -- cgit v1.2.3 From 19e41aae30e19374054d9ed37f36faa2104f751c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:20:24 -0700 Subject: Scheduler: drop _task suffix from method names It's redundant. After all, this scheduler cannot schedule anything else. --- bot/utils/scheduling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index d9b48034b..4a003d4fe 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -18,7 +18,7 @@ class Scheduler: """Return True if a task with the given `task_id` is currently scheduled.""" return task_id in self._scheduled_tasks - def schedule_task(self, task_id: t.Hashable, task: t.Awaitable) -> None: + def schedule(self, task_id: t.Hashable, task: t.Awaitable) -> None: """Schedule the execution of a task.""" self._log.trace(f"Scheduling task #{task_id}...") @@ -32,7 +32,7 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") - def cancel_task(self, task_id: t.Hashable) -> None: + def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") @@ -51,7 +51,7 @@ class Scheduler: self._log.debug("Unscheduling all tasks") for task_id in self._scheduled_tasks.copy(): - self.cancel_task(task_id) + self.cancel(task_id) def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From ee47b2afda1f8f409c1c60bd874d15b1d1a52ca6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:23:50 -0700 Subject: Scheduler: rename "task" param to "coroutine" Naming it "task" is inaccurate because `create_task` accepts a coroutine rather than a Task. What it does is wrap the coroutine in a Task. --- bot/utils/scheduling.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4a003d4fe..625b726d2 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -18,15 +18,15 @@ class Scheduler: """Return True if a task with the given `task_id` is currently scheduled.""" return task_id in self._scheduled_tasks - def schedule(self, task_id: t.Hashable, task: t.Awaitable) -> None: - """Schedule the execution of a task.""" + def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """Schedule the execution of a coroutine.""" self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") return - task = asyncio.create_task(task, name=f"{self.name}_{task_id}") + task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}") task.add_done_callback(partial(self._task_done_callback, task_id)) self._scheduled_tasks[task_id] = task -- cgit v1.2.3 From f807bf72fa649242b910e309d7043c8bdc2b1fdc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 13:54:20 -0700 Subject: Scheduler: add a method to schedule with a delay --- bot/utils/scheduling.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 625b726d2..ac67278f6 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -32,6 +32,10 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") + def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" + self.schedule(task_id, self._await_later(delay, coroutine)) + def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" self._log.trace(f"Cancelling task #{task_id}...") @@ -53,6 +57,21 @@ class Scheduler: for task_id in self._scheduled_tasks.copy(): self.cancel(task_id) + async def _await_later(self, delay: t.Union[int, float], coroutine: t.Coroutine) -> None: + """Await `coroutine` after the given `delay` number of seconds.""" + try: + self._log.trace(f"Waiting {delay} seconds before awaiting the coroutine.") + await asyncio.sleep(delay) + + # Use asyncio.shield to prevent the coroutine from cancelling itself. + self._log.trace("Done waiting; now awaiting the coroutine.") + await asyncio.shield(coroutine) + finally: + # Close it to prevent unawaited coroutine warnings, + # which would happen if the task was cancelled during the sleep. + self._log.trace("Explicitly closing the coroutine.") + coroutine.close() + def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ Delete the task and raise its exception if one exists. -- cgit v1.2.3 From dfcf71f36c85e357028ea2f86aac7e38c6b8ab47 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 14:02:23 -0700 Subject: Scheduler: add a method to schedule at a specific datetime --- bot/utils/scheduling.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index ac67278f6..f5308059a 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -2,6 +2,7 @@ import asyncio import contextlib import logging import typing as t +from datetime import datetime from functools import partial @@ -32,6 +33,18 @@ class Scheduler: self._scheduled_tasks[task_id] = task self._log.debug(f"Scheduled task #{task_id} {id(task)}.") + def schedule_at(self, time: datetime, task_id: t.Hashable, coroutine: t.Coroutine) -> None: + """ + Schedule `coroutine` to be executed at the given naïve UTC `time`. + + If `time` is in the past, schedule `coroutine` immediately. + """ + delay = (time - datetime.utcnow()).total_seconds() + if delay > 0: + coroutine = self._await_later(delay, coroutine) + + self.schedule(task_id, coroutine) + def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" self.schedule(task_id, self._await_later(delay, coroutine)) -- cgit v1.2.3 From f2f4b425dc8988ffaf9b1ebe8c2a5b449a50a48e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:25:48 -0700 Subject: Update Filtering's scheduler to the new API --- bot/cogs/filtering.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 76ea68660..099606b82 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -19,7 +19,6 @@ from bot.constants import ( ) from bot.utils.redis_cache import RedisCache from bot.utils.scheduling import Scheduler -from bot.utils.time import wait_until log = logging.getLogger(__name__) @@ -60,7 +59,7 @@ def expand_spoilers(text: str) -> str: OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) -class Filtering(Cog, Scheduler): +class Filtering(Cog): """Filtering out invites, blacklisting domains, and warning us of certain regular expressions.""" # Redis cache mapping a user ID to the last timestamp a bad nickname alert was sent @@ -68,8 +67,7 @@ class Filtering(Cog, Scheduler): def __init__(self, bot: Bot): self.bot = bot - super().__init__() - + self.scheduler = Scheduler(self.__class__.__name__) self.name_lock = asyncio.Lock() staff_mistake_str = "If you believe this was a mistake, please let staff know!" @@ -268,7 +266,7 @@ class Filtering(Cog, Scheduler): } await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_task(msg.id, data) + self.schedule_msg_delete(data) log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if is_private: @@ -457,12 +455,10 @@ class Filtering(Cog, Scheduler): except discord.errors.Forbidden: await channel.send(f"{filtered_member.mention} {reason}") - async def _scheduled_task(self, msg: dict) -> None: + def schedule_msg_delete(self, msg: dict) -> None: """Delete an offensive message once its deletion date is reached.""" delete_at = dateutil.parser.isoparse(msg['delete_date']).replace(tzinfo=None) - - await wait_until(delete_at) - await self.delete_offensive_msg(msg) + self.scheduler.schedule_at(delete_at, msg['id'], self.delete_offensive_msg(msg)) async def reschedule_offensive_msg_deletion(self) -> None: """Get all the pending message deletion from the API and reschedule them.""" @@ -477,7 +473,7 @@ class Filtering(Cog, Scheduler): if delete_at < now: await self.delete_offensive_msg(msg) else: - self.schedule_task(msg['id'], msg) + self.schedule_msg_delete(msg) async def delete_offensive_msg(self, msg: Mapping[str, str]) -> None: """Delete an offensive message, and then delete it from the db.""" -- cgit v1.2.3 From 90f0cb34cefdc362336cfb27b2e94f8925f312f4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:42:26 -0700 Subject: Update Reminders's scheduler to the new API --- bot/cogs/reminders.py | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index c242d2920..0d20bdb2b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -17,7 +17,7 @@ from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check from bot.utils.scheduling import Scheduler -from bot.utils.time import humanize_delta, wait_until +from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -25,12 +25,12 @@ WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 -class Reminders(Scheduler, Cog): +class Reminders(Cog): """Provide in-channel reminder functionality.""" def __init__(self, bot: Bot): self.bot = bot - super().__init__() + self.scheduler = Scheduler(self.__class__.__name__) self.bot.loop.create_task(self.reschedule_reminders()) @@ -56,7 +56,7 @@ class Reminders(Scheduler, Cog): late = relativedelta(now, remind_at) await self.send_reminder(reminder, late) else: - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) def ensure_valid_reminder( self, @@ -99,17 +99,18 @@ class Reminders(Scheduler, Cog): await ctx.send(embed=embed) - async def _scheduled_task(self, reminder: dict) -> None: + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] reminder_datetime = isoparse(reminder['expiration']).replace(tzinfo=None) - # Send the reminder message once the desired duration has passed - await wait_until(reminder_datetime) - await self.send_reminder(reminder) + async def _remind() -> None: + await self.send_reminder(reminder) - log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") - await self._delete_reminder(reminder_id) + log.debug(f"Deleting reminder {reminder_id} (the user has been reminded).") + await self._delete_reminder(reminder_id) + + self.scheduler.schedule_at(reminder_datetime, reminder_id, _remind()) async def _delete_reminder(self, reminder_id: str, cancel_task: bool = True) -> None: """Delete a reminder from the database, given its ID, and cancel the running task.""" @@ -117,15 +118,15 @@ class Reminders(Scheduler, Cog): if cancel_task: # Now we can remove it from the schedule list - self.cancel_task(reminder_id) + self.scheduler.cancel(reminder_id) async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" log.trace(f"Cancelling old task #{reminder['id']}") - self.cancel_task(reminder["id"]) + self.scheduler.cancel(reminder["id"]) log.trace(f"Scheduling new task #{reminder['id']}") - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) async def send_reminder(self, reminder: dict, late: relativedelta = None) -> None: """Send the reminder.""" @@ -223,7 +224,7 @@ class Reminders(Scheduler, Cog): delivery_dt=expiration, ) - self.schedule_task(reminder["id"], reminder) + self.schedule_reminder(reminder) @remind_group.command(name="list") async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]: -- cgit v1.2.3 From 6c76a04dab61de0ae4ea786c97f160805640d0c5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:45:10 -0700 Subject: Update Silence's scheduler to the new API --- bot/cogs/moderation/silence.py | 32 ++++++-------------------------- 1 file changed, 6 insertions(+), 26 deletions(-) diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index c8ab6443b..ae4fb7b64 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -1,7 +1,7 @@ import asyncio import logging from contextlib import suppress -from typing import NamedTuple, Optional +from typing import Optional from discord import TextChannel from discord.ext import commands, tasks @@ -16,13 +16,6 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) -class TaskData(NamedTuple): - """Data for a scheduled task.""" - - delay: int - ctx: Context - - class SilenceNotifier(tasks.Loop): """Loop notifier for posting notices to `alert_channel` containing added channels.""" @@ -61,25 +54,17 @@ class SilenceNotifier(tasks.Loop): await self._alert_channel.send(f"<@&{Roles.moderators}> currently silenced channels: {channels_text}") -class Silence(Scheduler, commands.Cog): +class Silence(commands.Cog): """Commands for stopping channel messages for `verified` role in a channel.""" def __init__(self, bot: Bot): - super().__init__() self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) self.muted_channels = set() + self._get_instance_vars_task = self.bot.loop.create_task(self._get_instance_vars()) self._get_instance_vars_event = asyncio.Event() - async def _scheduled_task(self, task: TaskData) -> None: - """Calls `self.unsilence` on expired silenced channel to unsilence it.""" - await asyncio.sleep(task.delay) - log.info("Unsilencing channel after set delay.") - - # Because `self.unsilence` explicitly cancels this scheduled task, it is shielded - # to avoid prematurely cancelling itself - await asyncio.shield(task.ctx.invoke(self.unsilence)) - async def _get_instance_vars(self) -> None: """Get instance variables after they're available to get from the guild.""" await self.bot.wait_until_guild_available() @@ -109,12 +94,7 @@ class Silence(Scheduler, commands.Cog): await ctx.send(f"{Emojis.check_mark} silenced current channel for {duration} minute(s).") - task_data = TaskData( - delay=duration*60, - ctx=ctx - ) - - self.schedule_task(ctx.channel.id, task_data) + self.scheduler.schedule_later(duration * 60, ctx.channel.id, ctx.invoke(self.unsilence)) @commands.command(aliases=("unhush",)) async def unsilence(self, ctx: Context) -> None: @@ -164,7 +144,7 @@ class Silence(Scheduler, commands.Cog): if current_overwrite.send_messages is False: await channel.set_permissions(self._verified_role, **dict(current_overwrite, send_messages=None)) log.info(f"Unsilenced channel #{channel} ({channel.id}).") - self.cancel_task(channel.id) + self.scheduler.cancel(channel.id) self.notifier.remove_channel(channel) self.muted_channels.discard(channel) return True -- cgit v1.2.3 From 0e69211295c6d7656b776870aa2bd8aab9244f5f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 16:56:14 -0700 Subject: Update HelpChannels's scheduler to the new API --- bot/cogs/help_channels.py | 70 ++++++++++++++--------------------------------- 1 file changed, 20 insertions(+), 50 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 187adfe51..93ef07c84 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -1,5 +1,4 @@ import asyncio -import inspect import json import logging import random @@ -57,14 +56,7 @@ through our guide for [asking a good question]({ASKING_GUIDE_URL}). CoroutineFunc = t.Callable[..., t.Coroutine] -class TaskData(t.NamedTuple): - """Data for a scheduled task.""" - - wait_time: int - callback: t.Awaitable - - -class HelpChannels(Scheduler, commands.Cog): +class HelpChannels(commands.Cog): """ Manage the help channel system of the guild. @@ -114,9 +106,8 @@ class HelpChannels(Scheduler, commands.Cog): claim_times = RedisCache() def __init__(self, bot: Bot): - super().__init__() - self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) # Categories self.available_category: discord.CategoryChannel = None @@ -145,7 +136,7 @@ class HelpChannels(Scheduler, commands.Cog): for task in self.queue_tasks: task.cancel() - self.cancel_all() + self.scheduler.cancel_all() def create_channel_queue(self) -> asyncio.Queue: """ @@ -229,10 +220,11 @@ class HelpChannels(Scheduler, commands.Cog): await self.remove_cooldown_role(ctx.author) # Ignore missing task when cooldown has passed but the channel still isn't dormant. - self.cancel_task(ctx.author.id, ignore_missing=True) + if ctx.author.id in self.scheduler: + self.scheduler.cancel(ctx.author.id) await self.move_to_dormant(ctx.channel, "command") - self.cancel_task(ctx.channel.id) + self.scheduler.cancel(ctx.channel.id) else: log.debug(f"{ctx.author} invoked command 'dormant' outside an in-use help channel") @@ -474,16 +466,15 @@ class HelpChannels(Scheduler, commands.Cog): else: # Cancel the existing task, if any. if has_task: - self.cancel_task(channel.id) - - data = TaskData(idle_seconds - time_elapsed, self.move_idle_channel(channel)) + self.scheduler.cancel(channel.id) + delay = idle_seconds - time_elapsed log.info( f"#{channel} ({channel.id}) is still active; " - f"scheduling it to be moved after {data.wait_time} seconds." + f"scheduling it to be moved after {delay} seconds." ) - self.schedule_task(channel.id, data) + self.scheduler.schedule_later(delay, channel.id, self.move_idle_channel(channel)) async def move_to_bottom_position(self, channel: discord.TextChannel, category_id: int, **options) -> None: """ @@ -588,8 +579,7 @@ class HelpChannels(Scheduler, commands.Cog): timeout = constants.HelpChannels.idle_minutes * 60 log.trace(f"Scheduling #{channel} ({channel.id}) to become dormant in {timeout} sec.") - data = TaskData(timeout, self.move_idle_channel(channel)) - self.schedule_task(channel.id, data) + self.scheduler.schedule_later(timeout, channel.id, self.move_idle_channel(channel)) self.report_stats() async def notify(self) -> None: @@ -722,10 +712,10 @@ class HelpChannels(Scheduler, commands.Cog): log.info(f"Claimant of #{msg.channel} ({msg.author}) deleted message, channel is empty now. Rescheduling task.") # Cancel existing dormant task before scheduling new. - self.cancel_task(msg.channel.id) + self.scheduler.cancel(msg.channel.id) - task = TaskData(constants.HelpChannels.deleted_idle_minutes * 60, self.move_idle_channel(msg.channel)) - self.schedule_task(msg.channel.id, task) + delay = constants.HelpChannels.deleted_idle_minutes * 60 + self.scheduler.schedule_later(delay, msg.channel.id, self.move_idle_channel(msg.channel)) async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if the most recent message in `channel` is the bot's `AVAILABLE_MSG`.""" @@ -752,8 +742,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.remove_cooldown_role(member) else: # The member is still on a cooldown; re-schedule it for the remaining time. - remaining = cooldown - in_use_time.seconds - await self.schedule_cooldown_expiration(member, remaining) + delay = cooldown - in_use_time.seconds + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) async def add_cooldown_role(self, member: discord.Member) -> None: """Add the help cooldown role to `member`.""" @@ -804,16 +794,11 @@ class HelpChannels(Scheduler, commands.Cog): # Cancel the existing task, if any. # Would mean the user somehow bypassed the lack of permissions (e.g. user is guild owner). - self.cancel_task(member.id, ignore_missing=True) + if member.id in self.scheduler: + self.scheduler.cancel(member.id) - await self.schedule_cooldown_expiration(member, constants.HelpChannels.claim_minutes * 60) - - async def schedule_cooldown_expiration(self, member: discord.Member, seconds: int) -> None: - """Schedule the cooldown role for `member` to be removed after a duration of `seconds`.""" - log.trace(f"Scheduling removal of {member}'s ({member.id}) cooldown.") - - callback = self.remove_cooldown_role(member) - self.schedule_task(member.id, TaskData(seconds, callback)) + delay = constants.HelpChannels.claim_minutes * 60 + self.scheduler.schedule_later(delay, member.id, self.remove_cooldown_role(member)) async def send_available_message(self, channel: discord.TextChannel) -> None: """Send the available message by editing a dormant message or sending a new message.""" @@ -855,21 +840,6 @@ class HelpChannels(Scheduler, commands.Cog): return channel - async def _scheduled_task(self, data: TaskData) -> None: - """Await the `data.callback` coroutine after waiting for `data.wait_time` seconds.""" - try: - log.trace(f"Waiting {data.wait_time} seconds before awaiting callback.") - await asyncio.sleep(data.wait_time) - - # Use asyncio.shield to prevent callback from cancelling itself. - # The parent task (_scheduled_task) will still get cancelled. - log.trace("Done waiting; now awaiting the callback.") - await asyncio.shield(data.callback) - finally: - if inspect.iscoroutine(data.callback): - log.trace("Explicitly closing coroutine.") - data.callback.close() - def validate_config() -> None: """Raise a ValueError if the cog's config is invalid.""" -- cgit v1.2.3 From 23e663d5ff992d13a7685b44f09da0f21b390b0c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 20 Jun 2020 17:17:56 -0700 Subject: Update InfractionScheduler's scheduler to the new API --- bot/cogs/moderation/management.py | 4 ++-- bot/cogs/moderation/scheduler.py | 23 +++++++++-------------- bot/cogs/moderation/superstarify.py | 2 +- 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index c39c7f3bc..e87f3d7a4 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -135,11 +135,11 @@ class ModManagement(commands.Cog): if 'expires_at' in request_data: # A scheduled task should only exist if the old infraction wasn't permanent if old_infraction['expires_at']: - self.infractions_cog.cancel_task(new_infraction['id']) + self.infractions_cog.scheduler.cancel(new_infraction['id']) # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: - self.infractions_cog.schedule_task(new_infraction['id'], new_infraction) + self.infractions_cog.scheduler.schedule(new_infraction['id'], new_infraction) log_text += f""" Previous expiry: {old_infraction['expires_at'] or "Permanent"} diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index d75a72ddb..601e238c9 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -1,4 +1,3 @@ -import asyncio import logging import textwrap import typing as t @@ -23,13 +22,13 @@ from .utils import UserSnowflake log = logging.getLogger(__name__) -class InfractionScheduler(Scheduler): +class InfractionScheduler: """Handles the application, pardoning, and expiration of infractions.""" def __init__(self, bot: Bot, supported_infractions: t.Container[str]): - super().__init__() - self.bot = bot + self.scheduler = Scheduler(self.__class__.__name__) + self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) @property @@ -49,7 +48,7 @@ class InfractionScheduler(Scheduler): ) for infraction in infractions: if infraction["expires_at"] is not None and infraction["type"] in supported_infractions: - self.schedule_task(infraction["id"], infraction) + self.schedule_expiration(infraction) async def reapply_infraction( self, @@ -155,7 +154,7 @@ class InfractionScheduler(Scheduler): await action_coro if expiry: # Schedule the expiration of the infraction. - self.schedule_task(infraction["id"], infraction) + self.schedule_expiration(infraction) except discord.HTTPException as e: # Accordingly display that applying the infraction failed. confirm_msg = ":x: failed to apply" @@ -278,7 +277,7 @@ class InfractionScheduler(Scheduler): # Cancel pending expiration task. if infraction["expires_at"] is not None: - self.cancel_task(infraction["id"]) + self.scheduler.cancel(infraction["id"]) # Accordingly display whether the user was successfully notified via DM. dm_emoji = "" @@ -415,7 +414,7 @@ class InfractionScheduler(Scheduler): # Cancel the expiration task. if infraction["expires_at"] is not None: - self.cancel_task(infraction["id"]) + self.scheduler.cancel(infraction["id"]) # Send a log message to the mod log. if send_log: @@ -449,7 +448,7 @@ class InfractionScheduler(Scheduler): """ raise NotImplementedError - async def _scheduled_task(self, infraction: utils.Infraction) -> None: + def schedule_expiration(self, infraction: utils.Infraction) -> None: """ Marks an infraction expired after the delay from time of scheduling to time of expiration. @@ -457,8 +456,4 @@ class InfractionScheduler(Scheduler): expiration task is cancelled. """ expiry = dateutil.parser.isoparse(infraction["expires_at"]).replace(tzinfo=None) - await time.wait_until(expiry) - - # Because deactivate_infraction() explicitly cancels this scheduled task, it is shielded - # to avoid prematurely cancelling itself. - await asyncio.shield(self.deactivate_infraction(infraction)) + self.scheduler.schedule_at(expiry, infraction["id"], self.deactivate_infraction(infraction)) diff --git a/bot/cogs/moderation/superstarify.py b/bot/cogs/moderation/superstarify.py index 45a010f00..867de815a 100644 --- a/bot/cogs/moderation/superstarify.py +++ b/bot/cogs/moderation/superstarify.py @@ -146,7 +146,7 @@ class Superstarify(InfractionScheduler, Cog): log.debug(f"Changing nickname of {member} to {forced_nick}.") self.mod_log.ignore(constants.Event.member_update, member.id) await member.edit(nick=forced_nick, reason=reason) - self.schedule_task(id_, infraction) + self.schedule_expiration(infraction) # Send a DM to the user to notify them of their new infraction. await utils.notify_infraction( -- cgit v1.2.3 From 6fa8caed037b247a7c194f58a4635de7dae21fd2 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 21 Jun 2020 13:51:17 +0200 Subject: Incidents: implement `make_username` helper The justification is to incorporate the `actioned_by` name into the username in some way, and so the logical thing to do is to abstract this process into a helper so that it can easily be adjusted in the future. For now, I've chosen to separate the names by a pipe. Discord webhook username cannot exceed 80 characters in length, and so we cap it at this length by default. This is seen as more of an edge-case, but it should be accounted for, as we're not joining two names. The `max_length` param is configurable primarily for testing purposes, it probably should never be passed explicitly. This commit also provides two tests for the function. --- bot/cogs/moderation/incidents.py | 24 ++++++++++++++++++++++++ tests/bot/cogs/moderation/test_incidents.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 040f2c0c8..2cce9b6fe 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -41,6 +41,30 @@ ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +def make_username(reported_by: discord.Member, actioned_by: discord.Member, max_length: int = 80) -> str: + """ + Create a webhook-friendly username from the names of `reported_by` and `actioned_by`. + + If the resulting username length exceeds `max_length`, it will be capped at `max_length - 3` + and have 3 dots appended to the end. The default value is 80, which corresponds to the limit + Discord imposes on webhook username length. + + If the value of `max_length` is < 3, ValueError is raised. + """ + if max_length < 3: + raise ValueError(f"Maximum length cannot be less than 3: {max_length=}") + + username = f"{reported_by.name} | {actioned_by.name}" + log.trace(f"Generated webhook username: {username} (length: {len(username)})") + + if len(username) > max_length: + stop = max_length - 3 + username = f"{username[:stop]}..." + log.trace(f"Username capped at {max_length=}: {username}") + + return username + + def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 2fc9180cf..5700a5a35 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -68,6 +68,35 @@ mock_404 = discord.NotFound( ) +class TestMakeUsername(unittest.TestCase): + """Collection of tests for the `make_username` helper function.""" + + def test_make_username_raises(self): + """Raises `ValueError` on `max_length` < 3.""" + with self.assertRaises(ValueError): + incidents.make_username(MockMember(), MockMember(), max_length=2) + + def test_make_username_never_exceed_limit(self): + """ + The return string length is always less than or equal to `max_length`. + + For this test we pass `max_length=10` for convenience. The name of the first + user (`reported_by`) is always 1 character in length, but we generate names + for the `actioned_by` user starting at length 1 and up to length 20. + + Finally, we assert that the output length never exceeded 10 in total. + """ + user_a = MockMember(name="A") + + max_length = 10 + test_cases = (MockMember(name="B" * n) for n in range(1, 20)) + + for user_b in test_cases: + with self.subTest(user_a=user_a, user_b=user_b, max_length=max_length): + generated_username = incidents.make_username(user_a, user_b, max_length) + self.assertLessEqual(len(generated_username), max_length) + + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ -- cgit v1.2.3 From a8d179d9b04f54b20c5e870bcfa85c78c42c8dca Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sun, 21 Jun 2020 14:21:18 +0200 Subject: Incidents: append `actioned_by` to webhook username Incident author and the moderator who actioned report are now passed through `make_username` to create the webhook username. Tests adjusted as appropriate. --- bot/cogs/moderation/incidents.py | 9 +++++---- tests/bot/cogs/moderation/test_incidents.py | 23 +++++++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2cce9b6fe..72cc4b26c 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -172,13 +172,14 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive(self, incident: discord.Message, outcome: Signal) -> bool: + async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: """ Relay `incident` to the #incidents-archive channel. The following pieces of information are relayed: * Incident message content (clean, pingless) - * Incident author name (as webhook author) + * Incident author name (as webhook username) + * Name of user who actioned the incident (appended to webhook username) * Incident author avatar (as webhook avatar) * Resolution signal (`outcome`) @@ -194,7 +195,7 @@ class Incidents(Cog): # Now relay the incident message: discord.Message = await webhook.send( content=incident.clean_content, # Clean content will prevent mentions from pinging - username=sub_clyde(incident.author.name), + username=sub_clyde(make_username(incident.author, actioned_by)), avatar_url=incident.author.avatar_url, wait=True, # This makes the method return the sent Message object ) @@ -259,7 +260,7 @@ class Incidents(Cog): log.debug("Reaction was valid, but no action is currently defined for it") return - relay_successful = await self.archive(incident, signal) + relay_successful = await self.archive(incident, signal, actioned_by=member) if not relay_successful: log.trace("Original message will not be deleted as we failed to relay it to the archive") return diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 5700a5a35..a811868e5 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -307,7 +307,9 @@ class TestArchive(TestIncidents): propagate out of the method, which is just as important. """ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) - self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) + + result = await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) + self.assertFalse(result) async def test_archive_relays_incident(self): """ @@ -332,12 +334,18 @@ class TestArchive(TestIncidents): author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, ) - archive_return = await self.cog_instance.archive(incident, outcome=MagicMock(value="A")) + + with patch("bot.cogs.moderation.incidents.make_username", MagicMock(return_value="generated_username")): + archive_return = await self.cog_instance.archive( + incident=incident, + outcome=MagicMock(value="A"), + actioned_by=MockMember(name="moderator"), + ) # Check that the webhook was dispatched correctly webhook.send.assert_called_once_with( content="pingless message", - username="author_name", + username="generated_username", avatar_url="author_avatar", wait=True, ) @@ -354,7 +362,8 @@ class TestArchive(TestIncidents): Discord will reject any webhook with "clyde" in the username field, as it impersonates the official Clyde bot. Since we do not control what the username will be (the incident - author name is used), we must ensure the name is cleansed, otherwise the relay may fail. + author name, and actioning moderator names are used), we must ensure the name is cleansed, + otherwise the relay may fail. This test assumes the username is passed as a kwarg. If this test fails, please review whether the passed argument is being retrieved correctly. @@ -362,9 +371,11 @@ class TestArchive(TestIncidents): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) - await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal)) + # The `make_username` helper will return a string with "clyde" in it + with patch("bot.cogs.moderation.incidents.make_username", MagicMock(return_value="clyde the great")): + await self.cog_instance.archive(MockMessage(), MagicMock(incidents.Signal), MockMember()) + # Assert that the "clyde" was never passed to `send` self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) -- cgit v1.2.3 From 58d20203870f293de9410db4bf0e602696d04c2c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 23 Jun 2020 23:52:50 -0700 Subject: Scheduler: close coroutine if task ID already exists This prevents unawaited coroutine warnings. --- bot/utils/scheduling.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index f5308059a..4e99db76c 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -20,11 +20,17 @@ class Scheduler: return task_id in self._scheduled_tasks def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """Schedule the execution of a coroutine.""" + """ + Schedule the execution of a coroutine. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. + This prevents unawaited coroutine warnings. + """ self._log.trace(f"Scheduling task #{task_id}...") if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") + coroutine.close() return task = asyncio.create_task(coroutine, name=f"{self.name}_{task_id}") -- cgit v1.2.3 From bc6817536a7db4242cfa725ce809ced45f7cb556 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 24 Jun 2020 16:46:14 -0700 Subject: Scheduler: remove duplicate dict delete The task is already popped from the dict, so there is no need to delete it afterwards. --- bot/utils/scheduling.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4e99db76c..4110598d5 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -64,7 +64,6 @@ class Scheduler: except KeyError: self._log.warning(f"Failed to unschedule {task_id} (no task found).") else: - del self._scheduled_tasks[task_id] task.cancel() self._log.debug(f"Unscheduled task #{task_id} {id(task)}.") -- cgit v1.2.3 From e09307e0f8f570279271c99525e0cde6cfa84d5b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 25 Jun 2020 11:51:19 -0700 Subject: Scheduler: only close unawaited coroutines The coroutine may cancel the scheduled task, which would also trigger the finally block. The coroutine isn't necessarily finished when it cancels the task, so it shouldn't be closed in this case. --- bot/utils/scheduling.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 4110598d5..cf2a1f110 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -1,5 +1,6 @@ import asyncio import contextlib +import inspect import logging import typing as t from datetime import datetime @@ -87,8 +88,11 @@ class Scheduler: finally: # Close it to prevent unawaited coroutine warnings, # which would happen if the task was cancelled during the sleep. - self._log.trace("Explicitly closing the coroutine.") - coroutine.close() + # Only close it if it's not been awaited yet. This check is important because the + # coroutine may cancel this task, which would also trigger the finally block. + if inspect.getcoroutinestate(coroutine) == "CORO_CREATED": + self._log.trace("Explicitly closing the coroutine.") + coroutine.close() def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From 98bbae201af3a125663025901eb8586914e99df6 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Thu, 25 Jun 2020 23:45:50 -0400 Subject: Account for spaces in LinePaginator._split_remaining_lines() --- bot/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index 30e74b1b1..e41f9a521 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -135,7 +135,7 @@ class LinePaginator(Paginator): if not is_full: if len(word) + reduced_char_count <= max_chars: reduced_words.append(word) - reduced_char_count += len(word) + reduced_char_count += len(word) + 1 else: is_full = True remaining_words.append(word) -- cgit v1.2.3 From be4ce9ee81a0487e9e2417bc952505a3db81fec6 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Fri, 26 Jun 2020 01:52:15 -0400 Subject: Fix LinePaginator new page creation --- bot/pagination.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index e41f9a521..be3f82343 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -62,7 +62,7 @@ class LinePaginator(Paginator): if scale_to_size < max_size: raise ValueError("scale_to_size must be >= max_size.") - self.scale_to_size = scale_to_size + self.scale_to_size = scale_to_size - len(suffix) self.max_lines = max_lines self._current_page = [prefix] self._linecount = 0 @@ -94,14 +94,14 @@ class LinePaginator(Paginator): raise RuntimeError(f'Line exceeds maximum scale_to_size {self.scale_to_size}' ' and could not be split.') - if self.max_lines is not None: - if self._linecount >= self.max_lines: - self._linecount = 0 - self.close_page() + if self.max_lines is not None and self._linecount >= self.max_lines: + log.debug("max_lines exceeded, creating new page.") + self._new_page() + elif self._count + len(line) + 1 > self.max_size and self._linecount > 0: + log.debug("max_size exceeded on page with lines, creating new page.") + self._new_page() - self._linecount += 1 - if self._count + len(line) + 1 > self.max_size: - self.close_page() + self._linecount += 1 self._count += len(line) + 1 self._current_page.append(line) @@ -111,8 +111,14 @@ class LinePaginator(Paginator): self._count += 1 if remaining_words: + self._new_page() self.add_line(remaining_words) + def _new_page(self) -> None: + self._linecount = 0 + self._count = len(self.prefix) + 1 + self.close_page() + def _split_remaining_words(self, line: str, max_chars: int) -> t.Tuple[str, t.Optional[str]]: """ Internal: split a line into two strings -- reduced_words and remaining_words. -- cgit v1.2.3 From 7cb56d44eb2b6db3e0e20c9b8277b00d9aa4ce3a Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Fri, 26 Jun 2020 02:00:27 -0400 Subject: Simplify LinePaginator continuation header --- bot/pagination.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index be3f82343..230cc5add 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -132,8 +132,9 @@ class LinePaginator(Paginator): Return a tuple in the format (reduced_words, remaining_words). """ reduced_words = [] + remaining_words = [] # "(Continued)" is used on a line by itself to indicate the continuation of last page - remaining_words = ["(Continued)\n", "---------------\n"] + continuation_header = "(Continued)\n-----------\n" reduced_char_count = 0 is_full = False @@ -147,9 +148,11 @@ class LinePaginator(Paginator): remaining_words.append(word) else: remaining_words.append(word) - - return " ".join(reduced_words), " ".join(remaining_words) if len(remaining_words) > 2 \ - else None + + return ( + " ".join(reduced_words), + continuation_header + " ".join(remaining_words) if remaining_words else None + ) @classmethod async def paginate( -- cgit v1.2.3 From 195e0f9407d2a8b7ac5b3028b4f10c1b73af0a4f Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Fri, 26 Jun 2020 02:08:48 -0400 Subject: Update LinePaginator.add_line() tests --- tests/bot/test_pagination.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py index f2e2c27ce..74896f010 100644 --- a/tests/bot/test_pagination.py +++ b/tests/bot/test_pagination.py @@ -18,18 +18,18 @@ class LinePaginatorTests(TestCase): self.assertEqual(len(self.paginator._pages), 0) def test_add_line_works_on_long_lines(self): - """`add_line` should scale long lines up to `scale_to_size`.""" - self.paginator.add_line('x' * self.paginator.scale_to_size) - self.assertEqual(len(self.paginator._pages), 1) + """After additional lines after `max_size` is exceeded should go on the next page.""" + self.paginator.add_line('x' * self.paginator.max_size) + self.assertEqual(len(self.paginator._pages), 0) # Any additional lines should start a new page after `max_size` is exceeded. self.paginator.add_line('x') - self.assertEqual(len(self.paginator._pages), 2) + self.assertEqual(len(self.paginator._pages), 1) def test_add_line_continuation(self): """When `scale_to_size` is exceeded, remaining words should be split onto the next page.""" self.paginator.add_line('zyz ' * (self.paginator.scale_to_size//4 + 1)) - self.assertEqual(len(self.paginator._pages), 2) + self.assertEqual(len(self.paginator._pages), 1) def test_add_line_no_continuation(self): """If adding a new line to an existing page would exceed `max_size`, it should start a new -- cgit v1.2.3 From b204339e4301592a475296af2629c7a986c74148 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Fri, 26 Jun 2020 02:23:03 -0400 Subject: Correctly pass scale_to_size in LinePaginator.paginate() --- bot/pagination.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index 230cc5add..746ec3696 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -210,7 +210,8 @@ class LinePaginator(Paginator): )) ) - paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines) + paginator = cls(prefix=prefix, suffix=suffix, max_size=max_size, max_lines=max_lines, + scale_to_size=scale_to_size) current_page = 0 if not lines: -- cgit v1.2.3 From 77ce4c88695ca748059a7076de88d5b42b37d5f5 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Fri, 26 Jun 2020 03:22:30 -0400 Subject: In LinePaginator, truncate words that exceed scale_to_size --- bot/pagination.py | 11 ++++++----- tests/bot/test_pagination.py | 12 +++++------- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index 746ec3696..cd602c715 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -88,11 +88,9 @@ class LinePaginator(Paginator): if len(line) > (max_chars := self.max_size - len(self.prefix) - 2): if len(line) > self.scale_to_size: line, remaining_words = self._split_remaining_words(line, max_chars) - # If line still exceeds scale_to_size, we were unable to split into a second - # page without truncating. if len(line) > self.scale_to_size: - raise RuntimeError(f'Line exceeds maximum scale_to_size {self.scale_to_size}' - ' and could not be split.') + log.debug("Could not continue to next page, truncating line.") + line = line[:self.scale_to_size] if self.max_lines is not None and self._linecount >= self.max_lines: log.debug("max_lines exceeded, creating new page.") @@ -144,11 +142,14 @@ class LinePaginator(Paginator): reduced_words.append(word) reduced_char_count += len(word) + 1 else: + # If reduced_words is empty, we were unable to split the words across pages + if not reduced_words: + return line, None is_full = True remaining_words.append(word) else: remaining_words.append(word) - + return ( " ".join(reduced_words), continuation_header + " ".join(remaining_words) if remaining_words else None diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py index 74896f010..ce880d457 100644 --- a/tests/bot/test_pagination.py +++ b/tests/bot/test_pagination.py @@ -39,13 +39,11 @@ class LinePaginatorTests(TestCase): self.paginator.add_line('z') self.assertEqual(len(self.paginator._pages), 1) - def test_add_line_raises_on_very_long_words(self): - """`add_line` should raise if a single long word is added that exceeds `scale_to_size`. - - Note: truncation is also a potential option, but this should not occur from normal usage. - """ - with self.assertRaises(RuntimeError): - self.paginator.add_line('x' * (self.paginator.scale_to_size + 1)) + def test_add_line_truncates_very_long_words(self): + """`add_line` should truncate if a single long word exceeds `scale_to_size`.""" + self.paginator.add_line('x' * (self.paginator.scale_to_size + 1)) + # Note: item at index 1 is the truncated line, index 0 is prefix + self.assertEqual(self.paginator._current_page[1], 'x' * self.paginator.scale_to_size) class ImagePaginatorTests(TestCase): -- cgit v1.2.3 From 73b1720f299b517450069ce11f5b29e740301eb0 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Fri, 26 Jun 2020 04:31:28 -0400 Subject: Improve LinePaginator.__init__() ValueError message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leon Sandøy --- bot/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index cd602c715..ef1c9a176 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -60,7 +60,7 @@ class LinePaginator(Paginator): self.suffix = suffix self.max_size = max_size - len(suffix) if scale_to_size < max_size: - raise ValueError("scale_to_size must be >= max_size.") + raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") self.scale_to_size = scale_to_size - len(suffix) self.max_lines = max_lines -- cgit v1.2.3 From 1a2325754cc511b7ff500dcd74cc5703f2359927 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Fri, 26 Jun 2020 04:42:15 -0400 Subject: Add space before comment in LinePaginator._split_remaining_words() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leon Sandøy --- bot/pagination.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/pagination.py b/bot/pagination.py index ef1c9a176..632e54873 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -131,6 +131,7 @@ class LinePaginator(Paginator): """ reduced_words = [] remaining_words = [] + # "(Continued)" is used on a line by itself to indicate the continuation of last page continuation_header = "(Continued)\n-----------\n" reduced_char_count = 0 -- cgit v1.2.3 From be809454cab8343ce8df8de30689481b9c90998d Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Sat, 27 Jun 2020 21:12:32 -0400 Subject: Improve LinePaginator docstrings --- bot/pagination.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index 632e54873..71e385020 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -73,12 +73,16 @@ class LinePaginator(Paginator): """ Adds a line to the current page. - If the line exceeds `self.max_size`, then `self.max_size` will go up to `scale_to_size` for - a single line before creating a new page. If it is still exceeded, the excess characters - are stored and placed on the next pages until there are none remaining (by word boundary). + If a line on a page exceeds `max_size` characters, then `max_size` will go up to + `scale_to_size` for a single line before creating a new page for the overflow words. If it + is still exceeded, the excess characters are stored and placed on the next pages unti + there are none remaining (by word boundary). The line is truncated if `scale_to_size` is + still exceeded after attempting to continue onto the next page. - Raises a RuntimeError if `self.max_size` is still exceeded after attempting to continue - onto the next page. + In the case that the page already contains one or more lines and the new lines would cause + `max_size` to be exceeded, a new page is created. This is done in order to make a best + effort to avoid breaking up single lines across pages, but to keep the total length of the + page at a reasonable size. This function overrides the `Paginator.add_line` from inside `discord.ext.commands`. @@ -113,6 +117,12 @@ class LinePaginator(Paginator): self.add_line(remaining_words) def _new_page(self) -> None: + """ + Internal: start a new page for the paginator. + + This closes the current page and resets the counters for the new page's line count and + character count. + """ self._linecount = 0 self._count = len(self.prefix) + 1 self.close_page() -- cgit v1.2.3 From 0e223f7f197419ec99d6a00996c5d2c980c57c38 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Sat, 27 Jun 2020 21:19:49 -0400 Subject: Add block comments to LinePaginator.add_line() --- bot/pagination.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/pagination.py b/bot/pagination.py index 71e385020..441a63a7b 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -96,6 +96,7 @@ class LinePaginator(Paginator): log.debug("Could not continue to next page, truncating line.") line = line[:self.scale_to_size] + # Check if we should start a new page or continue the line on the current one if self.max_lines is not None and self._linecount >= self.max_lines: log.debug("max_lines exceeded, creating new page.") self._new_page() @@ -112,6 +113,7 @@ class LinePaginator(Paginator): self._current_page.append('') self._count += 1 + # Start a new page if there were any overflow words if remaining_words: self._new_page() self.add_line(remaining_words) -- cgit v1.2.3 From e1def9b0704674b94fbceb9f180f535a53952630 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Sat, 27 Jun 2020 21:50:05 -0400 Subject: In LinePaginator, use ellipses to show line continuation --- bot/pagination.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index 441a63a7b..34ce7317b 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -139,6 +139,10 @@ class LinePaginator(Paginator): remaining_words: the words in `line` which exceed `max_chars`. This value is None if no words could be split from `line`. + If there are any remaining_words, an ellipses is appended to reduced_words and a + continuation header is inserted before remaining_words to visually communicate the line + continuation. + Return a tuple in the format (reduced_words, remaining_words). """ reduced_words = [] @@ -164,7 +168,7 @@ class LinePaginator(Paginator): remaining_words.append(word) return ( - " ".join(reduced_words), + " ".join(reduced_words) + "..." if remaining_words else "", continuation_header + " ".join(remaining_words) if remaining_words else None ) -- cgit v1.2.3 From 7a8de415255ef0e504982bb4c74976aeeba52c71 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Sat, 27 Jun 2020 21:51:39 -0400 Subject: Remove shortening of nomination reasons * Since LinePaginator now supports long lines, there's no need to shorten the nomination reason to 200 characters. --- bot/cogs/watchchannels/talentpool.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 14547105f..33550f68e 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -224,7 +224,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): Status: **Active** Date: {start_date} Actor: {actor.mention if actor else actor_id} - Reason: {textwrap.shorten(nomination_object["reason"], width=200, placeholder="...")} + Reason: {nomination_object["reason"]} Nomination ID: `{nomination_object["id"]}` =============== """ @@ -237,10 +237,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): Status: Inactive Date: {start_date} Actor: {actor.mention if actor else actor_id} - Reason: {textwrap.shorten(nomination_object["reason"], width=200, placeholder="...")} + Reason: {nomination_object["reason"]} End date: {end_date} - Unwatch reason: {textwrap.shorten(nomination_object["end_reason"], width=200, placeholder="...")} + Unwatch reason: {nomination_object["end_reason"]} Nomination ID: `{nomination_object["id"]}` =============== """ -- cgit v1.2.3 From 145af384ededb05ad1e2e11733d3aa53495312fb Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Sun, 28 Jun 2020 00:24:54 -0400 Subject: In LinePaginator, add limit of 2000 for max_size and scale_to_size args --- bot/cogs/help.py | 2 +- bot/pagination.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 542f19139..f59d30c9a 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -299,7 +299,7 @@ class CustomHelpCommand(HelpCommand): embed, prefix=description, max_lines=COMMANDS_PER_PAGE, - max_size=2040, + max_size=2000, ) async def send_bot_help(self, mapping: dict) -> None: diff --git a/bot/pagination.py b/bot/pagination.py index 34ce7317b..97ef08ad6 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -58,10 +58,20 @@ class LinePaginator(Paginator): """ self.prefix = prefix self.suffix = suffix + + # Embeds that exceed 2048 characters will result in an HTTPException + # (Discord API limit), so we've set a limit of 2000 + if max_size > 2000: + raise ValueError(f"max_size must be <= 2,000 characters. ({max_size} > 2000)") + self.max_size = max_size - len(suffix) + if scale_to_size < max_size: raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") + if scale_to_size > 2000: + raise ValueError(f"max_size must be <= 2,000 characters. ({scale_to_size} > 2000)") + self.scale_to_size = scale_to_size - len(suffix) self.max_lines = max_lines self._current_page = [prefix] -- cgit v1.2.3 From 20872a5f93fe3734ed4a84f8e1fe3d45bebb9181 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Sun, 28 Jun 2020 00:26:53 -0400 Subject: Fix grammar in LinePaginator.add_lines() docstring --- bot/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index 97ef08ad6..b047cf5fb 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -91,7 +91,7 @@ class LinePaginator(Paginator): In the case that the page already contains one or more lines and the new lines would cause `max_size` to be exceeded, a new page is created. This is done in order to make a best - effort to avoid breaking up single lines across pages, but to keep the total length of the + effort to avoid breaking up single lines across pages, while keeping the total length of the page at a reasonable size. This function overrides the `Paginator.add_line` from inside `discord.ext.commands`. -- cgit v1.2.3 From 3fd39e84a3f8d86839ed17766a7e7b2d72ed6074 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Sun, 28 Jun 2020 00:36:51 -0400 Subject: Lower LinePaginator max_size arg in CustomHelpCommand.send_bot_help --- bot/cogs/help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index f59d30c9a..832f6ea6b 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -346,7 +346,7 @@ class CustomHelpCommand(HelpCommand): # add any remaining command help that didn't get added in the last iteration above. pages.append(page) - await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2040) + await LinePaginator.paginate(pages, self.context, embed=embed, max_lines=1, max_size=2000) class Help(Cog): -- cgit v1.2.3 From b3ba0b59940559881bc39ef39818a934753ff1c3 Mon Sep 17 00:00:00 2001 From: Kyle Stanley Date: Sun, 28 Jun 2020 01:11:10 -0400 Subject: In LinePaginator.__init__(), fix scale_to_size ValueError message Co-authored-by: Mark --- bot/pagination.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/pagination.py b/bot/pagination.py index b047cf5fb..94c2d7c0c 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -70,7 +70,7 @@ class LinePaginator(Paginator): raise ValueError(f"scale_to_size must be >= max_size. ({scale_to_size} < {max_size})") if scale_to_size > 2000: - raise ValueError(f"max_size must be <= 2,000 characters. ({scale_to_size} > 2000)") + raise ValueError(f"scale_to_size must be <= 2,000 characters. ({scale_to_size} > 2000)") self.scale_to_size = scale_to_size - len(suffix) self.max_lines = max_lines -- cgit v1.2.3 From 4fd2ff500cd889c1086334e82f695857689ae328 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 29 Jun 2020 19:11:52 -0700 Subject: Scheduler: add details to class docstring --- bot/utils/scheduling.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index cf2a1f110..fc453f19e 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -8,7 +8,17 @@ from functools import partial class Scheduler: - """Task scheduler.""" + """ + Schedule the execution of coroutines and keep track of them. + + Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` + or `schedule_later`. A unique ID is required to be given in order to keep track of the + resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing + the same ID used to schedule it. The `in` operator is supported for checking if a task with a + given ID is currently scheduled. + + Any exception raised in a scheduled task is logged when the task is done. + """ def __init__(self, name: str): self.name = name -- cgit v1.2.3 From c641f7fbbebd4c4c18539c32eb3d3907c8e71dee Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 29 Jun 2020 19:15:43 -0700 Subject: Scheduler: explain the name param in the docstring --- bot/utils/scheduling.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index fc453f19e..0987c5de8 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -11,6 +11,10 @@ class Scheduler: """ Schedule the execution of coroutines and keep track of them. + When instantiating a Scheduler, a name must be provided. This name is used to distinguish the + instance's log messages from other instances. Using the name of the class or module containing + the instance is suggested. + Coroutines can be scheduled immediately with `schedule` or in the future with `schedule_at` or `schedule_later`. A unique ID is required to be given in order to keep track of the resulting Tasks. Any scheduled task can be cancelled prematurely using `cancel` by providing -- cgit v1.2.3 From be4a61fb70c485262d36ca2aabf992f3118abcff Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 30 Jun 2020 23:09:00 +0200 Subject: Incidents: revert latest 2 commits Decision was made to use embeds to archive incidents instead of webhooking the raw message. As such, we're reverting the branch to a state from which the adjustments will be easier to make. Reverted commits: * a8d179d9b04f54b20c5e870bcfa85c78c42c8dca * 6fa8caed037b247a7c194f58a4635de7dae21fd2 --- bot/cogs/moderation/incidents.py | 33 +++--------------- tests/bot/cogs/moderation/test_incidents.py | 52 ++++------------------------- 2 files changed, 10 insertions(+), 75 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 72cc4b26c..040f2c0c8 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -41,30 +41,6 @@ ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} -def make_username(reported_by: discord.Member, actioned_by: discord.Member, max_length: int = 80) -> str: - """ - Create a webhook-friendly username from the names of `reported_by` and `actioned_by`. - - If the resulting username length exceeds `max_length`, it will be capped at `max_length - 3` - and have 3 dots appended to the end. The default value is 80, which corresponds to the limit - Discord imposes on webhook username length. - - If the value of `max_length` is < 3, ValueError is raised. - """ - if max_length < 3: - raise ValueError(f"Maximum length cannot be less than 3: {max_length=}") - - username = f"{reported_by.name} | {actioned_by.name}" - log.trace(f"Generated webhook username: {username} (length: {len(username)})") - - if len(username) > max_length: - stop = max_length - 3 - username = f"{username[:stop]}..." - log.trace(f"Username capped at {max_length=}: {username}") - - return username - - def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( @@ -172,14 +148,13 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: + async def archive(self, incident: discord.Message, outcome: Signal) -> bool: """ Relay `incident` to the #incidents-archive channel. The following pieces of information are relayed: * Incident message content (clean, pingless) - * Incident author name (as webhook username) - * Name of user who actioned the incident (appended to webhook username) + * Incident author name (as webhook author) * Incident author avatar (as webhook avatar) * Resolution signal (`outcome`) @@ -195,7 +170,7 @@ class Incidents(Cog): # Now relay the incident message: discord.Message = await webhook.send( content=incident.clean_content, # Clean content will prevent mentions from pinging - username=sub_clyde(make_username(incident.author, actioned_by)), + username=sub_clyde(incident.author.name), avatar_url=incident.author.avatar_url, wait=True, # This makes the method return the sent Message object ) @@ -260,7 +235,7 @@ class Incidents(Cog): log.debug("Reaction was valid, but no action is currently defined for it") return - relay_successful = await self.archive(incident, signal, actioned_by=member) + relay_successful = await self.archive(incident, signal) if not relay_successful: log.trace("Original message will not be deleted as we failed to relay it to the archive") return diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index a811868e5..2fc9180cf 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -68,35 +68,6 @@ mock_404 = discord.NotFound( ) -class TestMakeUsername(unittest.TestCase): - """Collection of tests for the `make_username` helper function.""" - - def test_make_username_raises(self): - """Raises `ValueError` on `max_length` < 3.""" - with self.assertRaises(ValueError): - incidents.make_username(MockMember(), MockMember(), max_length=2) - - def test_make_username_never_exceed_limit(self): - """ - The return string length is always less than or equal to `max_length`. - - For this test we pass `max_length=10` for convenience. The name of the first - user (`reported_by`) is always 1 character in length, but we generate names - for the `actioned_by` user starting at length 1 and up to length 20. - - Finally, we assert that the output length never exceeded 10 in total. - """ - user_a = MockMember(name="A") - - max_length = 10 - test_cases = (MockMember(name="B" * n) for n in range(1, 20)) - - for user_b in test_cases: - with self.subTest(user_a=user_a, user_b=user_b, max_length=max_length): - generated_username = incidents.make_username(user_a, user_b, max_length) - self.assertLessEqual(len(generated_username), max_length) - - @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ @@ -307,9 +278,7 @@ class TestArchive(TestIncidents): propagate out of the method, which is just as important. """ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) - - result = await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) - self.assertFalse(result) + self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) async def test_archive_relays_incident(self): """ @@ -334,18 +303,12 @@ class TestArchive(TestIncidents): author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, ) - - with patch("bot.cogs.moderation.incidents.make_username", MagicMock(return_value="generated_username")): - archive_return = await self.cog_instance.archive( - incident=incident, - outcome=MagicMock(value="A"), - actioned_by=MockMember(name="moderator"), - ) + archive_return = await self.cog_instance.archive(incident, outcome=MagicMock(value="A")) # Check that the webhook was dispatched correctly webhook.send.assert_called_once_with( content="pingless message", - username="generated_username", + username="author_name", avatar_url="author_avatar", wait=True, ) @@ -362,8 +325,7 @@ class TestArchive(TestIncidents): Discord will reject any webhook with "clyde" in the username field, as it impersonates the official Clyde bot. Since we do not control what the username will be (the incident - author name, and actioning moderator names are used), we must ensure the name is cleansed, - otherwise the relay may fail. + author name is used), we must ensure the name is cleansed, otherwise the relay may fail. This test assumes the username is passed as a kwarg. If this test fails, please review whether the passed argument is being retrieved correctly. @@ -371,11 +333,9 @@ class TestArchive(TestIncidents): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) - # The `make_username` helper will return a string with "clyde" in it - with patch("bot.cogs.moderation.incidents.make_username", MagicMock(return_value="clyde the great")): - await self.cog_instance.archive(MockMessage(), MagicMock(incidents.Signal), MockMember()) + message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) + await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal)) - # Assert that the "clyde" was never passed to `send` self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) -- cgit v1.2.3 From 968251660768297383401576902a71f8ac9edada Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 30 Jun 2020 23:15:02 +0200 Subject: Incidents: pass `actioned_by` to `archive` This is an important piece of information that shall be relayed. --- bot/cogs/moderation/incidents.py | 4 ++-- tests/bot/cogs/moderation/test_incidents.py | 8 +++++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 040f2c0c8..580a258fe 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -148,7 +148,7 @@ class Incidents(Cog): log.debug("Crawl task finished!") - async def archive(self, incident: discord.Message, outcome: Signal) -> bool: + async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: """ Relay `incident` to the #incidents-archive channel. @@ -235,7 +235,7 @@ class Incidents(Cog): log.debug("Reaction was valid, but no action is currently defined for it") return - relay_successful = await self.archive(incident, signal) + relay_successful = await self.archive(incident, signal, actioned_by=member) if not relay_successful: log.trace("Original message will not be deleted as we failed to relay it to the archive") return diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 2fc9180cf..c2e32fe6b 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -278,7 +278,9 @@ class TestArchive(TestIncidents): propagate out of the method, which is just as important. """ self.cog_instance.bot.fetch_webhook = AsyncMock(side_effect=mock_404) - self.assertFalse(await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock())) + self.assertFalse( + await self.cog_instance.archive(incident=MockMessage(), outcome=MagicMock(), actioned_by=MockMember()) + ) async def test_archive_relays_incident(self): """ @@ -303,7 +305,7 @@ class TestArchive(TestIncidents): author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, ) - archive_return = await self.cog_instance.archive(incident, outcome=MagicMock(value="A")) + archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) # Check that the webhook was dispatched correctly webhook.send.assert_called_once_with( @@ -334,7 +336,7 @@ class TestArchive(TestIncidents): self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) message_from_clyde = MockMessage(author=MockUser(name="clyde the great")) - await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal)) + await self.cog_instance.archive(message_from_clyde, MagicMock(incidents.Signal), MockMember()) self.assertNotIn("clyde", webhook.send.call_args.kwargs["username"]) -- cgit v1.2.3 From 7e2450bb650312ee79ac159621c4376c784a8398 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:24:00 +0000 Subject: Add base Slowmode cog --- bot/__main__.py | 1 + bot/cogs/slowmode.py | 14 ++++++++++++++ 2 files changed, 15 insertions(+) create mode 100644 bot/cogs/slowmode.py diff --git a/bot/__main__.py b/bot/__main__.py index 4e0d4a111..bbd9c9144 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -62,6 +62,7 @@ bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") +bot.load_extension("bot.cogs.slowmode") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.stats") bot.load_extension("bot.cogs.sync") diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py new file mode 100644 index 000000000..96c069ab8 --- /dev/null +++ b/bot/cogs/slowmode.py @@ -0,0 +1,14 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class Slowmode(Cog): + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + +def setup(bot: Bot) -> None: + """Load the Slowmode cog.""" + bot.add_cog(Slowmode(bot)) -- cgit v1.2.3 From 3ec5a69f8e1709aca55da3abc24cb2e632ae1ddb Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:28:05 +0000 Subject: Create boilerplate code for the commands --- bot/cogs/slowmode.py | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 96c069ab8..9140f3e8f 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -1,6 +1,9 @@ -from discord.ext.commands import Cog +from discord import TextChannel +from discord.ext.commands import Cog, Context, group from bot.bot import Bot +from bot.constants import MODERATION_ROLES +from bot.decorators import with_role class Slowmode(Cog): @@ -8,6 +11,20 @@ class Slowmode(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot + @group(name='slowmode', aliases=['sm'], invoke_without_command=True) + async def slowmode_group(self, ctx: Context) -> None: + """Get and set the slowmode delay for a given text channel.""" + await ctx.send_help(ctx.command) + + @slowmode_group.command(name='get', aliases=['g']) + async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: + """Get the slowmode delay for a given text channel.""" + + @slowmode_group.command(name='set', aliases=['s']) + @with_role(*MODERATION_ROLES) + async def set_slowmode(self, ctx: Context, channel: TextChannel, seconds: int) -> None: + """Set the slowmode delay for a given text channel.""" + def setup(bot: Bot) -> None: """Load the Slowmode cog.""" -- cgit v1.2.3 From 38bd45d97127504ac38a098d86ebc0a83723110a Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:30:00 +0000 Subject: Implement the get_slowmode function --- bot/cogs/slowmode.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 9140f3e8f..d4226acec 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -19,6 +19,8 @@ class Slowmode(Cog): @slowmode_group.command(name='get', aliases=['g']) async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Get the slowmode delay for a given text channel.""" + slowmode_delay = channel.slowmode_delay + await ctx.send(f'The slowmode delay for {channel.mention} is {slowmode_delay} seconds.') @slowmode_group.command(name='set', aliases=['s']) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 2172154c8cfe77b495e3c71716c3df339bf573b1 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:31:12 +0000 Subject: Implement the set_slowmode function --- bot/cogs/slowmode.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index d4226acec..bab6eccd0 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -2,7 +2,7 @@ from discord import TextChannel from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.constants import MODERATION_ROLES +from bot.constants import Emojis, MODERATION_ROLES from bot.decorators import with_role @@ -26,6 +26,10 @@ class Slowmode(Cog): @with_role(*MODERATION_ROLES) async def set_slowmode(self, ctx: Context, channel: TextChannel, seconds: int) -> None: """Set the slowmode delay for a given text channel.""" + await channel.edit(slowmode_delay=seconds) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' + ) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 7af6b6f52e1dff19e04bb106f27f0f2409788e10 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:37:38 +0000 Subject: Ensure slowmode delay is between 0 and 21600 seconds before setting it --- bot/cogs/slowmode.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index bab6eccd0..4a10d3fac 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -26,10 +26,16 @@ class Slowmode(Cog): @with_role(*MODERATION_ROLES) async def set_slowmode(self, ctx: Context, channel: TextChannel, seconds: int) -> None: """Set the slowmode delay for a given text channel.""" - await channel.edit(slowmode_delay=seconds) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' - ) + if 0 <= seconds <= 21600: + await channel.edit(slowmode_delay=seconds) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' + ) + + else: + await ctx.send( + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 21600 seconds.' + ) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 743f729d8ec039ef616a24eb291c8af5bec84c26 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 00:57:47 +0000 Subject: Add reset_slowmode function --- bot/cogs/slowmode.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 4a10d3fac..a4eb428e9 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -37,6 +37,15 @@ class Slowmode(Cog): f'{Emojis.cross_mark} The slowmode delay must be between 0 and 21600 seconds.' ) + @slowmode_group.command(name='reset', aliases=['r']) + @with_role(*MODERATION_ROLES) + async def reset_slowmode(self, ctx: Context, channel: TextChannel) -> None: + """Reset the slowmode delay for a given text channel to 0 seconds.""" + await channel.edit(slowmode_delay=0) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' + ) + def setup(bot: Bot) -> None: """Load the Slowmode cog.""" -- cgit v1.2.3 From 7b90754f74170d4a8db0008a9c08a690c01a7618 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 01:17:44 +0000 Subject: Create docstring for Slowmode cog --- bot/cogs/slowmode.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index a4eb428e9..a650ac395 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -7,6 +7,7 @@ from bot.decorators import with_role class Slowmode(Cog): + """Commands for getting and setting slowmode delays of text channels.""" def __init__(self, bot: Bot) -> None: self.bot = bot -- cgit v1.2.3 From 18dace4da6868f0a8aa6c64728994c68695fed95 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 01:36:25 +0000 Subject: Add some logging for the Slowmode cog --- bot/cogs/slowmode.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index a650ac395..7bbd61623 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -1,3 +1,5 @@ +import logging + from discord import TextChannel from discord.ext.commands import Cog, Context, group @@ -5,6 +7,8 @@ from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES from bot.decorators import with_role +log = logging.getLogger(__name__) + class Slowmode(Cog): """Commands for getting and setting slowmode delays of text channels.""" @@ -33,10 +37,16 @@ class Slowmode(Cog): f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' ) + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {seconds} seconds.') + else: await ctx.send( f'{Emojis.cross_mark} The slowmode delay must be between 0 and 21600 seconds.' ) + log.info( + f'{ctx.author} tried to set the slowmode delay of #{channel} to {seconds} seconds, ' + 'which is not between 0 and 21600 seconds.' + ) @slowmode_group.command(name='reset', aliases=['r']) @with_role(*MODERATION_ROLES) @@ -46,6 +56,7 @@ class Slowmode(Cog): await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' ) + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') def setup(bot: Bot) -> None: -- cgit v1.2.3 From da93dc5d2eb06eae05c6180de2bd66f3fca90c1d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 30 Jun 2020 18:41:44 -0700 Subject: Scheduler: more verbose logging in _await_later Showing the task ID in the logs makes them distinguishable from logs for other tasks. The coroutine state is logged because it may come in handy while debugging; the coroutine inspection check hasn't been proven yet in production. --- bot/utils/scheduling.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 0987c5de8..9fc519393 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -62,13 +62,13 @@ class Scheduler: """ delay = (time - datetime.utcnow()).total_seconds() if delay > 0: - coroutine = self._await_later(delay, coroutine) + coroutine = self._await_later(delay, task_id, coroutine) self.schedule(task_id, coroutine) def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" - self.schedule(task_id, self._await_later(delay, coroutine)) + self.schedule(task_id, self._await_later(delay, task_id, coroutine)) def cancel(self, task_id: t.Hashable) -> None: """Unschedule the task identified by `task_id`. Log a warning if the task doesn't exist.""" @@ -90,23 +90,26 @@ class Scheduler: for task_id in self._scheduled_tasks.copy(): self.cancel(task_id) - async def _await_later(self, delay: t.Union[int, float], coroutine: t.Coroutine) -> None: + async def _await_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: """Await `coroutine` after the given `delay` number of seconds.""" try: - self._log.trace(f"Waiting {delay} seconds before awaiting the coroutine.") + self._log.trace(f"Waiting {delay} seconds before awaiting coroutine for #{task_id}.") await asyncio.sleep(delay) # Use asyncio.shield to prevent the coroutine from cancelling itself. - self._log.trace("Done waiting; now awaiting the coroutine.") + self._log.trace(f"Done waiting for #{task_id}; now awaiting the coroutine.") await asyncio.shield(coroutine) finally: # Close it to prevent unawaited coroutine warnings, # which would happen if the task was cancelled during the sleep. # Only close it if it's not been awaited yet. This check is important because the # coroutine may cancel this task, which would also trigger the finally block. - if inspect.getcoroutinestate(coroutine) == "CORO_CREATED": - self._log.trace("Explicitly closing the coroutine.") + state = inspect.getcoroutinestate(coroutine) + if state == "CORO_CREATED": + self._log.debug(f"Explicitly closing the coroutine for #{task_id}.") coroutine.close() + else: + self._log.debug(f"Finally block reached for #{task_id}; {state=}") def _task_done_callback(self, task_id: t.Hashable, done_task: asyncio.Task) -> None: """ -- cgit v1.2.3 From dd74105d4a4433bb9e9e6fa57960a4956c0f1231 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 30 Jun 2020 23:42:32 +0200 Subject: Incidents: implement `make_embed` helper & tests See `make_embed` docstring for further information. The tests are fairly loose and should be easily adjustable in the future should changes be made. --- bot/cogs/moderation/incidents.py | 32 ++++++++++++++++++++++++++++- tests/bot/cogs/moderation/test_incidents.py | 26 +++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 580a258fe..ca591fc6e 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -1,13 +1,14 @@ import asyncio import logging import typing as t +from datetime import datetime from enum import Enum import discord from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels, Emojis, Roles, Webhooks +from bot.constants import Channels, Colours, Emojis, Roles, Webhooks from bot.utils.messages import sub_clyde log = logging.getLogger(__name__) @@ -41,6 +42,35 @@ ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> discord.Embed: + """ + Create an embed representation of `incident` for the #incidents-archive channel. + + The name & discriminator of `actioned_by` and `outcome` will be presented in the + embed footer. Additionally, the embed is coloured based on `outcome`. + + The author of `incident` is not shown in the embed. It is assumed that this piece + of information will be relayed in other ways, e.g. webhook username. + + As mentions in embeds do not ping, we do not need to use `incident.clean_content`. + """ + if outcome is Signal.ACTIONED: + colour = Colours.soft_green + footer = f"Actioned by {actioned_by}" + else: + colour = Colours.soft_red + footer = f"Rejected by {actioned_by}" + + embed = discord.Embed( + description=incident.content, + timestamp=datetime.utcnow(), + colour=colour, + ) + embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) + + return embed + + def is_incident(message: discord.Message) -> bool: """True if `message` qualifies as an incident, False otherwise.""" conditions = ( diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index c2e32fe6b..4731a786d 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -9,6 +9,7 @@ import aiohttp import discord from bot.cogs.moderation import Incidents, incidents +from bot.constants import Colours from tests.helpers import ( MockAsyncWebhook, MockBot, @@ -68,6 +69,31 @@ mock_404 = discord.NotFound( ) +class TestMakeEmbed(unittest.TestCase): + """Collection of tests for the `make_embed` helper function.""" + + def test_make_embed_actioned(self): + """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" + embed = incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual(embed.colour.value, Colours.soft_green) + self.assertIn("Actioned", embed.footer.text) + + def test_make_embed_not_actioned(self): + """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" + embed = incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + + self.assertEqual(embed.colour.value, Colours.soft_red) + self.assertIn("Rejected", embed.footer.text) + + def test_make_embed_content(self): + """Incident content appears as embed description.""" + incident = MockMessage(content="this is an incident") + embed = incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertEqual(incident.content, embed.description) + + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): """ -- cgit v1.2.3 From 744aed585162cb0547e61a538734f116459ab510 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 1 Jul 2020 16:52:58 +0200 Subject: Incidents: relay incidents as embeds rather than raw content This applies the previously defined `make_embed` function. As the `archive` function is now simpler, I decided to reduce the amount of whitespace ~ it's a lot more compact now. Tests are adjusted as appropriate. --- bot/cogs/moderation/incidents.py | 24 ++++++++-------------- tests/bot/cogs/moderation/test_incidents.py | 32 ++++++++++------------------- 2 files changed, 19 insertions(+), 37 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index ca591fc6e..3a1a3d84e 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -180,38 +180,30 @@ class Incidents(Cog): async def archive(self, incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> bool: """ - Relay `incident` to the #incidents-archive channel. + Relay an embed representation of `incident` to the #incidents-archive channel. The following pieces of information are relayed: - * Incident message content (clean, pingless) + * Incident message content (as embed description) * Incident author name (as webhook author) * Incident author avatar (as webhook avatar) - * Resolution signal (`outcome`) + * Resolution signal `outcome` (as embed colour & footer) + * Moderator `actioned_by` (name & discriminator shown in footer) Return True if the relay finishes successfully. If anything goes wrong, meaning not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ - log.debug(f"Archiving incident: {incident.id} with outcome: {outcome}") + log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") try: - # First we try to grab the webhook - webhook: discord.Webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) - - # Now relay the incident - message: discord.Message = await webhook.send( - content=incident.clean_content, # Clean content will prevent mentions from pinging + webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) + await webhook.send( + embed=make_embed(incident, outcome, actioned_by), username=sub_clyde(incident.author.name), avatar_url=incident.author.avatar_url, - wait=True, # This makes the method return the sent Message object ) - - # Finally add the `outcome` emoji - await message.add_reaction(outcome.value) - except Exception: log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") return False - else: log.trace("Message archived successfully!") return True diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 4731a786d..70dfe6b5f 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -312,39 +312,29 @@ class TestArchive(TestIncidents): """ If webhook is found, method relays `incident` properly. - This test will assert the following: - * The fetched webhook's `send` method is fed the correct arguments - * The message returned by `send` will have `outcome` reaction added - * Finally, the `archive` method returns True - - Assertions are made specifically in this order. + This test will assert that the fetched webhook's `send` method is fed the correct arguments, + and that the `archive` method returns True. """ - webhook_message = MockMessage() # The message that will be returned by the webhook's `send` method - webhook = MockAsyncWebhook(send=AsyncMock(return_value=webhook_message)) - + webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook - # Now we'll pas our own `incident` to `archive` and capture the return value + # Define our own `incident` for archivation incident = MockMessage( - clean_content="pingless message", - content="pingful message", + content="this is an incident", author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, ) - archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) + built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this - # Check that the webhook was dispatched correctly + with patch("bot.cogs.moderation.incidents.make_embed", MagicMock(return_value=built_embed)): + archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) + + # Now we check that the webhook was given the correct args, and that `archive` returned True webhook.send.assert_called_once_with( - content="pingless message", + embed=built_embed, username="author_name", avatar_url="author_avatar", - wait=True, ) - - # Now check that the correct emoji was added to the relayed message - webhook_message.add_reaction.assert_called_once_with("A") - - # Finally check that the method returned True self.assertTrue(archive_return) async def test_archive_clyde_username(self): -- cgit v1.2.3 From bd041ef4363ad8750d619d97fb7e8f3a4c6ae757 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 16:37:48 +0000 Subject: Create DurationDelta converter and humanize timedelta output for Slowmode cog. The DurationDelta converter will allow the Slowmode cog to use a formatted timestamp instead of an integer representing seconds. I created a new converter because the Duration converter returned a datetime.datetime object, instead of a time delta. Joe mentioned that I could just subtract the datetime.datetime object from datetime.utcnow(), but there is a small delay between conversion and when the function is actually executed. This caused something like `!slowmode set #python-general 5s` to set the slowmode delay to 4 seconds instead of 5. Now, with this new converter, the set command can be invoked using a formatted timestamp like so: `!slowmode set #python-general 4h23M19s`. This would set the slowmode delay in #python-general to 4 hours, 23 minutes, and 19 seconds. Of course that delay would be quite overkill for #python-general, but that's just for the sake of this example. --- bot/cogs/slowmode.py | 31 +++++++++++++++++++++---------- bot/converters.py | 22 ++++++++++++++++++---- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 7bbd61623..898f4bf52 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -1,11 +1,15 @@ import logging +from datetime import datetime +from dateutil.relativedelta import relativedelta from discord import TextChannel from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES +from bot.converters import DurationDelta from bot.decorators import with_role +from bot.utils import time log = logging.getLogger(__name__) @@ -24,28 +28,35 @@ class Slowmode(Cog): @slowmode_group.command(name='get', aliases=['g']) async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Get the slowmode delay for a given text channel.""" - slowmode_delay = channel.slowmode_delay - await ctx.send(f'The slowmode delay for {channel.mention} is {slowmode_delay} seconds.') + delay = relativedelta(seconds=channel.slowmode_delay) + await ctx.send(f'The slowmode delay for {channel.mention} is {time.humanize_delta(delay, precision=3)}.') @slowmode_group.command(name='set', aliases=['s']) @with_role(*MODERATION_ROLES) - async def set_slowmode(self, ctx: Context, channel: TextChannel, seconds: int) -> None: + async def set_slowmode(self, ctx: Context, channel: TextChannel, delay: DurationDelta) -> None: """Set the slowmode delay for a given text channel.""" - if 0 <= seconds <= 21600: - await channel.edit(slowmode_delay=seconds) + # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` + # Must do this to get the delta in a particular unit of time + utcnow = datetime.utcnow() + slowmode_delay = (utcnow + delay - utcnow).seconds + + humanized_delay = time.humanize_delta(delay, precision=3) + + if 0 <= slowmode_delay <= 21600: + await channel.edit(slowmode_delay=slowmode_delay) await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {seconds} seconds.' + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' ) - log.info(f'{ctx.author} set the slowmode delay for #{channel} to {seconds} seconds.') + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') else: await ctx.send( - f'{Emojis.cross_mark} The slowmode delay must be between 0 and 21600 seconds.' + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' ) log.info( - f'{ctx.author} tried to set the slowmode delay of #{channel} to {seconds} seconds, ' - 'which is not between 0 and 21600 seconds.' + f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' + 'which is not between 0 and 6 hours.' ) @slowmode_group.command(name='reset', aliases=['r']) diff --git a/bot/converters.py b/bot/converters.py index 4deb59f87..65963f513 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -181,8 +181,8 @@ class TagContentConverter(Converter): return tag_content -class Duration(Converter): - """Convert duration strings into UTC datetime.datetime objects.""" +class DurationDelta(Converter): + """Convert duration strings into dateutil.relativedelta.relativedelta objects.""" duration_parser = re.compile( r"((?P\d+?) ?(years|year|Y|y) ?)?" @@ -194,9 +194,9 @@ class Duration(Converter): r"((?P\d+?) ?(seconds|second|S|s))?" ) - async def convert(self, ctx: Context, duration: str) -> datetime: + async def convert(self, ctx: Context, duration: str) -> relativedelta: """ - Converts a `duration` string to a datetime object that's `duration` in the future. + Converts a `duration` string to a relativedelta object. The converter supports the following symbols for each unit of time: - years: `Y`, `y`, `year`, `years` @@ -215,6 +215,20 @@ class Duration(Converter): duration_dict = {unit: int(amount) for unit, amount in match.groupdict(default=0).items()} delta = relativedelta(**duration_dict) + + return delta + + +class Duration(DurationDelta): + """Convert duration strings into UTC datetime.datetime objects.""" + + async def convert(self, ctx: Context, duration: str) -> datetime: + """ + Converts a `duration` string to a datetime object that's `duration` in the future. + + The converter supports the same symbols for each unit of time as its parent class. + """ + delta = super().convert(ctx, duration) now = datetime.utcnow() try: -- cgit v1.2.3 From 7eb3a5a7c1a38ad56f1e9584a24f2da9f00d0a40 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 17:03:02 +0000 Subject: Forgot an await in the Duration converter --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 65963f513..898822165 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -228,7 +228,7 @@ class Duration(DurationDelta): The converter supports the same symbols for each unit of time as its parent class. """ - delta = super().convert(ctx, duration) + delta = await super().convert(ctx, duration) now = datetime.utcnow() try: -- cgit v1.2.3 From 933a154ccbb83c4ee5ad1fa87e1bea9d8c012f27 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 17:14:46 +0000 Subject: Catch TypeError when the slowmode delay is 0 seconds --- bot/cogs/slowmode.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 898f4bf52..b8b3bb65c 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -29,7 +29,15 @@ class Slowmode(Cog): async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Get the slowmode delay for a given text channel.""" delay = relativedelta(seconds=channel.slowmode_delay) - await ctx.send(f'The slowmode delay for {channel.mention} is {time.humanize_delta(delay, precision=3)}.') + + try: + humanized_delay = time.humanize_delta(delay, precision=3) + + except TypeError: + humanized_delay = '0 seconds' + + finally: + await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') @slowmode_group.command(name='set', aliases=['s']) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 1906cf7caaf580f37a0d689713d5252d1649f4ec Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 17:17:00 +0000 Subject: Add comment explaining TypeError --- bot/cogs/slowmode.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index b8b3bb65c..7e1bee61d 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -34,6 +34,8 @@ class Slowmode(Cog): humanized_delay = time.humanize_delta(delay, precision=3) except TypeError: + # The slowmode delay is 0 seconds, + # which causes `time.humanize_delta` to raise a TypeError humanized_delay = '0 seconds' finally: -- cgit v1.2.3 From c8bcaff2b7bc5b7a66c0307650d6f72b65eac659 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Wed, 1 Jul 2020 18:06:08 +0000 Subject: Use total_seconds method instead of seconds attribute --- bot/cogs/slowmode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 7e1bee61d..c2ca97a7f 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -48,7 +48,7 @@ class Slowmode(Cog): # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` # Must do this to get the delta in a particular unit of time utcnow = datetime.utcnow() - slowmode_delay = (utcnow + delay - utcnow).seconds + slowmode_delay = (utcnow + delay - utcnow).total_seconds() humanized_delay = time.humanize_delta(delay, precision=3) -- cgit v1.2.3 From d2732ce299cf2071b92fdf0c1eecbb0f16f0afbd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 2 Jul 2020 15:52:05 +0200 Subject: Incidents: trace-level log incident embed creation --- bot/cogs/moderation/incidents.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 3a1a3d84e..8970c2c5c 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -54,6 +54,8 @@ def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord. As mentions in embeds do not ping, we do not need to use `incident.clean_content`. """ + log.trace(f"Creating embed for {incident.id=}") + if outcome is Signal.ACTIONED: colour = Colours.soft_green footer = f"Actioned by {actioned_by}" -- cgit v1.2.3 From 83544ca0f91dd7bc8510e4fc7a64bc73712ddaf8 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Fri, 3 Jul 2020 10:47:47 +0200 Subject: Incidents: archive incident attachments There is no handling of file types as explained in the `archive` docstring. Testing indicates that relaying incidents with e.g. a text file attachment is simply a noop in the Discord GUI. If there is at least one attachment, we always only relay the one at index 0, as it is believed the user-sent messages can only contain one attachment at maximum. This also adds an extra test asserting the behaviour when an incident with an attachment is archived. The existing test for `archive` is adjusted to assume no attachments. Joe helped me conceive & test this. Co-authored-by: Joseph Banks --- bot/cogs/moderation/incidents.py | 21 +++++++++++++++++++- tests/bot/cogs/moderation/test_incidents.py | 30 +++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 8970c2c5c..1a12c8bbd 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -186,22 +186,41 @@ class Incidents(Cog): The following pieces of information are relayed: * Incident message content (as embed description) + * Incident attachment (if image, shown in archive embed) * Incident author name (as webhook author) * Incident author avatar (as webhook avatar) * Resolution signal `outcome` (as embed colour & footer) * Moderator `actioned_by` (name & discriminator shown in footer) + If `incident` contains an attachment, we try to add it to the archive embed. There is + no handing of extensions / file types - we simply dispatch the attachment file with the + webhook, and try to display it in the embed. Testing indicates that if the attachment + cannot be displayed (e.g. a text file), it's invisible in the embed, with no error. + Return True if the relay finishes successfully. If anything goes wrong, meaning not all information was relayed, return False. This signals that the original message is not safe to be deleted, as we will lose some information. """ log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") + embed = make_embed(incident, outcome, actioned_by) + + # If the incident had an attachment, we will try to relay it + if incident.attachments: + attachment = incident.attachments[0] # User-sent messages can only contain one attachment + log.debug(f"Attempting to archive incident attachment: {attachment.filename}") + + attachment_file = await attachment.to_file() # The file will be sent with the webhook + embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file + else: + attachment_file = None + try: webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) await webhook.send( - embed=make_embed(incident, outcome, actioned_by), + embed=embed, username=sub_clyde(incident.author.name), avatar_url=incident.author.avatar_url, + file=attachment_file, ) except Exception: log.exception(f"Failed to archive incident {incident.id} to #incidents-archive") diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 70dfe6b5f..f8d479cef 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -323,6 +323,7 @@ class TestArchive(TestIncidents): content="this is an incident", author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, + attachments=[], # This incident has no attachments ) built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this @@ -334,9 +335,38 @@ class TestArchive(TestIncidents): embed=built_embed, username="author_name", avatar_url="author_avatar", + file=None, ) self.assertTrue(archive_return) + async def test_archive_relays_incident_with_attachments(self): + """ + Incident attachments are relayed and displayed in the embed. + + This test asserts the two things that need to happen in order to relay the attachment. + The embed returned by `make_embed` must have the `set_image` method called with the + attachment's filename, and the file must be passed to the webhook's send method. + """ + attachment_file = MagicMock(discord.File) + attachment = MagicMock( + discord.Attachment, + filename="abc.png", + to_file=AsyncMock(return_value=attachment_file), + ) + incident = MockMessage( + attachments=[attachment], + ) + built_embed = MagicMock(discord.Embed) + + with patch("bot.cogs.moderation.incidents.make_embed", MagicMock(return_value=built_embed)): + await self.cog_instance.archive(incident, incidents.Signal.ACTIONED, actioned_by=MockMember()) + + built_embed.set_image.assert_called_once_with(url="attachment://abc.png") + + send_kwargs = self.cog_instance.bot.fetch_webhook.return_value.send.call_args.kwargs + self.assertIn("file", send_kwargs) + self.assertIs(send_kwargs["file"], attachment_file) + async def test_archive_clyde_username(self): """ The archive webhook username is cleansed using `sub_clyde`. -- cgit v1.2.3 From a92bbddd092d4779a922f7d02b945ff3cb835350 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 3 Jul 2020 14:28:54 +0100 Subject: Outdated badge in README upset me --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e7b21271..cae7c3454 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Python Utility Bot -[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E30k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) +[![Discord](https://img.shields.io/static/v1?label=Python%20Discord&logo=discord&message=%3E60k%20members&color=%237289DA&logoColor=white)](https://discord.gg/2B963hn) [![Build Status](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master)](https://dev.azure.com/python-discord/Python%20Discord/_build/latest?definitionId=1&branchName=master) [![Tests](https://img.shields.io/azure-devops/tests/python-discord/Python%20Discord/1?compact_message)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) [![Coverage](https://img.shields.io/azure-devops/coverage/python-discord/Python%20Discord/1/master)](https://dev.azure.com/python-discord/Python%20Discord/_apis/build/status/Bot?branchName=master) -- cgit v1.2.3 From e7be2215dc0c800655c9985d655d5d6d687932f0 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Fri, 3 Jul 2020 15:51:41 +0000 Subject: Remove precision kwarg usage --- bot/cogs/slowmode.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index c2ca97a7f..9f69d30e0 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -30,16 +30,13 @@ class Slowmode(Cog): """Get the slowmode delay for a given text channel.""" delay = relativedelta(seconds=channel.slowmode_delay) - try: - humanized_delay = time.humanize_delta(delay, precision=3) - - except TypeError: - # The slowmode delay is 0 seconds, - # which causes `time.humanize_delta` to raise a TypeError + # Say "0 seconds" instead of "less than a second" + if channel.slowmode_delay == 0: humanized_delay = '0 seconds' + else: + humanized_delay = time.humanize_delta(delay) - finally: - await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') + await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') @slowmode_group.command(name='set', aliases=['s']) @with_role(*MODERATION_ROLES) @@ -50,7 +47,7 @@ class Slowmode(Cog): utcnow = datetime.utcnow() slowmode_delay = (utcnow + delay - utcnow).total_seconds() - humanized_delay = time.humanize_delta(delay, precision=3) + humanized_delay = time.humanize_delta(delay) if 0 <= slowmode_delay <= 21600: await channel.edit(slowmode_delay=slowmode_delay) -- cgit v1.2.3 From 5cfad8c592388bfff4152a684e10f7d8a04e6426 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Fri, 3 Jul 2020 15:53:31 +0000 Subject: Move log to before what it's logging executes. This makes sure the log will be made, since the operations executed are now below it. --- bot/cogs/slowmode.py | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 9f69d30e0..593208bea 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -50,31 +50,33 @@ class Slowmode(Cog): humanized_delay = time.humanize_delta(delay) if 0 <= slowmode_delay <= 21600: + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') + await channel.edit(slowmode_delay=slowmode_delay) await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' ) - log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') - else: - await ctx.send( - f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' - ) log.info( f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' 'which is not between 0 and 6 hours.' ) + await ctx.send( + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' + ) + @slowmode_group.command(name='reset', aliases=['r']) @with_role(*MODERATION_ROLES) async def reset_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Reset the slowmode delay for a given text channel to 0 seconds.""" + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') + await channel.edit(slowmode_delay=0) await ctx.send( f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' ) - log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') def setup(bot: Bot) -> None: -- cgit v1.2.3 From 7f430c7ca99030c31c019093019139caa6d81d9c Mon Sep 17 00:00:00 2001 From: Den4200 Date: Fri, 3 Jul 2020 22:55:19 +0000 Subject: Only allow moderators to use the entire cog --- bot/cogs/slowmode.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 593208bea..ec5e9cc0d 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -8,7 +8,7 @@ from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.constants import Emojis, MODERATION_ROLES from bot.converters import DurationDelta -from bot.decorators import with_role +from bot.decorators import with_role_check from bot.utils import time log = logging.getLogger(__name__) @@ -39,7 +39,6 @@ class Slowmode(Cog): await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') @slowmode_group.command(name='set', aliases=['s']) - @with_role(*MODERATION_ROLES) async def set_slowmode(self, ctx: Context, channel: TextChannel, delay: DurationDelta) -> None: """Set the slowmode delay for a given text channel.""" # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` @@ -68,7 +67,6 @@ class Slowmode(Cog): ) @slowmode_group.command(name='reset', aliases=['r']) - @with_role(*MODERATION_ROLES) async def reset_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Reset the slowmode delay for a given text channel to 0 seconds.""" log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') @@ -78,6 +76,10 @@ class Slowmode(Cog): f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' ) + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) + def setup(bot: Bot) -> None: """Load the Slowmode cog.""" -- cgit v1.2.3 From c4c4dfa698321912eb15ff3c1d77d1170968d124 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 00:29:53 +0000 Subject: Create a constant for the max slowmode delay --- bot/cogs/slowmode.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index ec5e9cc0d..830273174 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -13,6 +13,8 @@ from bot.utils import time log = logging.getLogger(__name__) +SLOWMODE_MAX_DELAY = 21600 # seconds + class Slowmode(Cog): """Commands for getting and setting slowmode delays of text channels.""" @@ -48,7 +50,8 @@ class Slowmode(Cog): humanized_delay = time.humanize_delta(delay) - if 0 <= slowmode_delay <= 21600: + # Ensure the delay is within discord's limits + if slowmode_delay <= SLOWMODE_MAX_DELAY: log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') await channel.edit(slowmode_delay=slowmode_delay) -- cgit v1.2.3 From 9804e84cdf5903c3aac3783a66b81e5865680c62 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 00:52:41 +0000 Subject: Remove monkeypatch and apply appropriate changes to _stringify_time_unit --- bot/cogs/slowmode.py | 7 +------ bot/utils/time.py | 4 +++- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 830273174..88f19b2f1 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -31,12 +31,7 @@ class Slowmode(Cog): async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: """Get the slowmode delay for a given text channel.""" delay = relativedelta(seconds=channel.slowmode_delay) - - # Say "0 seconds" instead of "less than a second" - if channel.slowmode_delay == 0: - humanized_delay = '0 seconds' - else: - humanized_delay = time.humanize_delta(delay) + humanized_delay = time.humanize_delta(delay) await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') diff --git a/bot/utils/time.py b/bot/utils/time.py index 77060143c..47e49904b 100644 --- a/bot/utils/time.py +++ b/bot/utils/time.py @@ -20,7 +20,9 @@ def _stringify_time_unit(value: int, unit: str) -> str: >>> _stringify_time_unit(0, "minutes") "less than a minute" """ - if value == 1: + if unit == "seconds" and value == 0: + return "0 seconds" + elif value == 1: return f"{value} {unit[:-1]}" elif value == 0: return f"less than a {unit[:-1]}" -- cgit v1.2.3 From 539030a1c2a79efe23541704f0026a072ba064ed Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 00:59:15 +0000 Subject: Default to the channel that `slowmode get` was invoked in --- bot/cogs/slowmode.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 88f19b2f1..7405c1e7f 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -1,5 +1,6 @@ import logging from datetime import datetime +from typing import Optional from dateutil.relativedelta import relativedelta from discord import TextChannel @@ -28,8 +29,12 @@ class Slowmode(Cog): await ctx.send_help(ctx.command) @slowmode_group.command(name='get', aliases=['g']) - async def get_slowmode(self, ctx: Context, channel: TextChannel) -> None: + async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel] = None) -> None: """Get the slowmode delay for a given text channel.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + delay = relativedelta(seconds=channel.slowmode_delay) humanized_delay = time.humanize_delta(delay) -- cgit v1.2.3 From 758568f2d39212737f15b871850597185b254fcd Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 01:02:23 +0000 Subject: Default to the channel that `slowmode reset` was invoked in --- bot/cogs/slowmode.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 7405c1e7f..0b9b64976 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -70,8 +70,12 @@ class Slowmode(Cog): ) @slowmode_group.command(name='reset', aliases=['r']) - async def reset_slowmode(self, ctx: Context, channel: TextChannel) -> None: + async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel] = None) -> None: """Reset the slowmode delay for a given text channel to 0 seconds.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') await channel.edit(slowmode_delay=0) -- cgit v1.2.3 From b04c4163f97bb3c811096587ed1db51d9754114b Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 01:41:21 +0000 Subject: Default to the channel that `slowmode set` was invoked in --- bot/cogs/slowmode.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 0b9b64976..93ddf4b19 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -41,8 +41,12 @@ class Slowmode(Cog): await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') @slowmode_group.command(name='set', aliases=['s']) - async def set_slowmode(self, ctx: Context, channel: TextChannel, delay: DurationDelta) -> None: + async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: """Set the slowmode delay for a given text channel.""" + # Use the channel this command was invoked in if one was not given + if not channel: + channel = ctx.channel + # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` # Must do this to get the delta in a particular unit of time utcnow = datetime.utcnow() -- cgit v1.2.3 From 76e8eaea958029fa11849624c0eb9edcfe248529 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 01:48:23 +0000 Subject: Make channel comparison against None consistent --- bot/cogs/slowmode.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 93ddf4b19..ecbc235a0 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -44,7 +44,7 @@ class Slowmode(Cog): async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: """Set the slowmode delay for a given text channel.""" # Use the channel this command was invoked in if one was not given - if not channel: + if channel is None: channel = ctx.channel # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` -- cgit v1.2.3 From 7c4f6db3f7291612862f6f16cddc73f7add72fd0 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 01:50:08 +0000 Subject: Remove unneeded kwargs for `typing.Optional` to keep consistency --- bot/cogs/slowmode.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index ecbc235a0..1e83065ab 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -29,7 +29,7 @@ class Slowmode(Cog): await ctx.send_help(ctx.command) @slowmode_group.command(name='get', aliases=['g']) - async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel] = None) -> None: + async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: """Get the slowmode delay for a given text channel.""" # Use the channel this command was invoked in if one was not given if channel is None: @@ -74,7 +74,7 @@ class Slowmode(Cog): ) @slowmode_group.command(name='reset', aliases=['r']) - async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel] = None) -> None: + async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: """Reset the slowmode delay for a given text channel to 0 seconds.""" # Use the channel this command was invoked in if one was not given if channel is None: -- cgit v1.2.3 From f31babf54ef1e4d2d2966bf8b695b1e4a01848e0 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 02:04:30 +0000 Subject: Update the docstrings to account for optional channel parameter --- bot/cogs/slowmode.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py index 1e83065ab..1d055afac 100644 --- a/bot/cogs/slowmode.py +++ b/bot/cogs/slowmode.py @@ -25,12 +25,12 @@ class Slowmode(Cog): @group(name='slowmode', aliases=['sm'], invoke_without_command=True) async def slowmode_group(self, ctx: Context) -> None: - """Get and set the slowmode delay for a given text channel.""" + """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" await ctx.send_help(ctx.command) @slowmode_group.command(name='get', aliases=['g']) async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Get the slowmode delay for a given text channel.""" + """Get the slowmode delay for a text channel.""" # Use the channel this command was invoked in if one was not given if channel is None: channel = ctx.channel @@ -42,7 +42,7 @@ class Slowmode(Cog): @slowmode_group.command(name='set', aliases=['s']) async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: - """Set the slowmode delay for a given text channel.""" + """Set the slowmode delay for a text channel.""" # Use the channel this command was invoked in if one was not given if channel is None: channel = ctx.channel @@ -75,7 +75,7 @@ class Slowmode(Cog): @slowmode_group.command(name='reset', aliases=['r']) async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Reset the slowmode delay for a given text channel to 0 seconds.""" + """Reset the slowmode delay for a text channel to 0 seconds.""" # Use the channel this command was invoked in if one was not given if channel is None: channel = ctx.channel -- cgit v1.2.3 From 40719793f9c0d8a2c5761d3730b5920a146709c3 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 04:08:27 +0000 Subject: Add tests for cog_check and get_slowmode --- tests/bot/cogs/test_slowmode.py | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 tests/bot/cogs/test_slowmode.py diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py new file mode 100644 index 000000000..fb9f3c9ad --- /dev/null +++ b/tests/bot/cogs/test_slowmode.py @@ -0,0 +1,37 @@ +import unittest +from unittest import mock + +from bot.cogs.slowmode import Slowmode +from tests.helpers import MockBot, MockContext, MockTextChannel + + +class SlowmodeTests(unittest.IsolatedAsyncioTestCase): + + def setUp(self) -> None: + self.bot = MockBot() + self.cog = Slowmode(self.bot) + self.text_channel = MockTextChannel() + self.ctx = MockContext(channel=self.text_channel) + + async def test_get_slowmode_no_channel(self) -> None: + """Get slowmode without a given channel""" + self.text_channel.mention = '#python-general' + self.text_channel.slowmode_delay = 5 + + await self.cog.get_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") + + async def test_get_slowmode_with_channel(self) -> None: + """Get slowmode without a given channel""" + self.text_channel.mention = '#python-language' + self.text_channel.slowmode_delay = 2 + + await self.cog.get_slowmode(self.cog, self.ctx, self.text_channel) + self.ctx.send.assert_called_once_with("The slowmode delay for #python-language is 2 seconds.") + + @mock.patch("bot.cogs.slowmode.with_role_check") + @mock.patch("bot.cogs.slowmode.MODERATION_ROLES", new=(1, 2, 3)) + def test_cog_check(self, role_check): + """Role check is called with `MODERATION_ROLES`""" + self.cog.cog_check(self.ctx) + role_check.assert_called_once_with(self.ctx, *(1, 2, 3)) -- cgit v1.2.3 From e760b4312a5264fe9442cb1d53c9e357dbeb2b81 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 04:55:42 +0000 Subject: Add tests for reset_slowmode --- tests/bot/cogs/test_slowmode.py | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index fb9f3c9ad..a2e5ad346 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -2,6 +2,7 @@ import unittest from unittest import mock from bot.cogs.slowmode import Slowmode +from bot.constants import Emojis from tests.helpers import MockBot, MockContext, MockTextChannel @@ -14,7 +15,7 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): self.ctx = MockContext(channel=self.text_channel) async def test_get_slowmode_no_channel(self) -> None: - """Get slowmode without a given channel""" + """Get slowmode without a given channel.""" self.text_channel.mention = '#python-general' self.text_channel.slowmode_delay = 5 @@ -22,12 +23,30 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") async def test_get_slowmode_with_channel(self) -> None: - """Get slowmode without a given channel""" + """Get slowmode with a given channel.""" self.text_channel.mention = '#python-language' self.text_channel.slowmode_delay = 2 await self.cog.get_slowmode(self.cog, self.ctx, self.text_channel) - self.ctx.send.assert_called_once_with("The slowmode delay for #python-language is 2 seconds.") + self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') + + async def test_reset_slowmode_no_channel(self) -> None: + """Reset slowmode without a given channel.""" + self.text_channel.mention = '#careers' + + await self.cog.reset_slowmode(self.cog, self.ctx, None) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #careers has been reset to 0 seconds.' + ) + + async def test_reset_slowmode_with_channel(self) -> None: + """Reset slowmode with a given channel.""" + self.text_channel.mention = '#meta' + + await self.cog.reset_slowmode(self.cog, self.ctx, self.text_channel) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' + ) @mock.patch("bot.cogs.slowmode.with_role_check") @mock.patch("bot.cogs.slowmode.MODERATION_ROLES", new=(1, 2, 3)) -- cgit v1.2.3 From 8613659cb191bedca925dc798c89623b49c9a90a Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 05:45:04 +0000 Subject: Add tests for set_slowmode --- tests/bot/cogs/test_slowmode.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index a2e5ad346..5262ce34a 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -1,6 +1,8 @@ import unittest from unittest import mock +from dateutil.relativedelta import relativedelta + from bot.cogs.slowmode import Slowmode from bot.constants import Emojis from tests.helpers import MockBot, MockContext, MockTextChannel @@ -30,6 +32,24 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): await self.cog.get_slowmode(self.cog, self.ctx, self.text_channel) self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') + async def test_set_slowmode_no_channel(self) -> None: + """Set slowmode without a given channel.""" + self.text_channel.mention = '#careers' + + await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=3)) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #careers is now 3 seconds.' + ) + + async def test_set_slowmode_with_channel(self) -> None: + """Set slowmode with a given channel.""" + self.text_channel.mention = '#meta' + + await self.cog.set_slowmode(self.cog, self.ctx, self.text_channel, relativedelta(seconds=4)) + self.ctx.send.assert_called_once_with( + f'{Emojis.check_mark} The slowmode delay for #meta is now 4 seconds.' + ) + async def test_reset_slowmode_no_channel(self) -> None: """Reset slowmode without a given channel.""" self.text_channel.mention = '#careers' -- cgit v1.2.3 From 4935ed5ae632f5887bcff23ac67c781eab8527e9 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 06:05:32 +0000 Subject: Use local text_channel instead of instance attribute --- tests/bot/cogs/test_slowmode.py | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index 5262ce34a..663c9fd43 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -13,28 +13,25 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): def setUp(self) -> None: self.bot = MockBot() self.cog = Slowmode(self.bot) - self.text_channel = MockTextChannel() - self.ctx = MockContext(channel=self.text_channel) + self.ctx = MockContext() async def test_get_slowmode_no_channel(self) -> None: """Get slowmode without a given channel.""" - self.text_channel.mention = '#python-general' - self.text_channel.slowmode_delay = 5 + self.ctx.channel = MockTextChannel(name='python-general', slowmode_delay=5) await self.cog.get_slowmode(self.cog, self.ctx, None) self.ctx.send.assert_called_once_with("The slowmode delay for #python-general is 5 seconds.") async def test_get_slowmode_with_channel(self) -> None: """Get slowmode with a given channel.""" - self.text_channel.mention = '#python-language' - self.text_channel.slowmode_delay = 2 + text_channel = MockTextChannel(name='python-language', slowmode_delay=2) - await self.cog.get_slowmode(self.cog, self.ctx, self.text_channel) + await self.cog.get_slowmode(self.cog, self.ctx, text_channel) self.ctx.send.assert_called_once_with('The slowmode delay for #python-language is 2 seconds.') async def test_set_slowmode_no_channel(self) -> None: """Set slowmode without a given channel.""" - self.text_channel.mention = '#careers' + self.ctx.channel = MockTextChannel(name='careers') await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=3)) self.ctx.send.assert_called_once_with( @@ -43,16 +40,16 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_set_slowmode_with_channel(self) -> None: """Set slowmode with a given channel.""" - self.text_channel.mention = '#meta' + text_channel = MockTextChannel(name='meta') - await self.cog.set_slowmode(self.cog, self.ctx, self.text_channel, relativedelta(seconds=4)) + await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=4)) self.ctx.send.assert_called_once_with( f'{Emojis.check_mark} The slowmode delay for #meta is now 4 seconds.' ) async def test_reset_slowmode_no_channel(self) -> None: """Reset slowmode without a given channel.""" - self.text_channel.mention = '#careers' + self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) await self.cog.reset_slowmode(self.cog, self.ctx, None) self.ctx.send.assert_called_once_with( @@ -61,9 +58,9 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_reset_slowmode_with_channel(self) -> None: """Reset slowmode with a given channel.""" - self.text_channel.mention = '#meta' + text_channel = MockTextChannel(name='meta', slowmode_delay=1) - await self.cog.reset_slowmode(self.cog, self.ctx, self.text_channel) + await self.cog.reset_slowmode(self.cog, self.ctx, text_channel) self.ctx.send.assert_called_once_with( f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' ) -- cgit v1.2.3 From 77a2e514dd2e200e23ccf45760677c2e7c40b9ff Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 06:11:00 +0000 Subject: Add multiple test cases for set_slowmode tests --- tests/bot/cogs/test_slowmode.py | 44 +++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index 663c9fd43..e9835b8bd 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -31,22 +31,46 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_set_slowmode_no_channel(self) -> None: """Set slowmode without a given channel.""" - self.ctx.channel = MockTextChannel(name='careers') - - await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=3)) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #careers is now 3 seconds.' + test_cases = ( + ('helpers', 23, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), + ('mods', 76526, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), + ('admins', 97, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') ) + for channel_name, seconds, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + result_msg=result_msg + ): + self.ctx.channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + async def test_set_slowmode_with_channel(self) -> None: """Set slowmode with a given channel.""" - text_channel = MockTextChannel(name='meta') - - await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=4)) - self.ctx.send.assert_called_once_with( - f'{Emojis.check_mark} The slowmode delay for #meta is now 4 seconds.' + test_cases = ( + ('bot-commands', 12, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), + ('mod-spam', 21, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), + ('admin-spam', 4323598, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') ) + for channel_name, seconds, result_msg in test_cases: + with self.subTest( + channel_mention=channel_name, + seconds=seconds, + result_msg=result_msg + ): + text_channel = MockTextChannel(name=channel_name) + + await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) + self.ctx.send.assert_called_once_with(result_msg) + + self.ctx.reset_mock() + async def test_reset_slowmode_no_channel(self) -> None: """Reset slowmode without a given channel.""" self.ctx.channel = MockTextChannel(name='careers', slowmode_delay=6) -- cgit v1.2.3 From 2d170b8af92c77bedea4d77fbdeedc515d3f2c59 Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 17:08:24 +0000 Subject: Improve set_slowmode tests by checking whether the channel was edited --- tests/bot/cogs/test_slowmode.py | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index e9835b8bd..65b1534cb 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -32,20 +32,27 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_set_slowmode_no_channel(self) -> None: """Set slowmode without a given channel.""" test_cases = ( - ('helpers', 23, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), - ('mods', 76526, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), - ('admins', 97, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') + ('helpers', 23, True, f'{Emojis.check_mark} The slowmode delay for #helpers is now 23 seconds.'), + ('mods', 76526, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.'), + ('admins', 97, True, f'{Emojis.check_mark} The slowmode delay for #admins is now 1 minute and 37 seconds.') ) - for channel_name, seconds, result_msg in test_cases: + for channel_name, seconds, edited, result_msg in test_cases: with self.subTest( channel_mention=channel_name, seconds=seconds, + edited=edited, result_msg=result_msg ): self.ctx.channel = MockTextChannel(name=channel_name) await self.cog.set_slowmode(self.cog, self.ctx, None, relativedelta(seconds=seconds)) + + if edited: + self.ctx.channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + self.ctx.channel.edit.assert_not_called() + self.ctx.send.assert_called_once_with(result_msg) self.ctx.reset_mock() @@ -53,20 +60,27 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): async def test_set_slowmode_with_channel(self) -> None: """Set slowmode with a given channel.""" test_cases = ( - ('bot-commands', 12, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), - ('mod-spam', 21, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), - ('admin-spam', 4323598, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') + ('bot-commands', 12, True, f'{Emojis.check_mark} The slowmode delay for #bot-commands is now 12 seconds.'), + ('mod-spam', 21, True, f'{Emojis.check_mark} The slowmode delay for #mod-spam is now 21 seconds.'), + ('admin-spam', 4323598, False, f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.') ) - for channel_name, seconds, result_msg in test_cases: + for channel_name, seconds, edited, result_msg in test_cases: with self.subTest( channel_mention=channel_name, seconds=seconds, + edited=edited, result_msg=result_msg ): text_channel = MockTextChannel(name=channel_name) await self.cog.set_slowmode(self.cog, self.ctx, text_channel, relativedelta(seconds=seconds)) + + if edited: + text_channel.edit.assert_awaited_once_with(slowmode_delay=float(seconds)) + else: + text_channel.edit.assert_not_called() + self.ctx.send.assert_called_once_with(result_msg) self.ctx.reset_mock() -- cgit v1.2.3 From 14cfd1e9dd4d149fb554b84969fed27f85ad5361 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 6 Jul 2020 10:09:03 -0700 Subject: Scheduler: assert the coroutine hasn't been awaited yet It'd fail to schedule the coroutine otherwise anyway. There is also the potential to close the coroutine, which may be unexpected to see for a coroutine that was already running (despite being documented). --- bot/utils/scheduling.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index 9fc519393..fddb0c2fe 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -43,6 +43,9 @@ class Scheduler: """ self._log.trace(f"Scheduling task #{task_id}...") + msg = f"Cannot schedule an already started coroutine for #{task_id}" + assert inspect.getcoroutinestate(coroutine) == "CORO_CREATED", msg + if task_id in self._scheduled_tasks: self._log.debug(f"Did not schedule task #{task_id}; task was already scheduled.") coroutine.close() -- cgit v1.2.3 From 420171bc5d472868f5fb96c8960731eea4d67c5d Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 17:15:45 +0000 Subject: Move slowmode cog to the moderation subpackage --- bot/__main__.py | 1 - bot/cogs/moderation/__init__.py | 4 +- bot/cogs/moderation/slowmode.py | 97 +++++++++++++++++++++++++++++++++++++++++ bot/cogs/slowmode.py | 97 ----------------------------------------- 4 files changed, 100 insertions(+), 99 deletions(-) create mode 100644 bot/cogs/moderation/slowmode.py delete mode 100644 bot/cogs/slowmode.py diff --git a/bot/__main__.py b/bot/__main__.py index bbd9c9144..4e0d4a111 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -62,7 +62,6 @@ bot.load_extension("bot.cogs.off_topic_names") bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") -bot.load_extension("bot.cogs.slowmode") bot.load_extension("bot.cogs.snekbox") bot.load_extension("bot.cogs.stats") bot.load_extension("bot.cogs.sync") diff --git a/bot/cogs/moderation/__init__.py b/bot/cogs/moderation/__init__.py index 6880ca1bd..a5c1ef362 100644 --- a/bot/cogs/moderation/__init__.py +++ b/bot/cogs/moderation/__init__.py @@ -3,13 +3,15 @@ from .infractions import Infractions from .management import ModManagement from .modlog import ModLog from .silence import Silence +from .slowmode import Slowmode from .superstarify import Superstarify def setup(bot: Bot) -> None: - """Load the Infractions, ModManagement, ModLog, Silence, and Superstarify cogs.""" + """Load the Infractions, ModManagement, ModLog, Silence, Slowmode, and Superstarify cogs.""" bot.add_cog(Infractions(bot)) bot.add_cog(ModLog(bot)) bot.add_cog(ModManagement(bot)) bot.add_cog(Silence(bot)) + bot.add_cog(Slowmode(bot)) bot.add_cog(Superstarify(bot)) diff --git a/bot/cogs/moderation/slowmode.py b/bot/cogs/moderation/slowmode.py new file mode 100644 index 000000000..1d055afac --- /dev/null +++ b/bot/cogs/moderation/slowmode.py @@ -0,0 +1,97 @@ +import logging +from datetime import datetime +from typing import Optional + +from dateutil.relativedelta import relativedelta +from discord import TextChannel +from discord.ext.commands import Cog, Context, group + +from bot.bot import Bot +from bot.constants import Emojis, MODERATION_ROLES +from bot.converters import DurationDelta +from bot.decorators import with_role_check +from bot.utils import time + +log = logging.getLogger(__name__) + +SLOWMODE_MAX_DELAY = 21600 # seconds + + +class Slowmode(Cog): + """Commands for getting and setting slowmode delays of text channels.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + @group(name='slowmode', aliases=['sm'], invoke_without_command=True) + async def slowmode_group(self, ctx: Context) -> None: + """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" + await ctx.send_help(ctx.command) + + @slowmode_group.command(name='get', aliases=['g']) + async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: + """Get the slowmode delay for a text channel.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + delay = relativedelta(seconds=channel.slowmode_delay) + humanized_delay = time.humanize_delta(delay) + + await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') + + @slowmode_group.command(name='set', aliases=['s']) + async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: + """Set the slowmode delay for a text channel.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` + # Must do this to get the delta in a particular unit of time + utcnow = datetime.utcnow() + slowmode_delay = (utcnow + delay - utcnow).total_seconds() + + humanized_delay = time.humanize_delta(delay) + + # Ensure the delay is within discord's limits + if slowmode_delay <= SLOWMODE_MAX_DELAY: + log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') + + await channel.edit(slowmode_delay=slowmode_delay) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' + ) + + else: + log.info( + f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' + 'which is not between 0 and 6 hours.' + ) + + await ctx.send( + f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' + ) + + @slowmode_group.command(name='reset', aliases=['r']) + async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: + """Reset the slowmode delay for a text channel to 0 seconds.""" + # Use the channel this command was invoked in if one was not given + if channel is None: + channel = ctx.channel + + log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') + + await channel.edit(slowmode_delay=0) + await ctx.send( + f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' + ) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the Slowmode cog.""" + bot.add_cog(Slowmode(bot)) diff --git a/bot/cogs/slowmode.py b/bot/cogs/slowmode.py deleted file mode 100644 index 1d055afac..000000000 --- a/bot/cogs/slowmode.py +++ /dev/null @@ -1,97 +0,0 @@ -import logging -from datetime import datetime -from typing import Optional - -from dateutil.relativedelta import relativedelta -from discord import TextChannel -from discord.ext.commands import Cog, Context, group - -from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES -from bot.converters import DurationDelta -from bot.decorators import with_role_check -from bot.utils import time - -log = logging.getLogger(__name__) - -SLOWMODE_MAX_DELAY = 21600 # seconds - - -class Slowmode(Cog): - """Commands for getting and setting slowmode delays of text channels.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - @group(name='slowmode', aliases=['sm'], invoke_without_command=True) - async def slowmode_group(self, ctx: Context) -> None: - """Get or set the slowmode delay for the text channel this was invoked in or a given text channel.""" - await ctx.send_help(ctx.command) - - @slowmode_group.command(name='get', aliases=['g']) - async def get_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Get the slowmode delay for a text channel.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - delay = relativedelta(seconds=channel.slowmode_delay) - humanized_delay = time.humanize_delta(delay) - - await ctx.send(f'The slowmode delay for {channel.mention} is {humanized_delay}.') - - @slowmode_group.command(name='set', aliases=['s']) - async def set_slowmode(self, ctx: Context, channel: Optional[TextChannel], delay: DurationDelta) -> None: - """Set the slowmode delay for a text channel.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - # Convert `dateutil.relativedelta.relativedelta` to `datetime.timedelta` - # Must do this to get the delta in a particular unit of time - utcnow = datetime.utcnow() - slowmode_delay = (utcnow + delay - utcnow).total_seconds() - - humanized_delay = time.humanize_delta(delay) - - # Ensure the delay is within discord's limits - if slowmode_delay <= SLOWMODE_MAX_DELAY: - log.info(f'{ctx.author} set the slowmode delay for #{channel} to {humanized_delay}.') - - await channel.edit(slowmode_delay=slowmode_delay) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} is now {humanized_delay}.' - ) - - else: - log.info( - f'{ctx.author} tried to set the slowmode delay of #{channel} to {humanized_delay}, ' - 'which is not between 0 and 6 hours.' - ) - - await ctx.send( - f'{Emojis.cross_mark} The slowmode delay must be between 0 and 6 hours.' - ) - - @slowmode_group.command(name='reset', aliases=['r']) - async def reset_slowmode(self, ctx: Context, channel: Optional[TextChannel]) -> None: - """Reset the slowmode delay for a text channel to 0 seconds.""" - # Use the channel this command was invoked in if one was not given - if channel is None: - channel = ctx.channel - - log.info(f'{ctx.author} reset the slowmode delay for #{channel} to 0 seconds.') - - await channel.edit(slowmode_delay=0) - await ctx.send( - f'{Emojis.check_mark} The slowmode delay for {channel.mention} has been reset to 0 seconds.' - ) - - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the Slowmode cog.""" - bot.add_cog(Slowmode(bot)) -- cgit v1.2.3 From 30114ac8c118220b743d4a91f737f8ad973eeb9c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 6 Jul 2020 10:10:47 -0700 Subject: Scheduler: document coroutine closing elsewhere --- bot/utils/scheduling.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/bot/utils/scheduling.py b/bot/utils/scheduling.py index fddb0c2fe..03f31d78f 100644 --- a/bot/utils/scheduling.py +++ b/bot/utils/scheduling.py @@ -36,10 +36,10 @@ class Scheduler: def schedule(self, task_id: t.Hashable, coroutine: t.Coroutine) -> None: """ - Schedule the execution of a coroutine. + Schedule the execution of a `coroutine`. - If a task with `task_id` already exists, close `coroutine` instead of scheduling it. - This prevents unawaited coroutine warnings. + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. """ self._log.trace(f"Scheduling task #{task_id}...") @@ -62,6 +62,9 @@ class Scheduler: Schedule `coroutine` to be executed at the given naïve UTC `time`. If `time` is in the past, schedule `coroutine` immediately. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. """ delay = (time - datetime.utcnow()).total_seconds() if delay > 0: @@ -70,7 +73,12 @@ class Scheduler: self.schedule(task_id, coroutine) def schedule_later(self, delay: t.Union[int, float], task_id: t.Hashable, coroutine: t.Coroutine) -> None: - """Schedule `coroutine` to be executed after the given `delay` number of seconds.""" + """ + Schedule `coroutine` to be executed after the given `delay` number of seconds. + + If a task with `task_id` already exists, close `coroutine` instead of scheduling it. This + prevents unawaited coroutine warnings. Don't pass a coroutine that'll be re-used elsewhere. + """ self.schedule(task_id, self._await_later(delay, task_id, coroutine)) def cancel(self, task_id: t.Hashable) -> None: -- cgit v1.2.3 From cdeb41bfd283cb6cb1285993737e8e3abd5aea9f Mon Sep 17 00:00:00 2001 From: Den4200 Date: Mon, 6 Jul 2020 17:30:44 +0000 Subject: Fix imports in slowmode tests --- tests/bot/cogs/test_slowmode.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_slowmode.py b/tests/bot/cogs/test_slowmode.py index 65b1534cb..f442814c8 100644 --- a/tests/bot/cogs/test_slowmode.py +++ b/tests/bot/cogs/test_slowmode.py @@ -3,7 +3,7 @@ from unittest import mock from dateutil.relativedelta import relativedelta -from bot.cogs.slowmode import Slowmode +from bot.cogs.moderation.slowmode import Slowmode from bot.constants import Emojis from tests.helpers import MockBot, MockContext, MockTextChannel @@ -103,8 +103,8 @@ class SlowmodeTests(unittest.IsolatedAsyncioTestCase): f'{Emojis.check_mark} The slowmode delay for #meta has been reset to 0 seconds.' ) - @mock.patch("bot.cogs.slowmode.with_role_check") - @mock.patch("bot.cogs.slowmode.MODERATION_ROLES", new=(1, 2, 3)) + @mock.patch("bot.cogs.moderation.slowmode.with_role_check") + @mock.patch("bot.cogs.moderation.slowmode.MODERATION_ROLES", new=(1, 2, 3)) def test_cog_check(self, role_check): """Role check is called with `MODERATION_ROLES`""" self.cog.cog_check(self.ctx) -- cgit v1.2.3 From b1c017741318ff0e96e4a46d0390054541a215d1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jul 2020 12:01:50 -0700 Subject: Prevent bot from mentioning roles This was open to abuse when the bot relayed user input. --- Pipfile | 2 +- Pipfile.lock | 220 ++++++++++++++++++++++++++++++++------------------------ bot/__main__.py | 1 + 3 files changed, 127 insertions(+), 96 deletions(-) diff --git a/Pipfile b/Pipfile index 33be99587..e25e7b1e1 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord.py = "~=1.3.2" +discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "e971e2f16cba22decd25db6b44e9cc84adf08555",editable = true} fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" diff --git a/Pipfile.lock b/Pipfile.lock index 0e591710c..12325f2a7 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "0297accc3d614d3da8080b89d56ef7fe489c28a0ada8102df396a604af7ee330" + "sha256": "f6fac6e59e6579ea4cc0e2b49a5fa59785137d02e6c6a7df47ef502375313703" }, "pipfile-spec": 6, "requires": { @@ -63,6 +63,7 @@ "sha256:41a9d4eb17db805f30ed172f3f609fe0c2b16657fb15b1b67df19d251dd93c0d", "sha256:7c19477a9450824cb79f9949fd238f4148e2c0dca67756a2868863c387209f04" ], + "markers": "python_version >= '3.6'", "version": "==3.2.2" }, "alabaster": { @@ -77,6 +78,7 @@ "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f", "sha256:4291ca197d287d274d0b6cb5d6f8f8f82d434ed288f962539ff18cc9012f9ea3" ], + "markers": "python_full_version >= '3.5.3'", "version": "==3.0.1" }, "attrs": { @@ -84,6 +86,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "babel": { @@ -91,6 +94,7 @@ "sha256:1aac2ae2d0d8ea368fa90906567f5c08463d98ade155c0c4bfedd6a0f7160e38", "sha256:d670ea0b10f8b723672d3a6abeb87b565b244da220d76b4dba1b66269ec152d4" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.8.0" }, "beautifulsoup4": { @@ -104,10 +108,10 @@ }, "certifi": { "hashes": [ - "sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304", - "sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519" + "sha256:5930595817496dd21bb8dc35dad090f1c2cd0adfaf21204bf6732ca5d8ee34d3", + "sha256:8fc0819f1f30ba15bdb34cceffb9ef04d99f420f68eb75d901e9560b8749fc41" ], - "version": "==2020.4.5.1" + "version": "==2020.6.20" }, "cffi": { "hashes": [ @@ -154,7 +158,6 @@ "sha256:7d73d2a99753107a36ac6b455ee49046802e59d9d076ef8e47b61499fa29afff", "sha256:e96da0d330793e2cb9485e9ddfd918d456036c7149416295932478192f4436a1" ], - "index": "pypi", "markers": "sys_platform == 'win32'", "version": "==0.4.3" }, @@ -174,26 +177,17 @@ "index": "pypi", "version": "==4.3.2" }, - "discord": { - "hashes": [ - "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", - "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" - ], - "index": "pypi", - "version": "==1.0.1" - }, - "discord.py": { - "hashes": [ - "sha256:406871b06d86c3dc49fba63238519f28628dac946fef8a0e22988ff58ec05580", - "sha256:ad00e34c72d2faa8db2157b651d05f3c415d7d05078e7e41dc9e8dc240051beb" - ], - "version": "==1.3.3" + "discord-py": { + "editable": true, + "git": "https://github.com/Rapptz/discord.py.git", + "ref": "e971e2f16cba22decd25db6b44e9cc84adf08555" }, "docutils": { "hashes": [ "sha256:0c5b78adfbf7762415433f5515cd5c9e762339e23369dbe8000d84a4bf4ab3af", "sha256:c2de3a60e9e7d07be26b7f2b00ca0309c207e06c100f9cc2a94931fc75a478fc" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==0.16" }, "fakeredis": { @@ -264,6 +258,7 @@ "sha256:fa2dc05b87d97acc1c6ae63f3e0f39eae5246565232484b08db6bf2dc1580678", "sha256:fe7d6ce9f6a5fbe24f09d95ea93e9c7271abc4e1565da511e1449b107b4d7848" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.0.1" }, "humanfriendly": { @@ -271,20 +266,23 @@ "sha256:bf52ec91244819c780341a3438d5d7b09f431d3f113a475147ac9b7b167a3d12", "sha256:e78960b31198511f45fd455534ae7645a6207d33e512d2e842c766d15d9c8080" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==8.2" }, "idna": { "hashes": [ - "sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb", - "sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa" + "sha256:b307872f855b18632ce0c21c5e45be78c0ea7ae4c15c828c20788b26921eb3f6", + "sha256:b97d804b1e9b523befed77c48dacec60e6dcb0b5391d57af6a65a312a90648c0" ], - "version": "==2.9" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==2.10" }, "imagesize": { "hashes": [ "sha256:6965f19a6a2039c7d48bca7dba2473069ff854c36ae6f19d2cde309d998228a1", "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.2.0" }, "jinja2": { @@ -292,6 +290,7 @@ "sha256:89aab215427ef59c34ad58735269eb58b1a5808103067f7bb9d5836c651b3bb0", "sha256:f0a4641d3cf955324a89c04f3d94663aa4d638abe8f733ecd3582848e1c37035" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", "version": "==2.11.2" }, "lxml": { @@ -370,15 +369,16 @@ "sha256:e249096428b3ae81b08327a63a485ad0878de3fb939049038579ac0ef61e17e7", "sha256:e8313f01ba26fbbe36c7be1966a7b7424942f670f38e666995b88d012765b9be" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.1.1" }, "more-itertools": { "hashes": [ - "sha256:558bb897a2232f5e4f8e2399089e35aecb746e1f9191b6584a151647e89267be", - "sha256:7818f596b1e87be009031c7653d01acc46ed422e6656b394b0f765ce66ed4982" + "sha256:68c70cc7167bdf5c7c9d8f6954a7837089c6a36bf565383919bb595efb8a17e5", + "sha256:b78134b2063dd214000685165d81c154522c3ee0a1c0d4d113c80361c234c5a2" ], "index": "pypi", - "version": "==8.3.0" + "version": "==8.4.0" }, "multidict": { "hashes": [ @@ -400,19 +400,22 @@ "sha256:fcfbb44c59af3f8ea984de67ec7c306f618a3ec771c2843804069917a8f2e255", "sha256:feed85993dbdb1dbc29102f50bca65bdc68f2c0c8d352468c25b54874f23c39d" ], + "markers": "python_version >= '3.5'", "version": "==4.7.6" }, "ordered-set": { "hashes": [ - "sha256:a31008c57f9c9776b12eb8841b1f61d1e4d70dfbbe8875ccfa2403c54af3d51b" + "sha256:ba93b2df055bca202116ec44b9bead3df33ea63a7d5827ff8e16738b97f33a95" ], - "version": "==4.0.1" + "markers": "python_version >= '3.5'", + "version": "==4.0.2" }, "packaging": { "hashes": [ "sha256:4357f74f47b9c12db93624a82154e9b120fa8293699949152b22065d556079f8", "sha256:998416ba6962ae7fbd6596850b80e17859a5753ba17c32284f67bfff33784181" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==20.4" }, "pamqp": { @@ -461,6 +464,7 @@ "sha256:2d475327684562c3a96cc71adf7dc8c4f0565175cf86b6d7a404ff4c771f15f0", "sha256:7582ad22678f0fcd81102833f60ef8d0e57288b6b5fb00323d101be910e35705" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.20" }, "pygments": { @@ -468,6 +472,7 @@ "sha256:647344a061c249a3b74e230c739f434d7ea4d8b1d5f3721bc0f3558049b38f44", "sha256:ff7a40b4860b727ab48fad6360eb351cc1b33cbf9b15a0f689ca5353e9463324" ], + "markers": "python_version >= '3.5'", "version": "==2.6.1" }, "pyparsing": { @@ -475,6 +480,7 @@ "sha256:c203ec8783bf771a155b207279b9bccb8dea02d8f0c9e5f8ead507bc3246ecc1", "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b" ], + "markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.4.7" }, "python-dateutil": { @@ -511,32 +517,34 @@ }, "redis": { "hashes": [ - "sha256:2ef11f489003f151777c064c5dbc6653dfb9f3eade159bcadc524619fddc2242", - "sha256:6d65e84bc58091140081ee9d9c187aab0480097750fac44239307a3bdf0b1251" + "sha256:0e7e0cfca8660dea8b7d5cd8c4f6c5e29e11f31158c0b0ae91a397f00e5a05a2", + "sha256:432b788c4530cfe16d8d943a09d40ca6c16149727e4afe8c2c9d5580c59d9f24" ], - "version": "==3.5.2" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'", + "version": "==3.5.3" }, "requests": { "hashes": [ - "sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee", - "sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6" + "sha256:b3559a131db72c33ee969480840fff4bb6dd111de7dd27c8ee1f820f4f00231b", + "sha256:fe75cc94a9443b9246fc7049224f75604b113c36acb93f87b80ed42c44cbb898" ], "index": "pypi", - "version": "==2.23.0" + "version": "==2.24.0" }, "sentry-sdk": { "hashes": [ - "sha256:0e5e947d0f7a969314aa23669a94a9712be5a688ff069ff7b9fc36c66adc160c", - "sha256:799a8bf76b012e3030a881be00e97bc0b922ce35dde699c6537122b751d80e2c" + "sha256:da06bc3641e81ec2c942f87a0676cd9180044fa3d1697524a0005345997542e2", + "sha256:e80d61af85d99a1222c1a3e2a24023618374cd50a99673aa7fa3cf920e7d813b" ], "index": "pypi", - "version": "==0.14.4" + "version": "==0.16.0" }, "six": { "hashes": [ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -548,16 +556,17 @@ }, "sortedcontainers": { "hashes": [ - "sha256:974e9a32f56b17c1bac2aebd9dcf197f3eb9cd30553c5852a3187ad162e1a03a", - "sha256:d9e96492dd51fae31e60837736b38fe42a187b5404c16606ff7ee7cd582d4c60" + "sha256:4e73a757831fc3ca4de2859c422564239a31d8213d09a2a666e375807034d2ba", + "sha256:c633ebde8580f241f274c1f8994a665c0e54a17724fecd0cae2f079e09c36d3f" ], - "version": "==2.1.0" + "version": "==2.2.2" }, "soupsieve": { "hashes": [ "sha256:1634eea42ab371d3d346309b93df7870a88610f0725d47528be902a0d95ecc55", "sha256:a59dc181727e95d25f781f0eb4fd1825ff45590ec8ff49eadfd7f1a537cc0232" ], + "markers": "python_version >= '3.5'", "version": "==2.0.1" }, "sphinx": { @@ -573,6 +582,7 @@ "sha256:806111e5e962be97c29ec4c1e7fe277bfd19e9652fb1a4392105b43e01af885a", "sha256:a072735ec80e7675e3f432fcae8610ecf509c5f1869d17e2eecff44389cdbc58" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-devhelp": { @@ -580,6 +590,7 @@ "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e", "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4" ], + "markers": "python_version >= '3.5'", "version": "==1.0.2" }, "sphinxcontrib-htmlhelp": { @@ -587,6 +598,7 @@ "sha256:3c0bc24a2c41e340ac37c85ced6dafc879ab485c095b1d65d2461ac2f7cca86f", "sha256:e8f5bb7e31b2dbb25b9cc435c8ab7a79787ebf7f906155729338f3156d93659b" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-jsmath": { @@ -594,6 +606,7 @@ "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178", "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8" ], + "markers": "python_version >= '3.5'", "version": "==1.0.1" }, "sphinxcontrib-qthelp": { @@ -601,6 +614,7 @@ "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72", "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6" ], + "markers": "python_version >= '3.5'", "version": "==1.0.3" }, "sphinxcontrib-serializinghtml": { @@ -608,6 +622,7 @@ "sha256:eaa0eccc86e982a9b939b2b82d12cc5d013385ba5eadcc7e4fed23f4405f77bc", "sha256:f242a81d423f59617a8e5cf16f5d4d74e28ee9a66f9e5b637a18082991db5a9a" ], + "markers": "python_version >= '3.5'", "version": "==1.1.4" }, "statsd": { @@ -623,6 +638,7 @@ "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'", "version": "==1.25.9" }, "websockets": { @@ -650,6 +666,7 @@ "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" ], + "markers": "python_full_version >= '3.6.1'", "version": "==8.1" }, "yarl": { @@ -672,6 +689,7 @@ "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2" ], + "markers": "python_version >= '3.5'", "version": "==1.4.2" } }, @@ -688,6 +706,7 @@ "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==19.3.0" }, "cfgv": { @@ -695,50 +714,55 @@ "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513" ], + "markers": "python_full_version >= '3.6.1'", "version": "==3.1.0" }, "coverage": { "hashes": [ - "sha256:00f1d23f4336efc3b311ed0d807feb45098fc86dee1ca13b3d6768cdab187c8a", - "sha256:01333e1bd22c59713ba8a79f088b3955946e293114479bbfc2e37d522be03355", - "sha256:0cb4be7e784dcdc050fc58ef05b71aa8e89b7e6636b99967fadbdba694cf2b65", - "sha256:0e61d9803d5851849c24f78227939c701ced6704f337cad0a91e0972c51c1ee7", - "sha256:1601e480b9b99697a570cea7ef749e88123c04b92d84cedaa01e117436b4a0a9", - "sha256:2742c7515b9eb368718cd091bad1a1b44135cc72468c731302b3d641895b83d1", - "sha256:2d27a3f742c98e5c6b461ee6ef7287400a1956c11421eb574d843d9ec1f772f0", - "sha256:402e1744733df483b93abbf209283898e9f0d67470707e3c7516d84f48524f55", - "sha256:5c542d1e62eece33c306d66fe0a5c4f7f7b3c08fecc46ead86d7916684b36d6c", - "sha256:5f2294dbf7875b991c381e3d5af2bcc3494d836affa52b809c91697449d0eda6", - "sha256:6402bd2fdedabbdb63a316308142597534ea8e1895f4e7d8bf7476c5e8751fef", - "sha256:66460ab1599d3cf894bb6baee8c684788819b71a5dc1e8fa2ecc152e5d752019", - "sha256:782caea581a6e9ff75eccda79287daefd1d2631cc09d642b6ee2d6da21fc0a4e", - "sha256:79a3cfd6346ce6c13145731d39db47b7a7b859c0272f02cdb89a3bdcbae233a0", - "sha256:7a5bdad4edec57b5fb8dae7d3ee58622d626fd3a0be0dfceda162a7035885ecf", - "sha256:8fa0cbc7ecad630e5b0f4f35b0f6ad419246b02bc750de7ac66db92667996d24", - "sha256:a027ef0492ede1e03a8054e3c37b8def89a1e3c471482e9f046906ba4f2aafd2", - "sha256:a3f3654d5734a3ece152636aad89f58afc9213c6520062db3978239db122f03c", - "sha256:a82b92b04a23d3c8a581fc049228bafde988abacba397d57ce95fe95e0338ab4", - "sha256:acf3763ed01af8410fc36afea23707d4ea58ba7e86a8ee915dfb9ceff9ef69d0", - "sha256:adeb4c5b608574a3d647011af36f7586811a2c1197c861aedb548dd2453b41cd", - "sha256:b83835506dfc185a319031cf853fa4bb1b3974b1f913f5bb1a0f3d98bdcded04", - "sha256:bb28a7245de68bf29f6fb199545d072d1036a1917dca17a1e75bbb919e14ee8e", - "sha256:bf9cb9a9fd8891e7efd2d44deb24b86d647394b9705b744ff6f8261e6f29a730", - "sha256:c317eaf5ff46a34305b202e73404f55f7389ef834b8dbf4da09b9b9b37f76dd2", - "sha256:dbe8c6ae7534b5b024296464f387d57c13caa942f6d8e6e0346f27e509f0f768", - "sha256:de807ae933cfb7f0c7d9d981a053772452217df2bf38e7e6267c9cbf9545a796", - "sha256:dead2ddede4c7ba6cb3a721870f5141c97dc7d85a079edb4bd8d88c3ad5b20c7", - "sha256:dec5202bfe6f672d4511086e125db035a52b00f1648d6407cc8e526912c0353a", - "sha256:e1ea316102ea1e1770724db01998d1603ed921c54a86a2efcb03428d5417e489", - "sha256:f90bfc4ad18450c80b024036eaf91e4a246ae287701aaa88eaebebf150868052" - ], - "index": "pypi", - "version": "==5.1" + "sha256:0fc4e0d91350d6f43ef6a61f64a48e917637e1dcfcba4b4b7d543c628ef82c2d", + "sha256:10f2a618a6e75adf64329f828a6a5b40244c1c50f5ef4ce4109e904e69c71bd2", + "sha256:12eaccd86d9a373aea59869bc9cfa0ab6ba8b1477752110cb4c10d165474f703", + "sha256:1874bdc943654ba46d28f179c1846f5710eda3aeb265ff029e0ac2b52daae404", + "sha256:1dcebae667b73fd4aa69237e6afb39abc2f27520f2358590c1b13dd90e32abe7", + "sha256:1e58fca3d9ec1a423f1b7f2aa34af4f733cbfa9020c8fe39ca451b6071237405", + "sha256:214eb2110217f2636a9329bc766507ab71a3a06a8ea30cdeebb47c24dce5972d", + "sha256:25fe74b5b2f1b4abb11e103bb7984daca8f8292683957d0738cd692f6a7cc64c", + "sha256:32ecee61a43be509b91a526819717d5e5650e009a8d5eda8631a59c721d5f3b6", + "sha256:3740b796015b889e46c260ff18b84683fa2e30f0f75a171fb10d2bf9fb91fc70", + "sha256:3b2c34690f613525672697910894b60d15800ac7e779fbd0fccf532486c1ba40", + "sha256:41d88736c42f4a22c494c32cc48a05828236e37c991bd9760f8923415e3169e4", + "sha256:42fa45a29f1059eda4d3c7b509589cc0343cd6bbf083d6118216830cd1a51613", + "sha256:4bb385a747e6ae8a65290b3df60d6c8a692a5599dc66c9fa3520e667886f2e10", + "sha256:509294f3e76d3f26b35083973fbc952e01e1727656d979b11182f273f08aa80b", + "sha256:5c74c5b6045969b07c9fb36b665c9cac84d6c174a809fc1b21bdc06c7836d9a0", + "sha256:60a3d36297b65c7f78329b80120f72947140f45b5c7a017ea730f9112b40f2ec", + "sha256:6f91b4492c5cde83bfe462f5b2b997cdf96a138f7c58b1140f05de5751623cf1", + "sha256:7403675df5e27745571aba1c957c7da2dacb537c21e14007ec3a417bf31f7f3d", + "sha256:87bdc8135b8ee739840eee19b184804e5d57f518578ffc797f5afa2c3c297913", + "sha256:8a3decd12e7934d0254939e2bf434bf04a5890c5bf91a982685021786a08087e", + "sha256:9702e2cb1c6dec01fb8e1a64c015817c0800a6eca287552c47a5ee0ebddccf62", + "sha256:a4d511012beb967a39580ba7d2549edf1e6865a33e5fe51e4dce550522b3ac0e", + "sha256:bbb387811f7a18bdc61a2ea3d102be0c7e239b0db9c83be7bfa50f095db5b92a", + "sha256:bfcc811883699ed49afc58b1ed9f80428a18eb9166422bce3c31a53dba00fd1d", + "sha256:c32aa13cc3fe86b0f744dfe35a7f879ee33ac0a560684fef0f3e1580352b818f", + "sha256:ca63dae130a2e788f2b249200f01d7fa240f24da0596501d387a50e57aa7075e", + "sha256:d54d7ea74cc00482a2410d63bf10aa34ebe1c49ac50779652106c867f9986d6b", + "sha256:d67599521dff98ec8c34cd9652cbcfe16ed076a2209625fca9dc7419b6370e5c", + "sha256:d82db1b9a92cb5c67661ca6616bdca6ff931deceebb98eecbd328812dab52032", + "sha256:d9ad0a988ae20face62520785ec3595a5e64f35a21762a57d115dae0b8fb894a", + "sha256:ebf2431b2d457ae5217f3a1179533c456f3272ded16f8ed0b32961a6d90e38ee", + "sha256:ed9a21502e9223f563e071759f769c3d6a2e1ba5328c31e86830368e8d78bc9c", + "sha256:f50632ef2d749f541ca8e6c07c9928a37f87505ce3a9f20c8446ad310f1aa87b" + ], + "index": "pypi", + "version": "==5.2" }, "distlib": { "hashes": [ - "sha256:2e166e231a26b36d6dfe35a48c4464346620f8645ed0ace01ee31822b288de21" + "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb", + "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1" ], - "version": "==0.3.0" + "version": "==0.3.1" }, "filelock": { "hashes": [ @@ -749,19 +773,19 @@ }, "flake8": { "hashes": [ - "sha256:c69ac1668e434d37a2d2880b3ca9aafd54b3a10a3ac1ab101d22f29e29cf8634", - "sha256:ccaa799ef9893cebe69fdfefed76865aeaefbb94cb8545617b2298786a4de9a5" + "sha256:15e351d19611c887e482fb960eae4d44845013cc142d42896e9862f775d8cf5c", + "sha256:f04b9fcbac03b0a3e58c0ab3a0ecc462e023a9faf046d57794184028123aa208" ], "index": "pypi", - "version": "==3.8.2" + "version": "==3.8.3" }, "flake8-annotations": { "hashes": [ - "sha256:9091d920406a7ff10e401e0dd1baa396d1d7d2e3d101a9beecf815f5894ad554", - "sha256:f59fdceb8c8f380a20aed20e1ba8a57bde05935958166c52be2249f113f7ab75" + "sha256:babc81a17a5f1a63464195917e20d3e8663fb712b3633d4522dbfc407cff31b3", + "sha256:fcd833b415726a7a374922c95a5c47a7a4d8ea71cb4a586369c665e7476146e1" ], "index": "pypi", - "version": "==2.1.0" + "version": "==2.2.0" }, "flake8-bugbear": { "hashes": [ @@ -819,10 +843,11 @@ }, "identify": { "hashes": [ - "sha256:0f3c3aac62b51b86fea6ff52fe8ff9e06f57f10411502443809064d23e16f1c2", - "sha256:f9ad3d41f01e98eb066b6e05c5b184fd1e925fadec48eb165b4e01c72a1ef3a7" + "sha256:c4d07f2b979e3931894170a9e0d4b8281e6905ea6d018c326f7ffefaf20db680", + "sha256:dac33eff90d57164e289fb20bf4e131baef080947ee9bf45efcd0da8d19064bf" ], - "version": "==1.4.16" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.4.21" }, "mccabe": { "hashes": [ @@ -833,31 +858,32 @@ }, "nodeenv": { "hashes": [ - "sha256:5b2438f2e42af54ca968dd1b374d14a1194848955187b0e5e4be1f73813a5212" + "sha256:4b0b77afa3ba9b54f4b6396e60b0c83f59eaeb2d63dc3cc7a70f7f4af96c82bc" ], - "version": "==1.3.5" + "version": "==1.4.0" }, "pep8-naming": { "hashes": [ - "sha256:5d9f1056cb9427ce344e98d1a7f5665710e2f20f748438e308995852cfa24164", - "sha256:f3b4a5f9dd72b991bf7d8e2a341d2e1aa3a884a769b5aaac4f56825c1763bf3a" + "sha256:a1dd47dd243adfe8a83616e27cf03164960b507530f155db94e10b36a6cd6724", + "sha256:f43bfe3eea7e0d73e8b5d07d6407ab47f2476ccaeff6937c84275cd30b016738" ], "index": "pypi", - "version": "==0.10.0" + "version": "==0.11.1" }, "pre-commit": { "hashes": [ - "sha256:5559e09afcac7808933951ffaf4ff9aac524f31efbc3f24d021540b6c579813c", - "sha256:703e2e34cbe0eedb0d319eff9f7b83e2022bb5a3ab5289a6a8841441076514d0" + "sha256:1657663fdd63a321a4a739915d7d03baedd555b25054449090f97bb0cb30a915", + "sha256:e8b1315c585052e729ab7e99dcca5698266bedce9067d21dc909c23e3ceed626" ], "index": "pypi", - "version": "==2.4.0" + "version": "==2.6.0" }, "pycodestyle": { "hashes": [ "sha256:2295e7b2f6b5bd100585ebcb1f616591b652db8a741695b3d8f5d28bdc934367", "sha256:c58a7d2815e0e8d7972bf1803331fb0152f867bd89adf8a01dfd55085434192e" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.6.0" }, "pydocstyle": { @@ -865,6 +891,7 @@ "sha256:da7831660b7355307b32778c4a0dbfb137d89254ef31a2b2978f50fc0b4d7586", "sha256:f4f5d210610c2d153fae39093d44224c17429e2ad7da12a8b419aba5c2f614b5" ], + "markers": "python_version >= '3.5'", "version": "==5.0.2" }, "pyflakes": { @@ -872,6 +899,7 @@ "sha256:0d94e0e05a19e57a99444b6ddcf9a6eb2e5c68d3ca1e98e90707af8152c90a92", "sha256:35b2d75ee967ea93b55750aa9edbbf72813e06a66ba54438df2cfac9e3c27fc8" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==2.2.0" }, "pyyaml": { @@ -896,6 +924,7 @@ "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259", "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced" ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", "version": "==1.15.0" }, "snowballstemmer": { @@ -922,10 +951,11 @@ }, "virtualenv": { "hashes": [ - "sha256:a116629d4e7f4d03433b8afa27f43deba09d48bc48f5ecefa4f015a178efb6cf", - "sha256:a730548b27366c5e6cbdf6f97406d861cccece2e22275e8e1a757aeff5e00c70" + "sha256:c11a475400e98450403c0364eb3a2d25d42f71cf1493da64390487b666de4324", + "sha256:e10cc66f40cbda459720dfe1d334c4dc15add0d80f09108224f171006a97a172" ], - "version": "==20.0.21" + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==20.0.26" } } } diff --git a/bot/__main__.py b/bot/__main__.py index 4e0d4a111..7e92d1a25 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -29,6 +29,7 @@ bot = Bot( activity=discord.Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000, + allowed_mentions=discord.AllowedMentions(everyone=False, roles=False) ) # Internal/debug -- cgit v1.2.3 From 36330b1e386f1d3964eb34f5c5cc4afdf988358f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jul 2020 12:15:24 -0700 Subject: Allow owners, admins, and mods roles to be pinged --- bot/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index 7e92d1a25..37e62c2f1 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -24,12 +24,13 @@ sentry_sdk.init( ] ) +allowed_roles = [discord.Object(id_) for id_ in constants.MODERATION_ROLES] bot = Bot( command_prefix=when_mentioned_or(constants.Bot.prefix), activity=discord.Game(name="Commands: !help"), case_insensitive=True, max_messages=10_000, - allowed_mentions=discord.AllowedMentions(everyone=False, roles=False) + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) ) # Internal/debug -- cgit v1.2.3 From 8f36817fb4a8c995e92986db3199763b7110aa9e Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 7 Jul 2020 20:24:04 +0100 Subject: Add git to Docker image --- Dockerfile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Dockerfile b/Dockerfile index 06a538b2a..c51b9dff6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,9 @@ ENV PIP_NO_CACHE_DIR=false \ PIPENV_HIDE_EMOJIS=1 \ PIPENV_IGNORE_VIRTUALENVS=1 \ PIPENV_NOSPIN=1 + +RUN apt-get update +RUN apt-get install -y git # Install pipenv RUN pip install -U pipenv -- cgit v1.2.3 From baf10b6327ba8ca6f3b2b644613170ee5f937e95 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 7 Jul 2020 20:28:17 +0100 Subject: Fix git install in Dockerfile --- Dockerfile | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c51b9dff6..0b1674e7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,9 +5,11 @@ ENV PIP_NO_CACHE_DIR=false \ PIPENV_HIDE_EMOJIS=1 \ PIPENV_IGNORE_VIRTUALENVS=1 \ PIPENV_NOSPIN=1 - -RUN apt-get update -RUN apt-get install -y git + +RUN apt-get -y update \ + && apt-get install -y \ + git \ + && rm -rf /var/lib/apt/lists/* # Install pipenv RUN pip install -U pipenv -- cgit v1.2.3 From ba1b9081cfbc245b7a8fd8d41f7ab7173b097a31 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jul 2020 12:35:06 -0700 Subject: Don't install discord.py as editable It may be causing it to not be cached in Azure. --- Pipfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pipfile b/Pipfile index e25e7b1e1..29aa1a08f 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "e971e2f16cba22decd25db6b44e9cc84adf08555",editable = true} +discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "e971e2f16cba22decd25db6b44e9cc84adf08555"} fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" -- cgit v1.2.3 From a1a44be2b57e35fbaee8cac024fdd74c218c72b1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 7 Jul 2020 12:42:24 -0700 Subject: Re-lock Pipfile Forgot to do this after removing editable. --- Pipfile.lock | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Pipfile.lock b/Pipfile.lock index 12325f2a7..a522e20d3 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "f6fac6e59e6579ea4cc0e2b49a5fa59785137d02e6c6a7df47ef502375313703" + "sha256": "6404ca2550369b6416801688b4382d22fdba178d9319c4a68bd207d1e5aaeaab" }, "pipfile-spec": 6, "requires": { @@ -178,7 +178,6 @@ "version": "==4.3.2" }, "discord-py": { - "editable": true, "git": "https://github.com/Rapptz/discord.py.git", "ref": "e971e2f16cba22decd25db6b44e9cc84adf08555" }, -- cgit v1.2.3 From d6c775bc96d8b913677a87c9025a6194831d4b3b Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Wed, 8 Jul 2020 15:56:49 -0400 Subject: Initial commit for proposed range-len command --- bot/resources/tags/range-len.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bot/resources/tags/range-len.md diff --git a/bot/resources/tags/range-len.md b/bot/resources/tags/range-len.md new file mode 100644 index 000000000..b1c973647 --- /dev/null +++ b/bot/resources/tags/range-len.md @@ -0,0 +1,19 @@ +Iterating over `range(len(...))` is a common approach to accessing each item +in an ordered collection. + +```py +for i in range(len(my_list)): + do_something(my_list[i]) +``` + +The pythonic syntax is much simpler, and is +guaranteed to produce elements in the same order: + +```py +for item in my_list: + do_something(item) +``` + +Python has other solutions for cases when the index itself might be needed. +To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). +To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). -- cgit v1.2.3 From 4775b174597e72100641b97ea6ef2c9e63622d60 Mon Sep 17 00:00:00 2001 From: Slushie Date: Wed, 8 Jul 2020 21:28:17 +0100 Subject: Edit BadArgument error message --- bot/cogs/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 5de961116..a7f8074e2 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -170,7 +170,7 @@ class ErrorHandler(Cog): await prepared_help_command self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): - await ctx.send(f"Bad argument: {e}\n") + await ctx.send("Bad argument: Please double check your input arguments and try again.\n") await prepared_help_command self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): -- cgit v1.2.3 From 9060c909f6816eb2fff97a41d709a1c67b034af1 Mon Sep 17 00:00:00 2001 From: Slushie Date: Wed, 8 Jul 2020 21:29:14 +0100 Subject: Create a filtering function to filter eval results --- bot/cogs/filtering.py | 172 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 120 insertions(+), 52 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 76ea68660..ae77ad7f0 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -2,7 +2,7 @@ import asyncio import logging import re from datetime import datetime, timedelta -from typing import List, Mapping, Optional, Union +from typing import List, Mapping, Optional, Tuple, Union import dateutil import discord.errors @@ -200,24 +200,66 @@ class Filtering(Cog, Scheduler): # Update time when alert sent await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) + async def _filter_eval(self, result: str, msg: Message) -> bool: + """ + Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. + + Also requires the original message, to check whether to filter and for mod logs. + Returns whether a filter was triggered or not. + """ + # Should we filter this message? + if self._check_filter(msg): + for filter_name, _filter in self.filters.items(): + # Is this specific filter enabled in the config? + # We also do not need to worry about filters that take the full message, + # since all we have is an arbitrary string. + if _filter["enabled"] and _filter["content_only"]: + match = await _filter["function"](result) + + if match: + # If this is a filter (not a watchlist), we set the variable so we know + # that it has been triggered + if _filter["type"] == "filter": + filter_triggered = True + + # We do not have to check against DM channels since !eval cannot be used there. + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, result + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} using !eval with " + f"[the following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger + + return filter_triggered + async def _filter_message(self, msg: Message, delta: Optional[int] = None) -> None: """Filter the input message to see if it violates any of our rules, and then respond accordingly.""" # Should we filter this message? - role_whitelisted = False - - if type(msg.author) is Member: # Only Member has roles, not User. - for role in msg.author.roles: - if role.id in Filter.role_whitelist: - role_whitelisted = True - - filter_message = ( - msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist - and not role_whitelisted # Role not in whitelist - and not msg.author.bot # Author not a bot - ) - - # If none of the above, we can start filtering. - if filter_message: + if self._check_filter(msg): for filter_name, _filter in self.filters.items(): # Is this specific filter enabled in the config? if _filter["enabled"]: @@ -276,16 +318,9 @@ class Filtering(Cog, Scheduler): else: channel_str = f"in {msg.channel.mention}" - # Word and match stats for watch_regex - if filter_name == "watch_regex": - surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] - message_content = ( - f"**Match:** '{match[0]}'\n" - f"**Location:** '...{escape_markdown(surroundings)}...'\n" - f"\n**Original Message:**\n{escape_markdown(msg.content)}" - ) - else: # Use content of discord Message - message_content = msg.content + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, msg.content + ) message = ( f"The {filter_name} {_filter['type']} was triggered " @@ -297,30 +332,6 @@ class Filtering(Cog, Scheduler): log.debug(message) - self.bot.stats.incr(f"filters.{filter_name}") - - additional_embeds = None - additional_embeds_msg = None - - # The function returns True for invalid invites. - # They have no data so additional embeds can't be created for them. - if filter_name == "filter_invites" and match is not True: - additional_embeds = [] - for invite, data in match.items(): - embed = discord.Embed(description=( - f"**Members:**\n{data['members']}\n" - f"**Active:**\n{data['active']}" - )) - embed.set_author(name=data["name"]) - embed.set_thumbnail(url=data["icon"]) - embed.set_footer(text=f"Guild Invite Code: {invite}") - additional_embeds.append(embed) - additional_embeds_msg = "For the following guild(s):" - - elif filter_name == "watch_rich_embeds": - additional_embeds = msg.embeds - additional_embeds_msg = "With the following embed(s):" - # Send pretty mod log embed to mod-alerts await self.mod_log.send_log_message( icon_url=Icons.filtering, @@ -336,6 +347,63 @@ class Filtering(Cog, Scheduler): break # We don't want multiple filters to trigger + def _add_stats(self, name: str, match: Union[re.Match, dict, bool, List[discord.Embed]], content: str) -> Tuple[ + str, Optional[List[discord.Embed]], Optional[str] + ]: + """Adds relevant statistical information to the relevant filter and increments the bot's stats.""" + # Word and match stats for watch_regex + if name == "watch_regex": + surroundings = match.string[max(match.start() - 10, 0): match.end() + 10] + message_content = ( + f"**Match:** '{match[0]}'\n" + f"**Location:** '...{escape_markdown(surroundings)}...'\n" + f"\n**Original Message:**\n{escape_markdown(content)}" + ) + else: # Use original content + message_content = content + + additional_embeds = None + additional_embeds_msg = None + + self.bot.stats.incr(f"filters.{name}") + + # The function returns True for invalid invites. + # They have no data so additional embeds can't be created for them. + if name == "filter_invites" and match is not True: + additional_embeds = [] + for invite, data in match.items(): + embed = discord.Embed(description=( + f"**Members:**\n{data['members']}\n" + f"**Active:**\n{data['active']}" + )) + embed.set_author(name=data["name"]) + embed.set_thumbnail(url=data["icon"]) + embed.set_footer(text=f"Guild Invite Code: {invite}") + additional_embeds.append(embed) + additional_embeds_msg = "For the following guild(s):" + + elif name == "watch_rich_embeds": + additional_embeds = match + additional_embeds_msg = "With the following embed(s):" + + return message_content, additional_embeds, additional_embeds_msg + + @staticmethod + def _check_filter(msg: Message) -> bool: + """Check whitelists to see if we should filter this message.""" + role_whitelisted = False + + if type(msg.author) is Member: # Only Member has roles, not User. + for role in msg.author.roles: + if role.id in Filter.role_whitelist: + role_whitelisted = True + + return ( + msg.channel.id not in Filter.channel_whitelist # Channel not in whitelist + and not role_whitelisted # Role not in whitelist + and not msg.author.bot # Author not a bot + ) + @staticmethod async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]: """ @@ -428,7 +496,7 @@ class Filtering(Cog, Scheduler): return invite_data if invite_data else False @staticmethod - async def _has_rich_embed(msg: Message) -> bool: + async def _has_rich_embed(msg: Message) -> Union[bool, List[discord.Embed]]: """Determines if `msg` contains any rich embeds not auto-generated from a URL.""" if msg.embeds: for embed in msg.embeds: @@ -437,7 +505,7 @@ class Filtering(Cog, Scheduler): if not embed.url or embed.url not in urls: # If `embed.url` does not exist or if `embed.url` is not part of the content # of the message, it's unlikely to be an auto-generated embed by Discord. - return True + return msg.embeds else: log.trace( "Found a rich embed sent by a regular user account, " -- cgit v1.2.3 From 63846d17a851c97fe073e5c1e27cd65719d2c854 Mon Sep 17 00:00:00 2001 From: Slushie Date: Wed, 8 Jul 2020 21:35:04 +0100 Subject: Call the filter eval command after receiving an eval result --- bot/cogs/snekbox.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index a2a7574d4..649bab492 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -212,7 +212,12 @@ class Snekbox(Cog): else: self.bot.stats.incr("snekbox.python.success") - response = await ctx.send(msg) + filter_cog = self.bot.get_cog("Filtering") + filter_triggered = await filter_cog._filter_eval(msg, ctx.message) + if filter_triggered: + response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") + else: + response = await ctx.send(msg) self.bot.loop.create_task( wait_for_deletion(response, user_ids=(ctx.author.id,), client=ctx.bot) ) -- cgit v1.2.3 From 9174125a41793d4703a81dc6783f4244f1634d27 Mon Sep 17 00:00:00 2001 From: swfarnsworth Date: Wed, 8 Jul 2020 17:51:04 -0400 Subject: Removed hard line breaks --- bot/resources/tags/range-len.md | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/bot/resources/tags/range-len.md b/bot/resources/tags/range-len.md index b1c973647..9b88aab47 100644 --- a/bot/resources/tags/range-len.md +++ b/bot/resources/tags/range-len.md @@ -1,19 +1,15 @@ -Iterating over `range(len(...))` is a common approach to accessing each item -in an ordered collection. +Iterating over `range(len(...))` is a common approach to accessing each item in an ordered collection. ```py for i in range(len(my_list)): do_something(my_list[i]) ``` -The pythonic syntax is much simpler, and is -guaranteed to produce elements in the same order: +The pythonic syntax is much simpler, and is guaranteed to produce elements in the same order: ```py for item in my_list: do_something(item) ``` -Python has other solutions for cases when the index itself might be needed. -To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). -To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). +Python has other solutions for cases when the index itself might be needed. To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). -- cgit v1.2.3 From 918e1b9ca628abd7867812b32096d05dcf69f32f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 9 Jul 2020 12:07:30 +0200 Subject: Incidents: use `moderation_roles` constant Better than building the set manually. Tested against regression by comparing the two sets for equality. Suggested by vivax. Co-authored-by: vivax3794 <51753506+vivax3794@users.noreply.github.com> --- bot/cogs/moderation/incidents.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 1a12c8bbd..be46c8202 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -8,7 +8,7 @@ import discord from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import Channels, Colours, Emojis, Roles, Webhooks +from bot.constants import Channels, Colours, Emojis, Guild, Webhooks from bot.utils.messages import sub_clyde log = logging.getLogger(__name__) @@ -35,8 +35,8 @@ class Signal(Enum): INVESTIGATING = Emojis.incident_investigating -# Reactions from roles not listed here will be removed -ALLOWED_ROLES: t.Set[int] = {Roles.moderators, Roles.admins, Roles.owners} +# Reactions from non-mod roles will be removed +ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) # Message must have all of these emoji to pass the `has_signals` check ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} -- cgit v1.2.3 From ddb1f556ace346a97b8639f278fae8915078e78d Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 9 Jul 2020 12:11:16 +0200 Subject: Incidents tests: improve in-line comment wording Co-authored-by: MarkKoz --- tests/bot/cogs/moderation/test_incidents.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index f8d479cef..789a37cd4 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -318,7 +318,7 @@ class TestArchive(TestIncidents): webhook = MockAsyncWebhook() self.cog_instance.bot.fetch_webhook = AsyncMock(return_value=webhook) # Patch in our webhook - # Define our own `incident` for archivation + # Define our own `incident` to be archived incident = MockMessage( content="this is an incident", author=MockUser(name="author_name", avatar_url="author_avatar"), -- cgit v1.2.3 From d10a61d3ef21cbf511304a97a5e2871bd1fcb2dd Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 9 Jul 2020 12:18:21 +0200 Subject: Config: refactor #incidents constants to lexicographical sorting Co-authored-by: MarkKoz --- bot/constants.py | 6 +++--- config-default.yml | 18 +++++++++--------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index b3ef1660f..cd660acee 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -427,12 +427,12 @@ class Webhooks(metaclass=YAMLGetter): section = "guild" subsection = "webhooks" - talent_pool: int big_brother: int - reddit: int - duck_pond: int dev_log: int + duck_pond: int incidents_archive: int + reddit: int + talent_pool: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 4c0196dc5..7fcc27d64 100644 --- a/config-default.yml +++ b/config-default.yml @@ -171,13 +171,13 @@ guild: admin_spam: &ADMIN_SPAM 563594791770914816 defcon: &DEFCON 464469101889454091 helpers: &HELPERS 385474242440986624 + incidents: 714214212200562749 + incidents_archive: 720668923636351037 mods: &MODS 305126844661760000 mod_alerts: &MOD_ALERTS 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 - incidents: 714214212200562749 - incidents_archive: 720668923636351037 # Voice admins_voice: &ADMINS_VOICE 500734494840717332 @@ -250,13 +250,13 @@ guild: - *HELPERS_ROLE webhooks: - talent_pool: 569145364800602132 - big_brother: 569133704568373283 - reddit: 635408384794951680 - duck_pond: 637821475327311927 - dev_log: 680501655111729222 - python_news: &PYNEWS_WEBHOOK 704381182279942324 - incidents_archive: 720671599790915702 + big_brother: 569133704568373283 + dev_log: 680501655111729222 + duck_pond: 637821475327311927 + incidents_archive: 720671599790915702 + python_news: &PYNEWS_WEBHOOK 704381182279942324 + reddit: 635408384794951680 + talent_pool: 569145364800602132 filter: -- cgit v1.2.3 From 5f73f40eeb025e6694443a8bc4535df894b83e4f Mon Sep 17 00:00:00 2001 From: slushiegoose <38522108+slushiegoose@users.noreply.github.com> Date: Thu, 9 Jul 2020 15:29:03 +0100 Subject: Fix missing hypen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Leon Sandøy --- bot/cogs/error_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index a7f8074e2..233851e41 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -170,7 +170,7 @@ class ErrorHandler(Cog): await prepared_help_command self.bot.stats.incr("errors.too_many_arguments") elif isinstance(e, errors.BadArgument): - await ctx.send("Bad argument: Please double check your input arguments and try again.\n") + await ctx.send("Bad argument: Please double-check your input arguments and try again.\n") await prepared_help_command self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): -- cgit v1.2.3 From 288c6526e03388bf7ff5b3b1e8b861ad1a7f6e63 Mon Sep 17 00:00:00 2001 From: Slushie Date: Thu, 9 Jul 2020 15:32:27 +0100 Subject: Add missing variable assignment to stop NameErrors occurring --- bot/cogs/filtering.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index ae77ad7f0..4c97073c3 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -207,6 +207,7 @@ class Filtering(Cog, Scheduler): Also requires the original message, to check whether to filter and for mod logs. Returns whether a filter was triggered or not. """ + filter_triggered = False # Should we filter this message? if self._check_filter(msg): for filter_name, _filter in self.filters.items(): -- cgit v1.2.3 From 8cba21c353a728d2c09ad82a425c46ce3f03abf0 Mon Sep 17 00:00:00 2001 From: Steele Farnsworth <32915757+swfarnsworth@users.noreply.github.com> Date: Thu, 9 Jul 2020 11:20:01 -0400 Subject: Update range-len.md Removed all blank lines to improve how it's rendered on Discord; thanks @kwzrd for rendering this! --- bot/resources/tags/range-len.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/resources/tags/range-len.md b/bot/resources/tags/range-len.md index 9b88aab47..65665eccf 100644 --- a/bot/resources/tags/range-len.md +++ b/bot/resources/tags/range-len.md @@ -1,15 +1,11 @@ Iterating over `range(len(...))` is a common approach to accessing each item in an ordered collection. - ```py for i in range(len(my_list)): do_something(my_list[i]) ``` - The pythonic syntax is much simpler, and is guaranteed to produce elements in the same order: - ```py for item in my_list: do_something(item) ``` - Python has other solutions for cases when the index itself might be needed. To get the element at the same index from two or more lists, use [zip](https://docs.python.org/3/library/functions.html#zip). To get both the index and the element at that index, use [enumerate](https://docs.python.org/3/library/functions.html#enumerate). -- cgit v1.2.3 From 39651d0410ed292a5f761d9595ba79833dfa167c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 9 Jul 2020 10:40:47 -0700 Subject: Update discord.py to fix issue with overwrites Fixes BOT-6T --- Pipfile | 2 +- Pipfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Pipfile b/Pipfile index 29aa1a08f..2d6b45aa9 100644 --- a/Pipfile +++ b/Pipfile @@ -12,7 +12,7 @@ beautifulsoup4 = "~=4.9" colorama = {version = "~=0.4.3",sys_platform = "== 'win32'"} coloredlogs = "~=14.0" deepdiff = "~=4.0" -discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "e971e2f16cba22decd25db6b44e9cc84adf08555"} +discord-py = {git = "https://github.com/Rapptz/discord.py.git",ref = "0bc15fa130b8f01fe2d67446a2184d474b0d0ba7"} fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" diff --git a/Pipfile.lock b/Pipfile.lock index a522e20d3..4b9d092d4 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "6404ca2550369b6416801688b4382d22fdba178d9319c4a68bd207d1e5aaeaab" + "sha256": "8a53baefbbd2a0f3fbaf831f028b23d257a5e28b5efa1260661d74604f4113b8" }, "pipfile-spec": 6, "requires": { @@ -179,7 +179,7 @@ }, "discord-py": { "git": "https://github.com/Rapptz/discord.py.git", - "ref": "e971e2f16cba22decd25db6b44e9cc84adf08555" + "ref": "0bc15fa130b8f01fe2d67446a2184d474b0d0ba7" }, "docutils": { "hashes": [ -- cgit v1.2.3 From 210c0a09b1bced80d03ed9ac81845f5f94c8b687 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 13:23:22 +0200 Subject: Ping @Moderators in ModLog Instead of pinging @everyone, let's just ping the people who actually need to see the mod alerts or the modlogs, which would be the mods. `@everyone` is currently not permitted by our allowed_mentions setting, so this also restores pings to those channels. GitHub #1038 https://github.com/python-discord/bot/issues/1038 --- bot/cogs/antispam.py | 4 ++-- bot/cogs/filtering.py | 2 +- bot/cogs/moderation/modlog.py | 10 +++++----- bot/cogs/watchchannels/watchchannel.py | 4 ++-- bot/constants.py | 4 ++-- config-default.yml | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..71382bba9 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -98,7 +98,7 @@ class DeletionContext: text=mod_alert_message, thumbnail=last_message.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=AntiSpamConfig.ping_everyone + ping_moderators=AntiSpamConfig.ping_moderators ) @@ -132,7 +132,7 @@ class AntiSpam(Cog): await self.mod_log.send_log_message( title="Error: AntiSpam configuration validation failed!", text=body, - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Colour.red() ) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 76ea68660..a5d59085f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -329,7 +329,7 @@ class Filtering(Cog, Scheduler): text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, + ping_moderators=Filter.ping_moderators, additional_embeds=additional_embeds, additional_embeds_msg=additional_embeds_msg ) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index ffbb87bbe..a37a9faf5 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context from discord.utils import escape_markdown from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -88,7 +88,7 @@ class ModLog(Cog, name="ModLog"): text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, channel_id: int = Channels.mod_log, - ping_everyone: bool = False, + ping_moderators: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, additional_embeds: t.Optional[t.List[discord.Embed]] = None, @@ -114,11 +114,11 @@ class ModLog(Cog, name="ModLog"): if thumbnail: embed.set_thumbnail(url=thumbnail) - if ping_everyone: + if ping_moderators: if content: - content = f"@everyone\n{content}" + content = f"<@&{Roles.moderators}>\n{content}" else: - content = "@everyone" + content = f"<@&{Roles.moderators}>" channel = self.bot.get_channel(channel_id) log_message = await channel.send(content=content, embed=embed, files=files) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 7c58a0fb5..8c4af4581 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -120,7 +120,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", text=message, - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Color.red() ) @@ -132,7 +132,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_everyone=True, + ping_moderators=True, icon_url=Icons.token_removed, colour=Color.red() ) diff --git a/bot/constants.py b/bot/constants.py index a1b392c82..34b312d2d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -225,7 +225,7 @@ class Filter(metaclass=YAMLGetter): notify_user_invites: bool notify_user_domains: bool - ping_everyone: bool + ping_moderators: bool offensive_msg_delete_days: int guild_invite_whitelist: List[int] domain_blacklist: List[str] @@ -522,7 +522,7 @@ class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' clean_offending: bool - ping_everyone: bool + ping_moderators: bool punishment: Dict[str, Dict[str, int]] rules: Dict[str, Dict[str, int]] diff --git a/config-default.yml b/config-default.yml index 64c4e715b..5dd96d67a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_everyone: true # Ping @everyone when we send a mod-alert? + ping_moderators: true # Ping @everyone when we send a mod-alert? offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: @@ -428,7 +428,7 @@ urls: anti_spam: # Clean messages that violate a rule. clean_offending: true - ping_everyone: true + ping_moderators: true punishment: role_id: *MUTED_ROLE -- cgit v1.2.3 From 57e210ccfcc91132182029f1d931118e715439b2 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 13:38:02 +0200 Subject: Allow role pings in Syncers and help_channels.py Now that we're running Discord 1.4.0a, we need to explicitely allow all the role mentions for sends that don't use ping one of the globally whitelisted role pings, which are Moderators, Admins and Owners. We were pinging roles other than Mods+ in exactly two cases: - Inside the Syncers, whenever we ask for sync confirmation (if the number of roles or users to sync is unusually high) - In the help_channels.py system, whenever we max out help channels and are unable to create more. This commit addresses both of these. GitHub #1038 https://github.com/python-discord/bot/issues/1038 --- bot/cogs/help_channels.py | 4 +++- bot/cogs/sync/syncers.py | 7 ++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 187adfe51..fd1a449c1 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -624,11 +624,13 @@ class HelpChannels(Scheduler, commands.Cog): channel = self.bot.get_channel(constants.HelpChannels.notify_channel) mentions = " ".join(f"<@&{role}>" for role in constants.HelpChannels.notify_roles) + allowed_roles = [discord.Object(id_) for id_ in constants.HelpChannels.notify_roles] message = await channel.send( f"{mentions} A new available help channel is needed but there " f"are no more dormant ones. Consider freeing up some in-use channels manually by " - f"using the `{constants.Bot.prefix}dormant` command within the channels." + f"using the `{constants.Bot.prefix}dormant` command within the channels.", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) ) self.bot.stats.incr("help.out_of_channel_alerts") diff --git a/bot/cogs/sync/syncers.py b/bot/cogs/sync/syncers.py index 536455668..f7ba811bc 100644 --- a/bot/cogs/sync/syncers.py +++ b/bot/cogs/sync/syncers.py @@ -5,6 +5,7 @@ import typing as t from collections import namedtuple from functools import partial +import discord from discord import Guild, HTTPException, Member, Message, Reaction, User from discord.ext.commands import Context @@ -68,7 +69,11 @@ class Syncer(abc.ABC): ) return None - message = await channel.send(f"{self._CORE_DEV_MENTION}{msg_content}") + allowed_roles = [discord.Object(constants.Roles.core_developers)] + message = await channel.send( + f"{self._CORE_DEV_MENTION}{msg_content}", + allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles) + ) else: await message.edit(content=msg_content) -- cgit v1.2.3 From cb5e361d04cd9c430bca4fb3496284e469d35c98 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 14:40:26 +0200 Subject: Add the #dm_log ID to constants. https://github.com/python-discord/bot/issues/667 --- bot/constants.py | 1 + config-default.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index a1b392c82..074699025 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -416,6 +416,7 @@ class Channels(metaclass=YAMLGetter): user_log: int verification: int voice_log: int + dm_log: int class Webhooks(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index 64c4e715b..d3ba45f88 100644 --- a/config-default.yml +++ b/config-default.yml @@ -150,6 +150,7 @@ guild: mod_log: &MOD_LOG 282638479504965634 user_log: 528976905546760203 voice_log: 640292421988646961 + dm_log: 653713721625018428 # Off-topic off_topic_0: 291284109232308226 -- cgit v1.2.3 From 9042325f06523e04a2c51b39fd20436cd6eaa3fc Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 14:57:15 +0200 Subject: Refactor Duck Pond embed sender to be a util. https://github.com/python-discord/bot/issues/667 --- bot/cogs/duck_pond.py | 30 ++++++++---------------------- bot/utils/webhooks.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 22 deletions(-) create mode 100644 bot/utils/webhooks.py diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 5b6a7fd62..89b4ad0e4 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -1,5 +1,5 @@ import logging -from typing import Optional, Union +from typing import Union import discord from discord import Color, Embed, Member, Message, RawReactionActionEvent, User, errors @@ -7,7 +7,8 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot -from bot.utils.messages import send_attachments, sub_clyde +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook log = logging.getLogger(__name__) @@ -18,6 +19,7 @@ class DuckPond(Cog): def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.duck_pond + self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) async def fetch_webhook(self) -> None: @@ -47,24 +49,6 @@ class DuckPond(Cog): return True return False - async def send_webhook( - self, - content: Optional[str] = None, - username: Optional[str] = None, - avatar_url: Optional[str] = None, - embed: Optional[Embed] = None, - ) -> None: - """Send a webhook to the duck_pond channel.""" - try: - await self.webhook.send( - content=content, - username=sub_clyde(username), - avatar_url=avatar_url, - embed=embed - ) - except discord.HTTPException: - log.exception("Failed to send a message to the Duck Pool webhook") - async def count_ducks(self, message: Message) -> int: """ Count the number of ducks in the reactions of a specific message. @@ -97,7 +81,8 @@ class DuckPond(Cog): clean_content = message.clean_content if clean_content: - await self.send_webhook( + await send_webhook( + webhook=self.webhook, content=message.clean_content, username=message.author.display_name, avatar_url=message.author.avatar_url @@ -111,7 +96,8 @@ class DuckPond(Cog): description=":x: **This message contained an attachment, but it could not be retrieved**", color=Color.red() ) - await self.send_webhook( + await send_webhook( + webhook=self.webhook, embed=e, username=message.author.display_name, avatar_url=message.author.avatar_url diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py new file mode 100644 index 000000000..37fdfe907 --- /dev/null +++ b/bot/utils/webhooks.py @@ -0,0 +1,34 @@ +import logging +from typing import Optional + +import discord +from discord import Embed + +from bot.utils.messages import sub_clyde + +log = logging.getLogger(__name__) + + +async def send_webhook( + webhook: discord.Webhook, + content: Optional[str] = None, + username: Optional[str] = None, + avatar_url: Optional[str] = None, + embed: Optional[Embed] = None, + wait: Optional[bool] = False +) -> None: + """ + Send a message using the provided webhook. + + This uses sub_clyde() and tries for an HTTPException to ensure it doesn't crash. + """ + try: + await webhook.send( + content=content, + username=sub_clyde(username), + avatar_url=avatar_url, + embed=embed, + wait=wait, + ) + except discord.HTTPException: + log.exception("Failed to send a message to the webhook!") -- cgit v1.2.3 From 3fd89d59081f2c906fa43265471d235f4f5b4749 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:08:54 +0200 Subject: Remove pointless comment This comment violates the DRY principle. Co-authored-by: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 5dd96d67a..0f6a25ef2 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_moderators: true # Ping @everyone when we send a mod-alert? + ping_moderators: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: -- cgit v1.2.3 From ef65033eaed01a2459561dd9fe37133b595f3d3a Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:10:00 +0200 Subject: Refactor python_news.py to use webhook util. https://github.com/python-discord/bot/issues/667 --- bot/cogs/python_news.py | 70 ++++++++++++++++++++----------------------------- bot/utils/webhooks.py | 4 +-- 2 files changed, 31 insertions(+), 43 deletions(-) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index adefd5c7c..1d8f2aeb0 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -10,7 +10,7 @@ from discord.ext.tasks import loop from bot import constants from bot.bot import Bot -from bot.utils.messages import sub_clyde +from bot.utils.webhooks import send_webhook PEPS_RSS_URL = "https://www.python.org/dev/peps/peps.rss/" @@ -100,13 +100,20 @@ class PythonNews(Cog): ): continue - msg = await self.send_webhook( + # Build an embed and send a webhook + embed = discord.Embed( title=new["title"], description=new["summary"], timestamp=new_datetime, url=new["link"], - webhook_profile_name=data["feed"]["title"], - footer=data["feed"]["title"] + colour=constants.Colours.soft_green + ) + embed.set_footer(text=data["feed"]["title"], icon_url=AVATAR_URL) + msg = await send_webhook( + webhook=self.webhook, + username=data["feed"]["title"], + embed=embed, + wait=True, ) payload["data"]["pep"].append(pep_nr) @@ -161,15 +168,28 @@ class PythonNews(Cog): content = email_information["content"] link = THREAD_URL.format(id=thread["href"].split("/")[-2], list=maillist) - msg = await self.send_webhook( + + # Build an embed and send a message to the webhook + embed = discord.Embed( title=thread_information["subject"], description=content[:500] + f"... [continue reading]({link})" if len(content) > 500 else content, timestamp=new_date, url=link, - author=f"{email_information['sender_name']} ({email_information['sender']['address']})", - author_url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), - webhook_profile_name=self.webhook_names[maillist], - footer=f"Posted to {self.webhook_names[maillist]}" + colour=constants.Colours.soft_green + ) + embed.set_author( + name=f"{email_information['sender_name']} ({email_information['sender']['address']})", + url=MAILMAN_PROFILE_URL.format(id=email_information["sender"]["mailman_id"]), + ) + embed.set_footer( + text=f"Posted to {self.webhook_names[maillist]}", + icon_url=AVATAR_URL, + ) + msg = await send_webhook( + webhook=self.webhook, + username=self.webhook_names[maillist], + embed=embed, + wait=True, ) payload["data"][maillist].append(thread_information["thread_id"]) @@ -182,38 +202,6 @@ class PythonNews(Cog): await self.bot.api_client.put("bot/bot-settings/news", json=payload) - async def send_webhook(self, - title: str, - description: str, - timestamp: datetime, - url: str, - webhook_profile_name: str, - footer: str, - author: t.Optional[str] = None, - author_url: t.Optional[str] = None, - ) -> discord.Message: - """Send webhook entry and return sent message.""" - embed = discord.Embed( - title=title, - description=description, - timestamp=timestamp, - url=url, - colour=constants.Colours.soft_green - ) - if author and author_url: - embed.set_author( - name=author, - url=author_url - ) - embed.set_footer(text=footer, icon_url=AVATAR_URL) - - return await self.webhook.send( - embed=embed, - username=sub_clyde(webhook_profile_name), - avatar_url=AVATAR_URL, - wait=True - ) - async def get_thread_and_first_mail(self, maillist: str, thread_identifier: str) -> t.Tuple[t.Any, t.Any]: """Get mail thread and first mail from mail.python.org based on `maillist` and `thread_identifier`.""" async with self.bot.http_session.get( diff --git a/bot/utils/webhooks.py b/bot/utils/webhooks.py index 37fdfe907..66f82ec66 100644 --- a/bot/utils/webhooks.py +++ b/bot/utils/webhooks.py @@ -16,14 +16,14 @@ async def send_webhook( avatar_url: Optional[str] = None, embed: Optional[Embed] = None, wait: Optional[bool] = False -) -> None: +) -> discord.Message: """ Send a message using the provided webhook. This uses sub_clyde() and tries for an HTTPException to ensure it doesn't crash. """ try: - await webhook.send( + return await webhook.send( content=content, username=sub_clyde(username), avatar_url=avatar_url, -- cgit v1.2.3 From 3fce243e15996eb81157c198544fcc705e46e1e6 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:27:23 +0200 Subject: Relay all DMs and embeds to #dm-log. https://github.com/python-discord/bot/issues/667 --- bot/__main__.py | 1 + bot/cogs/dm_relay.py | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 bot/cogs/dm_relay.py diff --git a/bot/__main__.py b/bot/__main__.py index 37e62c2f1..49388455a 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -54,6 +54,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") bot.load_extension("bot.cogs.defcon") +bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.eval") bot.load_extension("bot.cogs.information") diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py new file mode 100644 index 000000000..32ac0e4ee --- /dev/null +++ b/bot/cogs/dm_relay.py @@ -0,0 +1,66 @@ +import logging + +import discord +from discord import Color +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.utils.messages import send_attachments +from bot.utils.webhooks import send_webhook + +log = logging.getLogger(__name__) + + +class DMRelay(Cog): + """Debug logging module.""" + + def __init__(self, bot: Bot): + self.bot = bot + self.webhook_id = constants.Webhooks.dm_log + self.webhook = None + self.bot.loop.create_task(self.fetch_webhook()) + + async def fetch_webhook(self) -> None: + """Fetches the webhook object, so we can post to it.""" + await self.bot.wait_until_guild_available() + + try: + self.webhook = await self.bot.fetch_webhook(self.webhook_id) + except discord.HTTPException: + log.exception(f"Failed to fetch webhook with id `{self.webhook_id}`") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Relays the message's content and attachments to the dm_log channel.""" + clean_content = message.clean_content + if clean_content: + await send_webhook( + webhook=self.webhook, + content=message.clean_content, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + + # Handle any attachments + if message.attachments: + try: + await send_attachments(message, self.webhook) + except (discord.errors.Forbidden, discord.errors.NotFound): + e = discord.Embed( + description=":x: **This message contained an attachment, but it could not be retrieved**", + color=Color.red() + ) + await send_webhook( + webhook=self.webhook, + embed=e, + username=message.author.display_name, + avatar_url=message.author.avatar_url + ) + except discord.HTTPException: + log.exception("Failed to send an attachment to the webhook") + + +def setup(bot: Bot) -> None: + """Load the DMRelay cog.""" + bot.add_cog(DMRelay(bot)) -- cgit v1.2.3 From 5007e736b93017003f02a75d12ce1ef8bae9fd69 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:34:39 +0200 Subject: Replace channel ID with webhook ID for dm_log. https://github.com/python-discord/bot/issues/667 --- bot/constants.py | 2 +- config-default.yml | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 074699025..3f44003a8 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -416,7 +416,6 @@ class Channels(metaclass=YAMLGetter): user_log: int verification: int voice_log: int - dm_log: int class Webhooks(metaclass=YAMLGetter): @@ -428,6 +427,7 @@ class Webhooks(metaclass=YAMLGetter): reddit: int duck_pond: int dev_log: int + dm_log: int class Roles(metaclass=YAMLGetter): diff --git a/config-default.yml b/config-default.yml index d3ba45f88..c09902a5d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -150,7 +150,6 @@ guild: mod_log: &MOD_LOG 282638479504965634 user_log: 528976905546760203 voice_log: 640292421988646961 - dm_log: 653713721625018428 # Off-topic off_topic_0: 291284109232308226 @@ -252,10 +251,9 @@ guild: duck_pond: 637821475327311927 dev_log: 680501655111729222 python_news: &PYNEWS_WEBHOOK 704381182279942324 - + dm_log: 654567640664244225 filter: - # What do we filter? filter_zalgo: false filter_invites: true -- cgit v1.2.3 From 4349fdedaae43f35f9821aa61c91a1e76908b0b5 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 15:39:40 +0200 Subject: Only relay DMs, and only from humans. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 32ac0e4ee..bb060fe90 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -33,6 +33,10 @@ class DMRelay(Cog): @Cog.listener() async def on_message(self, message: discord.Message) -> None: """Relays the message's content and attachments to the dm_log channel.""" + # Only relay DMs from humans + if message.author.bot or message.guild: + return + clean_content = message.clean_content if clean_content: await send_webhook( -- cgit v1.2.3 From df1730ef5d51223fe1d5a2cfe8c027e5177ae9c7 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 16:30:03 +0200 Subject: Fix DuckPond tests now that send_webhook is gone. Some of the tests were failing because they were expecting send_webhook to be a method of the DuckPond cog, other tests simply were no longer applicable, and have been removed. https://github.com/python-discord/bot/issues/667 --- tests/bot/cogs/test_duck_pond.py | 51 ++++++++++------------------------------ 1 file changed, 12 insertions(+), 39 deletions(-) diff --git a/tests/bot/cogs/test_duck_pond.py b/tests/bot/cogs/test_duck_pond.py index a8c0107c6..cfe10aebf 100644 --- a/tests/bot/cogs/test_duck_pond.py +++ b/tests/bot/cogs/test_duck_pond.py @@ -129,38 +129,6 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): ): self.assertEqual(expected_return, actual_return) - def test_send_webhook_correctly_passes_on_arguments(self): - """The `send_webhook` method should pass the arguments to the webhook correctly.""" - self.cog.webhook = helpers.MockAsyncWebhook() - - content = "fake content" - username = "fake username" - avatar_url = "fake avatar_url" - embed = "fake embed" - - asyncio.run(self.cog.send_webhook(content, username, avatar_url, embed)) - - self.cog.webhook.send.assert_called_once_with( - content=content, - username=username, - avatar_url=avatar_url, - embed=embed - ) - - def test_send_webhook_logs_when_sending_message_fails(self): - """The `send_webhook` method should catch a `discord.HTTPException` and log accordingly.""" - self.cog.webhook = helpers.MockAsyncWebhook() - self.cog.webhook.send.side_effect = discord.HTTPException(response=MagicMock(), message="Something failed.") - - log = logging.getLogger('bot.cogs.duck_pond') - with self.assertLogs(logger=log, level=logging.ERROR) as log_watcher: - asyncio.run(self.cog.send_webhook()) - - self.assertEqual(len(log_watcher.records), 1) - - record = log_watcher.records[0] - self.assertEqual(record.levelno, logging.ERROR) - def _get_reaction( self, emoji: typing.Union[str, helpers.MockEmoji], @@ -280,16 +248,20 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): async def test_relay_message_correctly_relays_content_and_attachments(self): """The `relay_message` method should correctly relay message content and attachments.""" - send_webhook_path = f"{MODULE_PATH}.DuckPond.send_webhook" + send_webhook_path = f"{MODULE_PATH}.send_webhook" send_attachments_path = f"{MODULE_PATH}.send_attachments" + author = MagicMock( + display_name="x", + avatar_url="https://" + ) self.cog.webhook = helpers.MockAsyncWebhook() test_values = ( - (helpers.MockMessage(clean_content="", attachments=[]), False, False), - (helpers.MockMessage(clean_content="message", attachments=[]), True, False), - (helpers.MockMessage(clean_content="", attachments=["attachment"]), False, True), - (helpers.MockMessage(clean_content="message", attachments=["attachment"]), True, True), + (helpers.MockMessage(author=author, clean_content="", attachments=[]), False, False), + (helpers.MockMessage(author=author, clean_content="message", attachments=[]), True, False), + (helpers.MockMessage(author=author, clean_content="", attachments=["attachment"]), False, True), + (helpers.MockMessage(author=author, clean_content="message", attachments=["attachment"]), True, True), ) for message, expect_webhook_call, expect_attachment_call in test_values: @@ -314,14 +286,14 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): for side_effect in side_effects: # pragma: no cover send_attachments.side_effect = side_effect - with patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) as send_webhook: + with patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) as send_webhook: with self.subTest(side_effect=type(side_effect).__name__): with self.assertNotLogs(logger=log, level=logging.ERROR): await self.cog.relay_message(message) self.assertEqual(send_webhook.call_count, 2) - @patch(f"{MODULE_PATH}.DuckPond.send_webhook", new_callable=AsyncMock) + @patch(f"{MODULE_PATH}.send_webhook", new_callable=AsyncMock) @patch(f"{MODULE_PATH}.send_attachments", new_callable=AsyncMock) async def test_relay_message_handles_attachment_http_error(self, send_attachments, send_webhook): """The `relay_message` method should handle irretrievable attachments.""" @@ -337,6 +309,7 @@ class DuckPondTests(base.LoggingTestsMixin, unittest.IsolatedAsyncioTestCase): await self.cog.relay_message(message) send_webhook.assert_called_once_with( + webhook=self.cog.webhook, content=message.clean_content, username=message.author.display_name, avatar_url=message.author.avatar_url -- cgit v1.2.3 From aaf8db7550e8b95354d6f079c99ef2beb400cac8 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 12 Jul 2020 20:19:54 +0200 Subject: Add a way to respond to DMs. This shouldn't be used as a replacement for ModMail, but I think it makes sense to have the feature just in case #dm-log provides an interesting use-case where responding as the bot makes sense. It's a bit of a curiosity, and Ves hates it, but I included it anyway. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index bb060fe90..df19000fe 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -1,7 +1,9 @@ import logging +from typing import Optional import discord from discord import Color +from discord.ext import commands from discord.ext.commands import Cog from bot import constants @@ -20,6 +22,32 @@ class DMRelay(Cog): self.webhook_id = constants.Webhooks.dm_log self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) + self.last_dm_user = None + + @commands.command(aliases=("reply",)) + async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: + """ + Allows you to send a DM to a user from the bot. + + If `member` is not provided, it will send to the last user who DM'd the bot. + + This feature should be used extremely sparingly. Use ModMail if you need to have a serious + conversation with a user. This is just for responding to extraordinary DMs, having a little + fun with users, and telling people they are DMing the wrong bot. + + NOTE: This feature will be removed if it is overused. + """ + if member: + await member.send(message) + await ctx.message.add_reaction("✅") + return + elif self.last_dm_user: + await self.last_dm_user.send(message) + await ctx.message.add_reaction("✅") + return + else: + log.debug("Unable to send a DM to the user.") + await ctx.message.add_reaction("❌") async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" @@ -45,6 +73,7 @@ class DMRelay(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) + self.last_dm_user = message.author # Handle any attachments if message.attachments: -- cgit v1.2.3 From 4527c038d21149d4d3fab73c54b9a1ad31e671c0 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 00:17:37 +0200 Subject: Revert "Ping @Moderators in ModLog" Let's continue to use "@everyone" for now, and add an explicit allow for it so that it successfully pings people. There's a full justification for this in the pull request. https://github.com/python-discord/bot/issues/1038 --- bot/cogs/antispam.py | 4 ++-- bot/cogs/filtering.py | 2 +- bot/cogs/moderation/modlog.py | 17 +++++++++++------ bot/cogs/watchchannels/watchchannel.py | 4 ++-- bot/constants.py | 4 ++-- config-default.yml | 4 ++-- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 71382bba9..0bcca578d 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -98,7 +98,7 @@ class DeletionContext: text=mod_alert_message, thumbnail=last_message.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_moderators=AntiSpamConfig.ping_moderators + ping_everyone=AntiSpamConfig.ping_everyone ) @@ -132,7 +132,7 @@ class AntiSpam(Cog): await self.mod_log.send_log_message( title="Error: AntiSpam configuration validation failed!", text=body, - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Colour.red() ) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index a5d59085f..76ea68660 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -329,7 +329,7 @@ class Filtering(Cog, Scheduler): text=message, thumbnail=msg.author.avatar_url_as(static_format="png"), channel_id=Channels.mod_alerts, - ping_moderators=Filter.ping_moderators, + ping_everyone=Filter.ping_everyone, additional_embeds=additional_embeds, additional_embeds_msg=additional_embeds_msg ) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index a37a9faf5..0a63f57b8 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -15,7 +15,7 @@ from discord.ext.commands import Cog, Context from discord.utils import escape_markdown from bot.bot import Bot -from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, Roles, URLs +from bot.constants import Categories, Channels, Colours, Emojis, Event, Guild as GuildConstant, Icons, URLs from bot.utils.time import humanize_delta log = logging.getLogger(__name__) @@ -88,7 +88,7 @@ class ModLog(Cog, name="ModLog"): text: str, thumbnail: t.Optional[t.Union[str, discord.Asset]] = None, channel_id: int = Channels.mod_log, - ping_moderators: bool = False, + ping_everyone: bool = False, files: t.Optional[t.List[discord.File]] = None, content: t.Optional[str] = None, additional_embeds: t.Optional[t.List[discord.Embed]] = None, @@ -114,14 +114,19 @@ class ModLog(Cog, name="ModLog"): if thumbnail: embed.set_thumbnail(url=thumbnail) - if ping_moderators: + if ping_everyone: if content: - content = f"<@&{Roles.moderators}>\n{content}" + content = f"@everyone\n{content}" else: - content = f"<@&{Roles.moderators}>" + content = "@everyone" channel = self.bot.get_channel(channel_id) - log_message = await channel.send(content=content, embed=embed, files=files) + log_message = await channel.send( + content=content, + embed=embed, + files=files, + allowed_mentions=discord.AllowedMentions(everyone=True) + ) if additional_embeds: if additional_embeds_msg: diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 8c4af4581..7c58a0fb5 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -120,7 +120,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Error: Failed to initialize the {self.__class__.__name__} watch channel", text=message, - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Color.red() ) @@ -132,7 +132,7 @@ class WatchChannel(metaclass=CogABCMeta): await self.modlog.send_log_message( title=f"Warning: Failed to retrieve user cache for the {self.__class__.__name__} watch channel", text="Could not retrieve the list of watched users from the API and messages will not be relayed.", - ping_moderators=True, + ping_everyone=True, icon_url=Icons.token_removed, colour=Color.red() ) diff --git a/bot/constants.py b/bot/constants.py index 34b312d2d..a1b392c82 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -225,7 +225,7 @@ class Filter(metaclass=YAMLGetter): notify_user_invites: bool notify_user_domains: bool - ping_moderators: bool + ping_everyone: bool offensive_msg_delete_days: int guild_invite_whitelist: List[int] domain_blacklist: List[str] @@ -522,7 +522,7 @@ class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' clean_offending: bool - ping_moderators: bool + ping_everyone: bool punishment: Dict[str, Dict[str, int]] rules: Dict[str, Dict[str, int]] diff --git a/config-default.yml b/config-default.yml index 0f6a25ef2..636b9db37 100644 --- a/config-default.yml +++ b/config-default.yml @@ -269,7 +269,7 @@ filter: notify_user_domains: false # Filter configuration - ping_moderators: true + ping_everyone: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? guild_invite_whitelist: @@ -428,7 +428,7 @@ urls: anti_spam: # Clean messages that violate a rule. clean_offending: true - ping_moderators: true + ping_everyone: true punishment: role_id: *MUTED_ROLE -- cgit v1.2.3 From e1c3b66f5f4d1f421d6469bd4f0964166262832c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 12 Jul 2020 23:49:46 -0700 Subject: Fix rescheduling of edited infractions It was attempting to schedule a dictionary instead of a coroutine. Fixes #1043 Fixes BOT-6Y --- bot/cogs/moderation/management.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/moderation/management.py b/bot/cogs/moderation/management.py index 4ef9d4209..672bb0e9c 100644 --- a/bot/cogs/moderation/management.py +++ b/bot/cogs/moderation/management.py @@ -139,7 +139,7 @@ class ModManagement(commands.Cog): # If the infraction was not marked as permanent, schedule a new expiration task if request_data['expires_at']: - self.infractions_cog.scheduler.schedule(new_infraction['id'], new_infraction) + self.infractions_cog.schedule_expiration(new_infraction) log_text += f""" Previous expiry: {old_infraction['expires_at'] or "Permanent"} -- cgit v1.2.3 From c4e9060a76a901c7d2e6035e6ca19d51770a4ab3 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Mon, 13 Jul 2020 15:04:40 +0200 Subject: Incidents: add `download_file` helper & tests Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 13 +++++++++++++ tests/bot/cogs/moderation/test_incidents.py | 20 ++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index be46c8202..65b0e458e 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -42,6 +42,19 @@ ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: + """ + Download & return `attachment` file. + + If the download fails, the reason is logged and None will be returned. + """ + log.debug(f"Attempting to download attachment: {attachment.filename}") + try: + return await attachment.to_file() + except Exception: + log.exception("Failed to download attachment") + + def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> discord.Embed: """ Create an embed representation of `incident` for the #incidents-archive channel. diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 789a37cd4..273916199 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -12,6 +12,7 @@ from bot.cogs.moderation import Incidents, incidents from bot.constants import Colours from tests.helpers import ( MockAsyncWebhook, + MockAttachment, MockBot, MockMember, MockMessage, @@ -69,6 +70,25 @@ mock_404 = discord.NotFound( ) +class TestDownloadFile(unittest.IsolatedAsyncioTestCase): + """Collection of tests for the `download_file` helper function.""" + + async def test_download_file_success(self): + """If `to_file` succeeds, function returns the acquired `discord.File`.""" + file = MagicMock(discord.File, filename="bigbadlemon.jpg") + attachment = MockAttachment(to_file=AsyncMock(return_value=file)) + + acquired_file = await incidents.download_file(attachment) + self.assertIs(file, acquired_file) + + async def test_download_file_fail(self): + """If `to_file` fails, function handles the exception & returns None.""" + attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) + + acquired_file = await incidents.download_file(attachment) + self.assertIsNone(acquired_file) + + class TestMakeEmbed(unittest.TestCase): """Collection of tests for the `make_embed` helper function.""" -- cgit v1.2.3 From ed2368791870bd0b464391d9da7b13de15b322a3 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:13:39 +0200 Subject: Better docstring for DMRelay cog. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index df19000fe..c6206629e 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -15,7 +15,7 @@ log = logging.getLogger(__name__) class DMRelay(Cog): - """Debug logging module.""" + """Relay direct messages to and from the bot.""" def __init__(self, bot: Bot): self.bot = bot -- cgit v1.2.3 From ab1546611a9952ddb45f211901ad129c2e8c5007 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:14:31 +0200 Subject: Add avatar_url in python_news.py https://github.com/python-discord/bot/issues/667 --- bot/cogs/python_news.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/python_news.py b/bot/cogs/python_news.py index 1d8f2aeb0..0ab5738a4 100644 --- a/bot/cogs/python_news.py +++ b/bot/cogs/python_news.py @@ -113,6 +113,7 @@ class PythonNews(Cog): webhook=self.webhook, username=data["feed"]["title"], embed=embed, + avatar_url=AVATAR_URL, wait=True, ) payload["data"]["pep"].append(pep_nr) @@ -189,6 +190,7 @@ class PythonNews(Cog): webhook=self.webhook, username=self.webhook_names[maillist], embed=embed, + avatar_url=AVATAR_URL, wait=True, ) payload["data"][maillist].append(thread_information["thread_id"]) -- cgit v1.2.3 From 87c2ef7610a42207b0289820458285648f5dd41e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:20:59 +0200 Subject: Only mods+ may use the commands in this cog. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index c6206629e..67411f57b 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -8,6 +8,8 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot +from bot.constants import MODERATION_ROLES +from bot.utils.checks import with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -93,6 +95,10 @@ class DMRelay(Cog): except discord.HTTPException: log.exception("Failed to send an attachment to the webhook") + def cog_check(self, ctx: commands.Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *MODERATION_ROLES) + def setup(bot: Bot) -> None: """Load the DMRelay cog.""" -- cgit v1.2.3 From 311936991d5543e35dbe4a5a5a13261fb44c27f4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:24:58 +0200 Subject: Don't run on_message if self.webhook is None. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 67411f57b..3d16db8a0 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -64,7 +64,7 @@ class DMRelay(Cog): async def on_message(self, message: discord.Message) -> None: """Relays the message's content and attachments to the dm_log channel.""" # Only relay DMs from humans - if message.author.bot or message.guild: + if message.author.bot or message.guild or self.webhook is None: return clean_content = message.clean_content -- cgit v1.2.3 From d98a67f36444a7732f4527d8c343e2fb8fad6f93 Mon Sep 17 00:00:00 2001 From: Slushie Date: Mon, 13 Jul 2020 14:25:07 +0100 Subject: rename the `_filter_eval` function to be a public function --- bot/cogs/filtering.py | 2 +- bot/cogs/snekbox.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4c97073c3..ec6769f68 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -200,7 +200,7 @@ class Filtering(Cog, Scheduler): # Update time when alert sent await self.name_alerts.set(member.id, datetime.utcnow().timestamp()) - async def _filter_eval(self, result: str, msg: Message) -> bool: + async def filter_eval(self, result: str, msg: Message) -> bool: """ Filter the result of an !eval to see if it violates any of our rules, and then respond accordingly. diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 649bab492..4f73690da 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -213,7 +213,7 @@ class Snekbox(Cog): self.bot.stats.incr("snekbox.python.success") filter_cog = self.bot.get_cog("Filtering") - filter_triggered = await filter_cog._filter_eval(msg, ctx.message) + filter_triggered = await filter_cog.filter_eval(msg, ctx.message) if filter_triggered: response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: -- cgit v1.2.3 From aa0b20bdd780bc75cadde781981d063287bfe5ce Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 15:27:41 +0200 Subject: Remove redundant clean_content variable. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 3 +-- bot/cogs/duck_pond.py | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 3d16db8a0..494c71066 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -67,8 +67,7 @@ class DMRelay(Cog): if message.author.bot or message.guild or self.webhook is None: return - clean_content = message.clean_content - if clean_content: + if message.clean_content: await send_webhook( webhook=self.webhook, content=message.clean_content, diff --git a/bot/cogs/duck_pond.py b/bot/cogs/duck_pond.py index 89b4ad0e4..7021069fa 100644 --- a/bot/cogs/duck_pond.py +++ b/bot/cogs/duck_pond.py @@ -78,9 +78,7 @@ class DuckPond(Cog): async def relay_message(self, message: Message) -> None: """Relays the message's content and attachments to the duck pond channel.""" - clean_content = message.clean_content - - if clean_content: + if message.clean_content: await send_webhook( webhook=self.webhook, content=message.clean_content, -- cgit v1.2.3 From b40a5f0de6758eb9dfb79ac3f34fbc0bf90d8a1e Mon Sep 17 00:00:00 2001 From: Slushie Date: Mon, 13 Jul 2020 16:08:40 +0100 Subject: check for the filter_cog in case it is unloaded --- bot/cogs/snekbox.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 4f73690da..662f90869 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -213,7 +213,9 @@ class Snekbox(Cog): self.bot.stats.incr("snekbox.python.success") filter_cog = self.bot.get_cog("Filtering") - filter_triggered = await filter_cog.filter_eval(msg, ctx.message) + filter_triggered = False + if filter_cog: + filter_triggered = await filter_cog.filter_eval(msg, ctx.message) if filter_triggered: response = await ctx.send("Attempt to circumvent filter detected. Moderator team has been alerted.") else: -- cgit v1.2.3 From f1b1d0cb723abbbf7d4b49ac4b42fe0b7f266692 Mon Sep 17 00:00:00 2001 From: Slushie Date: Mon, 13 Jul 2020 16:09:08 +0100 Subject: edit snekbox tests to work with filtering --- tests/bot/cogs/test_snekbox.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index cf9adbee0..98dee7a1b 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -233,6 +233,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.get_status_emoji = MagicMock(return_value=':yay!:') self.cog.format_output = AsyncMock(return_value=('[No output]', None)) + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_eval = AsyncMock(return_value=False) + self.bot.get_cog.return_value = mocked_filter_cog + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' @@ -254,6 +258,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.get_status_emoji = MagicMock(return_value=':yay!:') self.cog.format_output = AsyncMock(return_value=('Way too long beard', 'lookatmybeard.com')) + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_eval = AsyncMock(return_value=False) + self.bot.get_cog.return_value = mocked_filter_cog + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( '@LemonLemonishBeard#0042 :yay!: Return code 0.' @@ -275,6 +283,10 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): self.cog.get_status_emoji = MagicMock(return_value=':nope!:') self.cog.format_output = AsyncMock() # This function isn't called + mocked_filter_cog = MagicMock() + mocked_filter_cog.filter_eval = AsyncMock(return_value=False) + self.bot.get_cog.return_value = mocked_filter_cog + await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```' -- cgit v1.2.3 From ea62b6bae85113be913101a41053c91497b23c9a Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 21:09:41 +0200 Subject: Store last DM user in RedisCache. Also now catches the exception if a user has disabled DMs, and adds a red cross reaction. https://github.com/python-discord/bot/issues/667 --- bot/cogs/dm_relay.py | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 494c71066..3fce52b93 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -9,6 +9,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot from bot.constants import MODERATION_ROLES +from bot.utils import RedisCache from bot.utils.checks import with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -19,12 +20,14 @@ log = logging.getLogger(__name__) class DMRelay(Cog): """Relay direct messages to and from the bot.""" + # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] + dm_cache = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.dm_log self.webhook = None self.bot.loop.create_task(self.fetch_webhook()) - self.last_dm_user = None @commands.command(aliases=("reply",)) async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: @@ -39,16 +42,23 @@ class DMRelay(Cog): NOTE: This feature will be removed if it is overused. """ - if member: - await member.send(message) - await ctx.message.add_reaction("✅") - return - elif self.last_dm_user: - await self.last_dm_user.send(message) - await ctx.message.add_reaction("✅") - return - else: - log.debug("Unable to send a DM to the user.") + user_id = await self.dm_cache.get("last_user") + last_dm_user = ctx.guild.get_member(user_id) if user_id else None + + try: + if member: + await member.send(message) + await ctx.message.add_reaction("✅") + return + elif last_dm_user: + await last_dm_user.send(message) + await ctx.message.add_reaction("✅") + return + else: + log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") + await ctx.message.add_reaction("❌") + except discord.errors.Forbidden: + log.debug("User has disabled DMs.") await ctx.message.add_reaction("❌") async def fetch_webhook(self) -> None: @@ -74,7 +84,7 @@ class DMRelay(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) - self.last_dm_user = message.author + await self.dm_cache.set("last_user", message.author.id) # Handle any attachments if message.attachments: -- cgit v1.2.3 From c91ad4b74d4aea220ef564af3b1c044ab81a01d8 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 13 Jul 2020 21:19:30 +0200 Subject: Whitelisting some popular communities The following communities are whitelisted by this commit: - Django - Programming Discussions - JetBrains Community - Raspberry Pi - Programmers Hangout - SpeakJS - DevCord - Unity - Programmer Humor - Microsoft Community Most of these are partners, or otherwise friendly communities that aren't worth pinging mods over. --- config-default.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/config-default.yml b/config-default.yml index 636b9db37..19d79fa76 100644 --- a/config-default.yml +++ b/config-default.yml @@ -295,12 +295,22 @@ filter: - 172018499005317120 # The Coding Den - 666560367173828639 # PyWeek - 702724176489873509 # Microsoft Python + - 150662382874525696 # Microsoft Community - 81384788765712384 # Discord API - 613425648685547541 # Discord Developers - 185590609631903755 # Blender Hub - 420324994703163402 # /r/FlutterDev - 488751051629920277 # Python Atlanta - 143867839282020352 # C# + - 159039020565790721 # Django + - 238666723824238602 # Programming Discussions + - 433980600391696384 # JetBrains Community + - 204621105720328193 # Raspberry Pi + - 244230771232079873 # Programmers Hangout + - 239433591950540801 # SpeakJS + - 174075418410876928 # DevCord + - 489222168727519232 # Unity + - 494558898880118785 # Programmer Humor domain_blacklist: - pornhub.com -- cgit v1.2.3 From 1fb9bdb0deb3609f426a3bca555c67d0a7dc52a7 Mon Sep 17 00:00:00 2001 From: Slushie Date: Tue, 14 Jul 2020 00:39:31 +0100 Subject: fix misaligned indentation --- bot/cogs/filtering.py | 74 +++++++++++++++++++++++++-------------------------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index ec6769f68..2de00f3a1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -217,43 +217,43 @@ class Filtering(Cog, Scheduler): if _filter["enabled"] and _filter["content_only"]: match = await _filter["function"](result) - if match: - # If this is a filter (not a watchlist), we set the variable so we know - # that it has been triggered - if _filter["type"] == "filter": - filter_triggered = True - - # We do not have to check against DM channels since !eval cannot be used there. - channel_str = f"in {msg.channel.mention}" - - message_content, additional_embeds, additional_embeds_msg = self._add_stats( - filter_name, match, result - ) - - message = ( - f"The {filter_name} {_filter['type']} was triggered " - f"by **{msg.author}** " - f"(`{msg.author.id}`) {channel_str} using !eval with " - f"[the following message]({msg.jump_url}):\n\n" - f"{message_content}" - ) - - log.debug(message) - - # Send pretty mod log embed to mod-alerts - await self.mod_log.send_log_message( - icon_url=Icons.filtering, - colour=Colour(Colours.soft_red), - title=f"{_filter['type'].title()} triggered!", - text=message, - thumbnail=msg.author.avatar_url_as(static_format="png"), - channel_id=Channels.mod_alerts, - ping_everyone=Filter.ping_everyone, - additional_embeds=additional_embeds, - additional_embeds_msg=additional_embeds_msg - ) - - break # We don't want multiple filters to trigger + if match: + # If this is a filter (not a watchlist), we set the variable so we know + # that it has been triggered + if _filter["type"] == "filter": + filter_triggered = True + + # We do not have to check against DM channels since !eval cannot be used there. + channel_str = f"in {msg.channel.mention}" + + message_content, additional_embeds, additional_embeds_msg = self._add_stats( + filter_name, match, result + ) + + message = ( + f"The {filter_name} {_filter['type']} was triggered " + f"by **{msg.author}** " + f"(`{msg.author.id}`) {channel_str} using !eval with " + f"[the following message]({msg.jump_url}):\n\n" + f"{message_content}" + ) + + log.debug(message) + + # Send pretty mod log embed to mod-alerts + await self.mod_log.send_log_message( + icon_url=Icons.filtering, + colour=Colour(Colours.soft_red), + title=f"{_filter['type'].title()} triggered!", + text=message, + thumbnail=msg.author.avatar_url_as(static_format="png"), + channel_id=Channels.mod_alerts, + ping_everyone=Filter.ping_everyone, + additional_embeds=additional_embeds, + additional_embeds_msg=additional_embeds_msg + ) + + break # We don't want multiple filters to trigger return filter_triggered -- cgit v1.2.3 From 7ff8b2c9ebc27194835c25258bc90c623bdbec6b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 10:51:56 +0800 Subject: Allow ordering watched users by oldest first --- bot/cogs/watchchannels/watchchannel.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 7c58a0fb5..2992a3085 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -287,7 +287,9 @@ class WatchChannel(metaclass=CogABCMeta): await self.webhook_send(embed=embed, username=msg.author.display_name, avatar_url=msg.author.avatar_url) - async def list_watched_users(self, ctx: Context, update_cache: bool = True) -> None: + async def list_watched_users( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: """ Gives an overview of the watched user list for this channel. @@ -305,7 +307,11 @@ class WatchChannel(metaclass=CogABCMeta): time_delta = self._get_time_delta(inserted_at) lines.append(f"• <@{user_id}> (added {time_delta})") + if oldest_first: + lines.reverse() + lines = lines or ("There's nothing here yet.",) + embed = Embed( title=f"{self.__class__.__name__} watched users ({'updated' if update_cache else 'cached'})", color=Color.blue() -- cgit v1.2.3 From a6f66611b17aab8fbc3c54377c3971faeac5073b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 10:52:33 +0800 Subject: Pass argument as kwarg to preserve functionality --- bot/cogs/watchchannels/bigbrother.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 702d371f4..fc899281b 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -42,7 +42,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, update_cache) + await self.list_watched_users(ctx, update_cache=update_cache) @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From ac7ed93eb64884a622c8718c8594aa74a3b2b201 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 10:58:02 +0800 Subject: Accept argument to order nominees by oldest first --- bot/cogs/watchchannels/talentpool.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 33550f68e..1f5989f23 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -38,14 +38,18 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): @nomination_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) - async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: """ Shows the users that are currently being monitored in the talent pool. + The optional kwarg `oldest_first` can be used to order the list by oldest nomination. + The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, update_cache) + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) @with_role(*STAFF_ROLES) -- cgit v1.2.3 From 01d2803b608407330959ef880bd562456921d0fd Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 10:58:56 +0800 Subject: Add command to list nominees by oldest first --- bot/cogs/watchchannels/talentpool.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 1f5989f23..89256e92e 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -51,6 +51,17 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """ await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + @nomination_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows talent pool monitored users ordered by oldest nomination. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) + @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) @with_role(*STAFF_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: -- cgit v1.2.3 From 28fe47d5d2404cdc70eaabe3e6c41567b9fd7c3d Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 11:55:00 +0800 Subject: Achieve feature parity with talentpool --- bot/cogs/watchchannels/bigbrother.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index fc899281b..4d27a6333 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -35,14 +35,29 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): @bigbrother_group.command(name='watched', aliases=('all', 'list')) @with_role(*MODERATION_ROLES) - async def watched_command(self, ctx: Context, update_cache: bool = True) -> None: + async def watched_command( + self, ctx: Context, oldest_first: bool = False, update_cache: bool = True + ) -> None: """ Shows the users that are currently being monitored by Big Brother. + The optional kwarg `oldest_first` can be used to order the list by oldest watched. + + The optional kwarg `update_cache` can be used to update the user + cache using the API before listing the users. + """ + await self.list_watched_users(ctx, oldest_first=oldest_first, update_cache=update_cache) + + @bigbrother_group.command(name='oldest') + @with_role(*MODERATION_ROLES) + async def oldest_command(self, ctx: Context, update_cache: bool = True) -> None: + """ + Shows Big Brother monitored users ordered by oldest watched. + The optional kwarg `update_cache` can be used to update the user cache using the API before listing the users. """ - await self.list_watched_users(ctx, update_cache=update_cache) + await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) @bigbrother_group.command(name='watch', aliases=('w',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 8d62214b0b009d1cc9b343c9589a5a1fe8f4692b Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 12:49:39 +0800 Subject: Invoke fuzzywuzzy's processor before matching Trying to match a string with only non-alphanumeric characters results in a warning by fuzzywuzzy. Processing the string before matching lets us avoid the warning, which which uses the root logger and thus isn't supressible. --- bot/cogs/help.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 832f6ea6b..198e88b55 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -8,6 +8,7 @@ from typing import List, Union from discord import Colour, Embed, Member, Message, NotFound, Reaction, User from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process +from fuzzywuzzy.utils import full_process from bot import constants from bot.constants import Channels, Emojis, STAFF_ROLES @@ -146,7 +147,13 @@ class CustomHelpCommand(HelpCommand): Will return an instance of the `HelpQueryNotFound` exception with the error message and possible matches. """ choices = await self.get_all_help_choices() - result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60) + + # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty + # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters + if full_process(string): + result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) + else: + result = [] return HelpQueryNotFound(f'Query "{string}" not found.', dict(result)) -- cgit v1.2.3 From 3c1d43dc83b4a3d3a02492a1d045c7b9f1735feb Mon Sep 17 00:00:00 2001 From: kosayoda Date: Tue, 14 Jul 2020 13:22:08 +0800 Subject: Remove redundant kwarg in !kick and !shadow_kick The kwarg `active=False` is already being passed in `apply_kick`, therefore passing it in the parent callers result in a TypeError. Fixes #976 Fixes BOT-5P --- bot/cogs/moderation/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/infractions.py b/bot/cogs/moderation/infractions.py index 3b28526b2..8df642428 100644 --- a/bot/cogs/moderation/infractions.py +++ b/bot/cogs/moderation/infractions.py @@ -64,7 +64,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command() async def kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason.""" - await self.apply_kick(ctx, user, reason, active=False) + await self.apply_kick(ctx, user, reason) @command() async def ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: @@ -134,7 +134,7 @@ class Infractions(InfractionScheduler, commands.Cog): @command(hidden=True, aliases=['shadowkick', 'skick']) async def shadow_kick(self, ctx: Context, user: Member, *, reason: t.Optional[str] = None) -> None: """Kick a user for the given reason without notifying the user.""" - await self.apply_kick(ctx, user, reason, hidden=True, active=False) + await self.apply_kick(ctx, user, reason, hidden=True) @command(hidden=True, aliases=['shadowban', 'sban']) async def shadow_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str] = None) -> None: -- cgit v1.2.3 From b35fad987b2b6de8da2c36bb02f4e0c6777b9737 Mon Sep 17 00:00:00 2001 From: ItsCinnabar <50111163+ItsCinnabar@users.noreply.github.com> Date: Tue, 14 Jul 2020 09:48:31 -0400 Subject: Update or-gotcha.md Adjust description and include link to docs --- bot/resources/tags/or-gotcha.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index 00c2db1f8..2dc1410ad 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -3,7 +3,7 @@ When checking if something is equal to one thing or another, you might think tha if favorite_fruit == 'grapefruit' or 'lemon': print("That's a weird favorite fruit to have.") ``` -After all, that's how you would normally phrase it in plain English. In Python, however, you have to have _complete instructions on both sides of the logical operator_. +While this makes sense in English, it may not behave the way you would expect. In Python, you should have _complete instructions on both sides of the logical operator_. So, if you want to check if something is equal to one thing or another, there are two common ways: ```py @@ -15,3 +15,4 @@ if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': if favorite_fruit in ('grapefruit', 'lemon'): print("That's a weird favorite fruit to have.") ``` +For more info, see here: [Python Docs - Boolean Operations](https://docs.python.org/3/reference/expressions.html#boolean-operations) -- cgit v1.2.3 From 18e58ad1e040f3997a23308d916eed7d474a5dd6 Mon Sep 17 00:00:00 2001 From: ItsCinnabar <50111163+ItsCinnabar@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:03:07 -0400 Subject: Update or-gotcha.md --- bot/resources/tags/or-gotcha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index 2dc1410ad..cbb64c276 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -3,7 +3,7 @@ When checking if something is equal to one thing or another, you might think tha if favorite_fruit == 'grapefruit' or 'lemon': print("That's a weird favorite fruit to have.") ``` -While this makes sense in English, it may not behave the way you would expect. In Python, you should have _complete instructions on both sides of the logical operator_. +While this makes sense in English, it may not behave the way you would expect. [In Python, you should have _complete instructions on both sides of the logical operator_.](https://docs.python.org/3/reference/expressions.html#boolean-operations) So, if you want to check if something is equal to one thing or another, there are two common ways: ```py -- cgit v1.2.3 From 6d064912e5c2caf29de609955f78eb60014a5b63 Mon Sep 17 00:00:00 2001 From: ItsCinnabar <50111163+ItsCinnabar@users.noreply.github.com> Date: Tue, 14 Jul 2020 10:06:00 -0400 Subject: Update or-gotcha.md --- bot/resources/tags/or-gotcha.md | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index cbb64c276..00c8a5645 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -15,4 +15,3 @@ if favorite_fruit == 'grapefruit' or favorite_fruit == 'lemon': if favorite_fruit in ('grapefruit', 'lemon'): print("That's a weird favorite fruit to have.") ``` -For more info, see here: [Python Docs - Boolean Operations](https://docs.python.org/3/reference/expressions.html#boolean-operations) -- cgit v1.2.3 From 6e48d666b31d13d801c394b527ce545b039b478f Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 14 Jul 2020 17:28:53 +0200 Subject: Incidents: link `proxy_url` if attachment fails to download Suggested by Mark during review. If the download fails, we fallback on showing an informative message, which will link the attachment cdn link. The attachment-handling logic was moved from the `archive` coroutine into `make_embed`, which now also returns the file, if available. In the end, this appears to be the smoothest approach. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 36 +++++++++----- tests/bot/cogs/moderation/test_incidents.py | 73 ++++++++++++++--------------- 2 files changed, 59 insertions(+), 50 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 65b0e458e..018538040 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -41,6 +41,10 @@ ALLOWED_ROLES: t.Set[int] = set(Guild.moderation_roles) # Message must have all of these emoji to pass the `has_signals` check ALL_SIGNALS: t.Set[str] = {signal.value for signal in Signal} +# An embed coupled with an optional file to be dispatched +# If the file is not None, the embed attempts to show it in its body +FileEmbed = t.Tuple[discord.Embed, t.Optional[discord.File]] + async def download_file(attachment: discord.Attachment) -> t.Optional[discord.File]: """ @@ -55,7 +59,7 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi log.exception("Failed to download attachment") -def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> discord.Embed: +async def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord.Member) -> FileEmbed: """ Create an embed representation of `incident` for the #incidents-archive channel. @@ -66,6 +70,11 @@ def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord. of information will be relayed in other ways, e.g. webhook username. As mentions in embeds do not ping, we do not need to use `incident.clean_content`. + + If `incident` contains attachments, the first attachment will be downloaded and + returned alongside the embed. The embed attempts to display the attachment. + Should the download fail, we fallback on linking the `proxy_url`, which should + remain functional for some time after the original message is deleted. """ log.trace(f"Creating embed for {incident.id=}") @@ -83,7 +92,18 @@ def make_embed(incident: discord.Message, outcome: Signal, actioned_by: discord. ) embed.set_footer(text=footer, icon_url=actioned_by.avatar_url) - return embed + if incident.attachments: + attachment = incident.attachments[0] # User-sent messages can only contain one attachment + file = await download_file(attachment) + + if file is not None: + embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file + else: + embed.set_author(name="[Failed to relay attachment]", url=attachment.proxy_url) # Embed links the file + else: + file = None + + return embed, file def is_incident(message: discord.Message) -> bool: @@ -215,17 +235,7 @@ class Incidents(Cog): message is not safe to be deleted, as we will lose some information. """ log.debug(f"Archiving incident: {incident.id} (outcome: {outcome}, actioned by: {actioned_by})") - embed = make_embed(incident, outcome, actioned_by) - - # If the incident had an attachment, we will try to relay it - if incident.attachments: - attachment = incident.attachments[0] # User-sent messages can only contain one attachment - log.debug(f"Attempting to archive incident attachment: {attachment.filename}") - - attachment_file = await attachment.to_file() # The file will be sent with the webhook - embed.set_image(url=f"attachment://{attachment.filename}") # Embed displays the attached file - else: - attachment_file = None + embed, attachment_file = await make_embed(incident, outcome, actioned_by) try: webhook = await self.bot.fetch_webhook(Webhooks.incidents_archive) diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 273916199..9b6054f55 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -89,30 +89,58 @@ class TestDownloadFile(unittest.IsolatedAsyncioTestCase): self.assertIsNone(acquired_file) -class TestMakeEmbed(unittest.TestCase): +class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): """Collection of tests for the `make_embed` helper function.""" - def test_make_embed_actioned(self): + async def test_make_embed_actioned(self): """Embed is coloured green and footer contains 'Actioned' when `outcome=Signal.ACTIONED`.""" - embed = incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) + embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.ACTIONED, MockMember()) self.assertEqual(embed.colour.value, Colours.soft_green) self.assertIn("Actioned", embed.footer.text) - def test_make_embed_not_actioned(self): + async def test_make_embed_not_actioned(self): """Embed is coloured red and footer contains 'Rejected' when `outcome=Signal.NOT_ACTIONED`.""" - embed = incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) + embed, file = await incidents.make_embed(MockMessage(), incidents.Signal.NOT_ACTIONED, MockMember()) self.assertEqual(embed.colour.value, Colours.soft_red) self.assertIn("Rejected", embed.footer.text) - def test_make_embed_content(self): + async def test_make_embed_content(self): """Incident content appears as embed description.""" incident = MockMessage(content="this is an incident") - embed = incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + embed, file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) self.assertEqual(incident.content, embed.description) + async def test_make_embed_with_attachment_succeeds(self): + """Incident's attachment is downloaded and displayed in the embed's image field.""" + file = MagicMock(discord.File, filename="bigbadjoe.jpg") + attachment = MockAttachment(filename="bigbadjoe.jpg") + incident = MockMessage(content="this is an incident", attachments=[attachment]) + + # Patch `download_file` to return our `file` + with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=file)): + embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertIs(file, returned_file) + self.assertEqual("attachment://bigbadjoe.jpg", embed.image.url) + + async def test_make_embed_with_attachment_fails(self): + """Incident's attachment fails to download, proxy url is linked instead.""" + attachment = MockAttachment(proxy_url="discord.com/bigbadjoe.jpg") + incident = MockMessage(content="this is an incident", attachments=[attachment]) + + # Patch `download_file` to return None as if the download failed + with patch("bot.cogs.moderation.incidents.download_file", AsyncMock(return_value=None)): + embed, returned_file = await incidents.make_embed(incident, incidents.Signal.ACTIONED, MockMember()) + + self.assertIsNone(returned_file) + + # The author name field is simply expected to have something in it, we do not assert the message + self.assertGreater(len(embed.author.name), 0) + self.assertEqual(embed.author.url, "discord.com/bigbadjoe.jpg") # However, it should link the exact url + @patch("bot.constants.Channels.incidents", 123) class TestIsIncident(unittest.TestCase): @@ -343,11 +371,10 @@ class TestArchive(TestIncidents): content="this is an incident", author=MockUser(name="author_name", avatar_url="author_avatar"), id=123, - attachments=[], # This incident has no attachments ) built_embed = MagicMock(discord.Embed, id=123) # We patch `make_embed` to return this - with patch("bot.cogs.moderation.incidents.make_embed", MagicMock(return_value=built_embed)): + with patch("bot.cogs.moderation.incidents.make_embed", AsyncMock(return_value=(built_embed, None))): archive_return = await self.cog_instance.archive(incident, MagicMock(value="A"), MockMember()) # Now we check that the webhook was given the correct args, and that `archive` returned True @@ -359,34 +386,6 @@ class TestArchive(TestIncidents): ) self.assertTrue(archive_return) - async def test_archive_relays_incident_with_attachments(self): - """ - Incident attachments are relayed and displayed in the embed. - - This test asserts the two things that need to happen in order to relay the attachment. - The embed returned by `make_embed` must have the `set_image` method called with the - attachment's filename, and the file must be passed to the webhook's send method. - """ - attachment_file = MagicMock(discord.File) - attachment = MagicMock( - discord.Attachment, - filename="abc.png", - to_file=AsyncMock(return_value=attachment_file), - ) - incident = MockMessage( - attachments=[attachment], - ) - built_embed = MagicMock(discord.Embed) - - with patch("bot.cogs.moderation.incidents.make_embed", MagicMock(return_value=built_embed)): - await self.cog_instance.archive(incident, incidents.Signal.ACTIONED, actioned_by=MockMember()) - - built_embed.set_image.assert_called_once_with(url="attachment://abc.png") - - send_kwargs = self.cog_instance.bot.fetch_webhook.return_value.send.call_args.kwargs - self.assertIn("file", send_kwargs) - self.assertIs(send_kwargs["file"], attachment_file) - async def test_archive_clyde_username(self): """ The archive webhook username is cleansed using `sub_clyde`. -- cgit v1.2.3 From 4fc0971dfff6a72c322a8f434a4b656cbea8fb66 Mon Sep 17 00:00:00 2001 From: ItsCinnabar <50111163+ItsCinnabar@users.noreply.github.com> Date: Tue, 14 Jul 2020 16:59:19 +0000 Subject: Update bot/resources/tags/or-gotcha.md Co-authored-by: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/resources/tags/or-gotcha.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/or-gotcha.md b/bot/resources/tags/or-gotcha.md index 00c8a5645..d75a73d78 100644 --- a/bot/resources/tags/or-gotcha.md +++ b/bot/resources/tags/or-gotcha.md @@ -3,7 +3,7 @@ When checking if something is equal to one thing or another, you might think tha if favorite_fruit == 'grapefruit' or 'lemon': print("That's a weird favorite fruit to have.") ``` -While this makes sense in English, it may not behave the way you would expect. [In Python, you should have _complete instructions on both sides of the logical operator_.](https://docs.python.org/3/reference/expressions.html#boolean-operations) +While this makes sense in English, it may not behave the way you would expect. In Python, you should have _[complete instructions on both sides of the logical operator](https://docs.python.org/3/reference/expressions.html#boolean-operations)_. So, if you want to check if something is equal to one thing or another, there are two common ways: ```py -- cgit v1.2.3 From 042f472ac3207ad685a5acb659a5a69f22c72282 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 01:09:35 +0200 Subject: Remove caching of last_dm_user. If you're typing up a reply and the bot gets another DM while you're typing, you might accidentally send your reply to the wrong person. This could happen even if you're very attentive, because it might be a matter of milliseconds. The complexity to prevent this isn't worth the convenience of the feature, and it's nice to get rid of the caching as well, so I've decided to just make .reply require a user for every reply. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 42 +++++++++++++++++------------------------- bot/constants.py | 2 ++ config-default.yml | 1 + 3 files changed, 20 insertions(+), 25 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 3fce52b93..f62d6105e 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -1,5 +1,4 @@ import logging -from typing import Optional import discord from discord import Color @@ -8,9 +7,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot -from bot.constants import MODERATION_ROLES -from bot.utils import RedisCache -from bot.utils.checks import with_role_check +from bot.utils.checks import in_whitelist_check, with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -20,9 +17,6 @@ log = logging.getLogger(__name__) class DMRelay(Cog): """Relay direct messages to and from the bot.""" - # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] - dm_cache = RedisCache() - def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.dm_log @@ -30,11 +24,11 @@ class DMRelay(Cog): self.bot.loop.create_task(self.fetch_webhook()) @commands.command(aliases=("reply",)) - async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: + async def send_dm(self, ctx: commands.Context, member: discord.Member, *, message: str) -> None: """ Allows you to send a DM to a user from the bot. - If `member` is not provided, it will send to the last user who DM'd the bot. + A `member` must be provided. This feature should be used extremely sparingly. Use ModMail if you need to have a serious conversation with a user. This is just for responding to extraordinary DMs, having a little @@ -42,21 +36,11 @@ class DMRelay(Cog): NOTE: This feature will be removed if it is overused. """ - user_id = await self.dm_cache.get("last_user") - last_dm_user = ctx.guild.get_member(user_id) if user_id else None - try: - if member: - await member.send(message) - await ctx.message.add_reaction("✅") - return - elif last_dm_user: - await last_dm_user.send(message) - await ctx.message.add_reaction("✅") - return - else: - log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") - await ctx.message.add_reaction("❌") + await member.send(message) + await ctx.message.add_reaction("✅") + return + except discord.errors.Forbidden: log.debug("User has disabled DMs.") await ctx.message.add_reaction("❌") @@ -84,7 +68,6 @@ class DMRelay(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) - await self.dm_cache.set("last_user", message.author.id) # Handle any attachments if message.attachments: @@ -106,7 +89,16 @@ class DMRelay(Cog): def cog_check(self, ctx: commands.Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *MODERATION_ROLES) + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + in_whitelist_check( + ctx, + channels=[constants.Channels.dm_log], + redirect=None, + fail_silently=True, + ) + ] + return all(checks) def setup(bot: Bot) -> None: diff --git a/bot/constants.py b/bot/constants.py index 3f44003a8..778bc093c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -395,6 +395,7 @@ class Channels(metaclass=YAMLGetter): dev_contrib: int dev_core: int dev_log: int + dm_log: int esoteric: int helpers: int how_to_get_help: int @@ -461,6 +462,7 @@ class Guild(metaclass=YAMLGetter): staff_channels: List[int] staff_roles: List[int] + class Keys(metaclass=YAMLGetter): section = "keys" diff --git a/config-default.yml b/config-default.yml index d12b9be27..8061e5e16 100644 --- a/config-default.yml +++ b/config-default.yml @@ -150,6 +150,7 @@ guild: mod_log: &MOD_LOG 282638479504965634 user_log: 528976905546760203 voice_log: 640292421988646961 + dm_log: 653713721625018428 # Off-topic off_topic_0: 291284109232308226 -- cgit v1.2.3 From 867b561a6ce4aff85451d00794c22e02793c8dac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Jul 2020 17:07:35 -0700 Subject: Suppress NotFound when removing help cmd reactions The message may be deleted somehow before the wait_for times out. Fixes #1050 Fixes BOT-6X --- bot/cogs/help.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 832f6ea6b..70e62d590 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -36,13 +36,12 @@ async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: await message.add_reaction(DELETE_EMOJI) - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - except NotFound: - pass + with suppress(NotFound): + try: + await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + except TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) class HelpQueryNotFound(ValueError): -- cgit v1.2.3 From 992f3c47d328821bcf647df7683fd5ca8bd780aa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Jul 2020 18:20:52 -0700 Subject: HelpChannels: remove cooldown info from available message Users can no longer see available channels if they're on cooldown. They will instead see a special "cooldown" channel which will explain what's going on. --- bot/cogs/help_channels.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 4d0c534b0..0c8cbb417 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -34,9 +34,6 @@ and will be yours until it has been inactive for {constants.HelpChannels.idle_mi is closed manually with `!close`. When that happens, it will be set to **dormant** and moved into \ the **Help: Dormant** category. -You may claim a new channel once every {constants.HelpChannels.claim_minutes} minutes. If you \ -currently cannot send a message in this channel, it means you are on cooldown and need to wait. - Try to write the best question you can by providing a detailed description and telling us what \ you've tried already. For more information on asking a good question, \ check out our guide on [asking good questions]({ASKING_GUIDE_URL}). -- cgit v1.2.3 From e46385d656129e06dd267764811d10ef5e8cd5a2 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 15 Jul 2020 11:06:03 +0800 Subject: Document new kwarg in docstring --- bot/cogs/watchchannels/watchchannel.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 2992a3085..044077350 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -293,6 +293,8 @@ class WatchChannel(metaclass=CogABCMeta): """ Gives an overview of the watched user list for this channel. + The optional kwarg `oldest_first` orders the list by oldest entry. + The optional kwarg `update_cache` specifies whether the cache should be refreshed by polling the API. """ -- cgit v1.2.3 From eec57c86999bb2e9486dd6443b44cfd29026c823 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Wed, 15 Jul 2020 11:39:17 +0800 Subject: Pass processed string to `extractBests` Fixes a regression where the string to be matched was not processed beforehand. --- bot/cogs/help.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 198e88b55..5f3fc4750 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -150,8 +150,8 @@ class CustomHelpCommand(HelpCommand): # Run fuzzywuzzy's processor beforehand, and avoid matching if processed string is empty # This avoids fuzzywuzzy from raising a warning on inputs with only non-alphanumeric characters - if full_process(string): - result = process.extractBests(string, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) + if (processed := full_process(string)): + result = process.extractBests(processed, choices, scorer=fuzz.ratio, score_cutoff=60, processor=None) else: result = [] -- cgit v1.2.3 From 6ccbb944a50058cea74bfdfe855a538b09ab67b7 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 07:22:59 +0200 Subject: Restore DM user caching. This reverts commit 042f472a --- bot/cogs/dm_relay.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index f62d6105e..9a68b5341 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -1,4 +1,5 @@ import logging +from typing import Optional import discord from discord import Color @@ -7,6 +8,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot +from bot.utils import RedisCache from bot.utils.checks import in_whitelist_check, with_role_check from bot.utils.messages import send_attachments from bot.utils.webhooks import send_webhook @@ -17,6 +19,9 @@ log = logging.getLogger(__name__) class DMRelay(Cog): """Relay direct messages to and from the bot.""" + # RedisCache[str, t.Union[discord.User.id, discord.Member.id]] + dm_cache = RedisCache() + def __init__(self, bot: Bot): self.bot = bot self.webhook_id = constants.Webhooks.dm_log @@ -24,11 +29,11 @@ class DMRelay(Cog): self.bot.loop.create_task(self.fetch_webhook()) @commands.command(aliases=("reply",)) - async def send_dm(self, ctx: commands.Context, member: discord.Member, *, message: str) -> None: + async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: """ Allows you to send a DM to a user from the bot. - A `member` must be provided. + If `member` is not provided, it will send to the last user who DM'd the bot. This feature should be used extremely sparingly. Use ModMail if you need to have a serious conversation with a user. This is just for responding to extraordinary DMs, having a little @@ -36,11 +41,21 @@ class DMRelay(Cog): NOTE: This feature will be removed if it is overused. """ - try: - await member.send(message) - await ctx.message.add_reaction("✅") - return + user_id = await self.dm_cache.get("last_user") + last_dm_user = ctx.guild.get_member(user_id) if user_id else None + try: + if member: + await member.send(message) + await ctx.message.add_reaction("✅") + return + elif last_dm_user: + await last_dm_user.send(message) + await ctx.message.add_reaction("✅") + return + else: + log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") + await ctx.message.add_reaction("❌") except discord.errors.Forbidden: log.debug("User has disabled DMs.") await ctx.message.add_reaction("❌") @@ -68,6 +83,7 @@ class DMRelay(Cog): username=message.author.display_name, avatar_url=message.author.avatar_url ) + await self.dm_cache.set("last_user", message.author.id) # Handle any attachments if message.attachments: -- cgit v1.2.3 From 226eb68a4d397c14c68566f60a2de4a3704cf696 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 07:30:02 +0200 Subject: Add the user ID to the username in dm relays. Without this, it is difficult to know precisely who the user that is DMing us is, which might be useful to us. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 9a68b5341..d3637d34b 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -80,7 +80,7 @@ class DMRelay(Cog): await send_webhook( webhook=self.webhook, content=message.clean_content, - username=message.author.display_name, + username=f"{message.author.display_name} ({message.author.id})", avatar_url=message.author.avatar_url ) await self.dm_cache.set("last_user", message.author.id) -- cgit v1.2.3 From ed6d848e3cd5cf355b9702e9c8df08c063c11a47 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 07:33:48 +0200 Subject: Add some stats for DMs sent and received. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index d3637d34b..edfcccf6d 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -48,10 +48,12 @@ class DMRelay(Cog): if member: await member.send(message) await ctx.message.add_reaction("✅") + self.bot.stats.incr("dm_relay.dm_sent") return elif last_dm_user: await last_dm_user.send(message) await ctx.message.add_reaction("✅") + self.bot.stats.incr("dm_relay.dm_sent") return else: log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") @@ -84,6 +86,7 @@ class DMRelay(Cog): avatar_url=message.author.avatar_url ) await self.dm_cache.set("last_user", message.author.id) + self.bot.stats.incr("dm_relay.dm_received") # Handle any attachments if message.attachments: -- cgit v1.2.3 From 90d2a77becb39d6b3c0056ea7c05b4a9e4d16f50 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 10:15:08 +0200 Subject: Ves' refactor Co-authored-by: Sebastiaan Zeeff <33516116+SebastiaanZ@users.noreply.github.com> --- bot/cogs/dm_relay.py | 31 ++++++++++++++++--------------- 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index edfcccf6d..0c3eddf42 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -41,23 +41,24 @@ class DMRelay(Cog): NOTE: This feature will be removed if it is overused. """ - user_id = await self.dm_cache.get("last_user") - last_dm_user = ctx.guild.get_member(user_id) if user_id else None + if not member: + user_id = await self.dm_cache.get("last_user") + member = ctx.guild.get_member(user_id) if user_id else None + + # If we still don't have a Member at this point, give up + if not member: + log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") + await ctx.message.add_reaction("❌") + return try: - if member: - await member.send(message) - await ctx.message.add_reaction("✅") - self.bot.stats.incr("dm_relay.dm_sent") - return - elif last_dm_user: - await last_dm_user.send(message) - await ctx.message.add_reaction("✅") - self.bot.stats.incr("dm_relay.dm_sent") - return - else: - log.debug("This bot has never gotten a DM, or the RedisCache has been cleared.") - await ctx.message.add_reaction("❌") + await member.send(message) + except discord.errors.Forbidden: + log.debug("User has disabled DMs.") + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("✅") + self.bot.stats.incr("dm_relay.dm_sent") except discord.errors.Forbidden: log.debug("User has disabled DMs.") await ctx.message.add_reaction("❌") -- cgit v1.2.3 From 403572b83cf3faea9068a25cb09e809d993c1514 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 10:39:07 +0200 Subject: Create a UserMentionOrID converter. When we're using the !reply command, using a regular UserConverter is somewhat problematic. For example, if I wanted to send the message "lemon loves you", then I'd try to write `!reply lemon loves you` - however, the optional User converter would then try to convert `lemon` into a User, which it would successfully do since there's like 60 lemons on our server. As a result, the message "loves you" would be sent to a user called lemon.. god knows which one. To solve this bit of ambiguity, I introduce a new converter which only converts user mentions or user IDs into User, not strings that may be intended as part of the message you are sending. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 3 ++- bot/converters.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 0c3eddf42..c5a3dba22 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -8,6 +8,7 @@ from discord.ext.commands import Cog from bot import constants from bot.bot import Bot +from bot.converters import UserMentionOrID from bot.utils import RedisCache from bot.utils.checks import in_whitelist_check, with_role_check from bot.utils.messages import send_attachments @@ -29,7 +30,7 @@ class DMRelay(Cog): self.bot.loop.create_task(self.fetch_webhook()) @commands.command(aliases=("reply",)) - async def send_dm(self, ctx: commands.Context, member: Optional[discord.Member], *, message: str) -> None: + async def send_dm(self, ctx: commands.Context, member: Optional[UserMentionOrID], *, message: str) -> None: """ Allows you to send a DM to a user from the bot. diff --git a/bot/converters.py b/bot/converters.py index 898822165..7c62f92dd 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -330,6 +330,28 @@ def proxy_user(user_id: str) -> discord.Object: return user +class UserMentionOrID(UserConverter): + """ + Converts to a `discord.User`, but only if a mention or userID is provided. + + Unlike the default `UserConverter`, it does allow conversion from name, or name#descrim. + + This is useful in cases where that lookup strategy would lead to ambiguity. + """ + + async def convert(self, ctx: Context, argument: str) -> discord.User: + """Convert the `arg` to a `discord.User`.""" + print(argument) + match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) + + print(match) + + if match is not None: + return await super().convert(ctx, argument) + else: + raise BadArgument(f"`{argument}` is not a User mention or a User ID.") + + class FetchedUser(UserConverter): """ Converts to a `discord.User` or, if it fails, a `discord.Object`. -- cgit v1.2.3 From 80c1dbb240b744ceed5d1ea56c44c91d0014c304 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 10:44:11 +0200 Subject: How did that except except block get in? Weird. https://github.com/python-discord/bot/issues/1041 --- bot/cogs/dm_relay.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index c5a3dba22..0dc15d4b1 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -60,9 +60,6 @@ class DMRelay(Cog): else: await ctx.message.add_reaction("✅") self.bot.stats.incr("dm_relay.dm_sent") - except discord.errors.Forbidden: - log.debug("User has disabled DMs.") - await ctx.message.add_reaction("❌") async def fetch_webhook(self) -> None: """Fetches the webhook object, so we can post to it.""" -- cgit v1.2.3 From 14141a25bb87c298afae89886cb0ca3df65b9dee Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 15 Jul 2020 12:59:48 +0200 Subject: Oops, these prints shouldn't be here. https://github.com/python-discord/bot/issues/1041 --- bot/converters.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 7c62f92dd..4a0633951 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -341,11 +341,8 @@ class UserMentionOrID(UserConverter): async def convert(self, ctx: Context, argument: str) -> discord.User: """Convert the `arg` to a `discord.User`.""" - print(argument) match = self._get_id_match(argument) or re.match(r'<@!?([0-9]+)>$', argument) - print(match) - if match is not None: return await super().convert(ctx, argument) else: -- cgit v1.2.3 From 281ffdfdcaa900cb82c4f0a9f0b0ae5c859a4de4 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 15 Jul 2020 18:28:03 +0200 Subject: Added command&system to purge all messages up to given message --- bot/cogs/clean.py | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 368d91c85..7b2b83a02 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -45,6 +45,7 @@ class Clean(Cog): bots_only: bool = False, user: User = None, regex: Optional[str] = None, + until_message: Optional[Message] = None, ) -> None: """A helper function that does the actual message cleaning.""" def predicate_bots_only(message: Message) -> bool: @@ -129,6 +130,25 @@ class Clean(Cog): if not self.cleaning: return + # If we are looking for specific message. + if until_message: + # Since we will be using `delete_messages` method + # of a TextChannel + # and we need message objects to use it + # as well as to send logs + # we will start appending messages here + # instead adding them from purge. + messages.append(message) + # we could use ID's here however + # in case if the message we are looking for + # gets deleted, we won't have a way to figure that out + # thus checking for datetime should be more reliable + if message.created_at <= until_message.created_at: + # means we have found the message until which + # we were supposed to be deleting. + message_ids.append(message.id) + break + # If the message passes predicate, let's save it. if predicate is None or predicate(message): message_ids.append(message.id) @@ -138,7 +158,14 @@ class Clean(Cog): # Now let's delete the actual messages with purge. self.mod_log.ignore(Event.message_delete, *message_ids) for channel in channels: - messages += await channel.purge(limit=amount, check=predicate) + if until_message: + for i in range(0, len(messages), 100): + # while purge automatically handles the amount of messages + # delete_messages only allows for up to 100 messages at once + # thus we need to paginate the amount to always be <= 100 + await channel.delete_messages(messages[i:i + 100]) + else: + messages += await channel.purge(limit=amount, check=predicate) # Reverse the list to restore chronological order if messages: @@ -221,6 +248,17 @@ class Clean(Cog): """Delete all messages that match a certain regex, stop cleaning after traversing `amount` messages.""" await self._clean_messages(amount, ctx, regex=regex, channels=channels) + @clean_group.command(name="message", aliases=["messages"]) + @with_role(*MODERATION_ROLES) + async def clean_message(self, ctx: Context, message: Message) -> None: + """Delete all messages until certain message, stop cleaning after hitting the `message`""" + await self._clean_messages( + CleanMessages.message_limit, + ctx, + channels=[message.channel], + until_message=message + ) + @clean_group.command(name="stop", aliases=["cancel", "abort"]) @with_role(*MODERATION_ROLES) async def clean_cancel(self, ctx: Context) -> None: -- cgit v1.2.3 From ace60c4776ee02104390f0c782543118290e53c8 Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Wed, 15 Jul 2020 19:04:15 +0200 Subject: Fix docstring and comments --- bot/cogs/clean.py | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index 7b2b83a02..aee7fa055 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -132,20 +132,14 @@ class Clean(Cog): # If we are looking for specific message. if until_message: - # Since we will be using `delete_messages` method - # of a TextChannel - # and we need message objects to use it - # as well as to send logs - # we will start appending messages here - # instead adding them from purge. + # Since we will be using `delete_messages` method of a TextChannel and we need message objects to + # use it as well as to send logs we will start appending messages here instead adding them from + # purge. messages.append(message) - # we could use ID's here however - # in case if the message we are looking for - # gets deleted, we won't have a way to figure that out - # thus checking for datetime should be more reliable + # we could use ID's here however in case if the message we are looking for gets deleted, + # we won't have a way to figure that out thus checking for datetime should be more reliable if message.created_at <= until_message.created_at: - # means we have found the message until which - # we were supposed to be deleting. + # means we have found the message until which we were supposed to be deleting. message_ids.append(message.id) break @@ -251,7 +245,7 @@ class Clean(Cog): @clean_group.command(name="message", aliases=["messages"]) @with_role(*MODERATION_ROLES) async def clean_message(self, ctx: Context, message: Message) -> None: - """Delete all messages until certain message, stop cleaning after hitting the `message`""" + """Delete all messages until certain message, stop cleaning after hitting the `message`.""" await self._clean_messages( CleanMessages.message_limit, ctx, -- cgit v1.2.3 From 776b4379c478284803a4a526b5f14fe63d8e7c01 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 11:45:15 +0800 Subject: Remove duplicate reminder deletion. The function `_delete_reminder` was called twice, once in `schedule_reminder`, which calls `send_reminder`, then another in `send_reminder` itself. This led to a 404 response from the site api, as the reminder was already deleted the first time. Fixes BOT-6W --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 0d20bdb2b..4f2ab1781 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -55,6 +55,7 @@ class Reminders(Cog): if remind_at < now: late = relativedelta(now, remind_at) await self.send_reminder(reminder, late) + await self._delete_reminder(reminder["id"]) else: self.schedule_reminder(reminder) @@ -157,7 +158,6 @@ class Reminders(Cog): content=user.mention, embed=embed ) - await self._delete_reminder(reminder["id"]) @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None: -- cgit v1.2.3 From 9389543fe89f623301842b3f850cf767d1bf45ea Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 12:29:20 +0800 Subject: Extract sending error embed to a separate method. --- bot/cogs/reminders.py | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 4f2ab1781..ebf85cc4d 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -100,6 +100,16 @@ class Reminders(Cog): await ctx.send(embed=embed) + @staticmethod + async def _send_denial(ctx: Context, reason: str) -> None: + """Send an embed denying the user from creating a reminder.""" + embed = discord.Embed() + embed.colour = discord.Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = reason + + await ctx.send(embed=embed) + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] @@ -171,18 +181,12 @@ class Reminders(Cog): Expiration is parsed per: http://strftime.org/ """ - embed = discord.Embed() - # If the user is not staff, we need to verify whether or not to make a reminder at all. if without_role_check(ctx, *STAFF_ROLES): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: - embed.colour = discord.Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = "Sorry, you can't do that here!" - - return await ctx.send(embed=embed) + return await self._send_denial(ctx, "Sorry, you can't do that here!") # Get their current active reminders active_reminders = await self.bot.api_client.get( @@ -195,12 +199,7 @@ class Reminders(Cog): # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: - embed.colour = discord.Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = "You have too many active reminders!" - - return await ctx.send(embed=embed) - + return await self._send_denial(ctx, "You have too many active reminders!") # Now we can attempt to actually set the reminder. reminder = await self.bot.api_client.post( 'bot/reminders', -- cgit v1.2.3 From 61459ed1fd40b10eb9c61d2b2d3ae1cea3547ea8 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:01:54 +0800 Subject: Add method to check if user is allowed to mention in reminders. --- bot/cogs/reminders.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ebf85cc4d..aefc4a359 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -9,10 +9,11 @@ from operator import itemgetter import discord from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta +from discord import Member, Role from discord.ext.commands import Cog, Context, group from bot.bot import Bot -from bot.constants import Guild, Icons, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check @@ -24,6 +25,8 @@ log = logging.getLogger(__name__) WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 +Mentionable = t.Union[Member, Role] + class Reminders(Cog): """Provide in-channel reminder functionality.""" @@ -110,6 +113,23 @@ class Reminders(Cog): await ctx.send(embed=embed) + @staticmethod + async def allow_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: + """ + Returns whether or not the list of mentions is allowed. + + Conditions: + - Role reminders are Mods+ + - Reminders for other users are Helpers+ + If mentions aren't allowed, also return the type of mention(s) disallowed. + """ + if without_role_check(ctx, *STAFF_ROLES): + return False, "members/roles" + elif without_role_check(ctx, *MODERATION_ROLES): + return all(isinstance(mention, Member) for mention in mentions), "roles" + else: + return True, "" + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] -- cgit v1.2.3 From 76b8e4625e853bcc96c946fa408c2267e78dbc72 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:10:28 +0800 Subject: Add generator that converts IDs to Role or Member objects. --- bot/cogs/reminders.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index aefc4a359..ab47f3b11 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -130,6 +130,13 @@ class Reminders(Cog): else: return True, "" + def get_mentionables_from_ids(self, mention_ids: t.List[str]) -> t.Iterator[Mentionable]: + """Converts Role and Member ids to their corresponding objects if possible.""" + guild = self.bot.get_guild(Guild.id) + for mention_id in mention_ids: + if (mentionable := (guild.get_member(mention_id) or guild.get_role(mention_id))): + yield mentionable + def schedule_reminder(self, reminder: dict) -> None: """A coroutine which sends the reminder once the time is reached, and cancels the running task.""" reminder_id = reminder["id"] -- cgit v1.2.3 From da2849a4fbbc5b2180cc042be66f1511017b2488 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:12:12 +0800 Subject: Allow mentioning other users and roles in reminders. --- bot/cogs/reminders.py | 32 ++++++++++++++++++++++++++------ 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ab47f3b11..5ef35602c 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -10,7 +10,7 @@ import discord from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta from discord import Member, Role -from discord.ext.commands import Cog, Context, group +from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot from bot.constants import Guild, Icons, MODERATION_ROLES, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES @@ -197,12 +197,16 @@ class Reminders(Cog): ) @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) - async def remind_group(self, ctx: Context, expiration: Duration, *, content: str) -> None: + async def remind_group( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> None: """Commands for managing your reminders.""" - await ctx.invoke(self.new_reminder, expiration=expiration, content=content) + await ctx.invoke(self.new_reminder, mentions=mentions, expiration=expiration, content=content) @remind_group.command(name="new", aliases=("add", "create")) - async def new_reminder(self, ctx: Context, expiration: Duration, *, content: str) -> t.Optional[discord.Message]: + async def new_reminder( + self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str + ) -> t.Optional[discord.Message]: """ Set yourself a simple reminder. @@ -227,6 +231,17 @@ class Reminders(Cog): # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: return await self._send_denial(ctx, "You have too many active reminders!") + + # Filter mentions to see if the user can mention members/roles + if mentions: + mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) + if not mentions_allowed: + return await self._send_denial( + ctx, f"You can't mention other {disallowed_mentions} in your reminder!" + ) + + mention_ids = [mention.id for mention in mentions] + # Now we can attempt to actually set the reminder. reminder = await self.bot.api_client.post( 'bot/reminders', @@ -235,17 +250,22 @@ class Reminders(Cog): 'channel_id': ctx.message.channel.id, 'jump_url': ctx.message.jump_url, 'content': content, - 'expiration': expiration.isoformat() + 'expiration': expiration.isoformat(), + 'mentions': mention_ids, } ) now = datetime.utcnow() - timedelta(seconds=1) humanized_delta = humanize_delta(relativedelta(expiration, now)) + mention_string = ( + f"Your reminder will arrive in {humanized_delta} " + f"and will mention {len(mentions)} other(s)!" + ) # Confirm to the user that it worked. await self._send_confirmation( ctx, - on_success=f"Your reminder will arrive in {humanized_delta}!", + on_success=mention_string, reminder_id=reminder["id"], delivery_dt=expiration, ) -- cgit v1.2.3 From 1ee3febc398deafa4d87b6db93b4e3af6976b0e7 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:13:05 +0800 Subject: Send additional mentions in reminders. --- bot/cogs/reminders.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 5ef35602c..a004902c2 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -191,8 +191,12 @@ class Reminders(Cog): name=f"Sorry it arrived {humanize_delta(late, max_units=2)} late!" ) + additional_mentions = ' '.join( + mentionable.mention for mentionable in self.get_mentionables_from_ids(reminder["mentions"]) + ) + await channel.send( - content=user.mention, + content=f"{user.mention} {additional_mentions}", embed=embed ) -- cgit v1.2.3 From cf9a350934a099ae71fcc0c46fbf57d7fb82dd86 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:14:10 +0800 Subject: List additional mentions in `!reminder list`. --- bot/cogs/reminders.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index a004902c2..fd3c6efa2 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -290,7 +290,7 @@ class Reminders(Cog): # Make a list of tuples so it can be sorted by time. reminders = sorted( ( - (rem['content'], rem['expiration'], rem['id']) + (rem['content'], rem['expiration'], rem['id'], rem['mentions']) for rem in data ), key=itemgetter(1) @@ -298,13 +298,19 @@ class Reminders(Cog): lines = [] - for content, remind_at, id_ in reminders: + for content, remind_at, id_, mentions in reminders: # Parse and humanize the time, make it pretty :D remind_datetime = isoparse(remind_at).replace(tzinfo=None) time = humanize_delta(relativedelta(remind_datetime, now)) + mentions = ", ".join( + # Both Role and User objects have the `name` attribute + mention.name for mention in self.get_mentionables_from_ids(mentions) + ) + mention_string = f"\n**Mentions:** {mentions}" if mentions else "" + text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}) + **Reminder #{id_}:** *expires in {time}* (ID: {id_}) {mention_string} {content} """).strip() -- cgit v1.2.3 From 3f319488f479cd38e719201b4c926ace68ef9102 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Thu, 16 Jul 2020 13:14:38 +0800 Subject: Allow editing additional mentions for reminders. --- bot/cogs/reminders.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index fd3c6efa2..9eddd283b 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -384,6 +384,34 @@ class Reminders(Cog): ) await self._reschedule_reminder(reminder) + @edit_reminder_group.command(name="mentions", aliases=("pings",)) + async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: + """Edit one of your reminder's mentions.""" + # Filter mentions to see if the user can mention members/roles + mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) + if not mentions_allowed: + return await self._send_denial( + ctx, f"You can't mention other {disallowed_mentions} in your reminder!" + ) + + mention_ids = [mention.id for mention in mentions] + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(id_), + json={"mentions": mention_ids} + ) + + # Parse the reminder expiration back into a datetime for the confirmation message + expiration = isoparse(reminder['expiration']).replace(tzinfo=None) + + # Send a confirmation message to the channel + await self._send_confirmation( + ctx, + on_success="That reminder has been edited successfully!", + reminder_id=id_, + delivery_dt=expiration, + ) + await self._reschedule_reminder(reminder) + @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" -- cgit v1.2.3 From b6abe9cbb2e63f562bb44e14d51ea87f19da32ac Mon Sep 17 00:00:00 2001 From: Senjan21 <53477086+Senjan21@users.noreply.github.com> Date: Thu, 16 Jul 2020 10:41:54 +0200 Subject: Prevent deleting messages above the desired message. --- bot/cogs/clean.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/cogs/clean.py b/bot/cogs/clean.py index aee7fa055..f436e531a 100644 --- a/bot/cogs/clean.py +++ b/bot/cogs/clean.py @@ -132,17 +132,18 @@ class Clean(Cog): # If we are looking for specific message. if until_message: - # Since we will be using `delete_messages` method of a TextChannel and we need message objects to - # use it as well as to send logs we will start appending messages here instead adding them from - # purge. - messages.append(message) + # we could use ID's here however in case if the message we are looking for gets deleted, # we won't have a way to figure that out thus checking for datetime should be more reliable - if message.created_at <= until_message.created_at: + if message.created_at < until_message.created_at: # means we have found the message until which we were supposed to be deleting. - message_ids.append(message.id) break + # Since we will be using `delete_messages` method of a TextChannel and we need message objects to + # use it as well as to send logs we will start appending messages here instead adding them from + # purge. + messages.append(message) + # If the message passes predicate, let's save it. if predicate is None or predicate(message): message_ids.append(message.id) -- cgit v1.2.3 From 6f5fb205bcc3f9b468ef585f83e123e5b19d7340 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 16 Jul 2020 17:03:02 +0200 Subject: Incidents: reduce log level of 404 exception Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 2 ++ tests/bot/cogs/moderation/test_incidents.py | 14 ++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 018538040..2d5f26f20 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -55,6 +55,8 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi log.debug(f"Attempting to download attachment: {attachment.filename}") try: return await attachment.to_file() + except discord.NotFound as not_found: + log.debug(f"Failed to download attachment: {not_found}") except Exception: log.exception("Failed to download attachment") diff --git a/tests/bot/cogs/moderation/test_incidents.py b/tests/bot/cogs/moderation/test_incidents.py index 9b6054f55..435a1cd51 100644 --- a/tests/bot/cogs/moderation/test_incidents.py +++ b/tests/bot/cogs/moderation/test_incidents.py @@ -81,13 +81,23 @@ class TestDownloadFile(unittest.IsolatedAsyncioTestCase): acquired_file = await incidents.download_file(attachment) self.assertIs(file, acquired_file) - async def test_download_file_fail(self): - """If `to_file` fails, function handles the exception & returns None.""" + async def test_download_file_404(self): + """If `to_file` encounters a 404, function handles the exception & returns None.""" attachment = MockAttachment(to_file=AsyncMock(side_effect=mock_404)) acquired_file = await incidents.download_file(attachment) self.assertIsNone(acquired_file) + async def test_download_file_fail(self): + """If `to_file` fails on a non-404 error, function logs the exception & returns None.""" + arbitrary_error = discord.HTTPException(MagicMock(aiohttp.ClientResponse), "Arbitrary API error") + attachment = MockAttachment(to_file=AsyncMock(side_effect=arbitrary_error)) + + with self.assertLogs(logger=incidents.log, level=logging.ERROR): + acquired_file = await incidents.download_file(attachment) + + self.assertIsNone(acquired_file) + class TestMakeEmbed(unittest.IsolatedAsyncioTestCase): """Collection of tests for the `make_embed` helper function.""" -- cgit v1.2.3 From 4fb188da6af5a1751e7a996693001d464232b10c Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 16 Jul 2020 19:54:06 +0200 Subject: Bugfix: Show ID for embed DM relays, too. --- bot/cogs/dm_relay.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/dm_relay.py b/bot/cogs/dm_relay.py index 0dc15d4b1..0d8f340b4 100644 --- a/bot/cogs/dm_relay.py +++ b/bot/cogs/dm_relay.py @@ -99,7 +99,7 @@ class DMRelay(Cog): await send_webhook( webhook=self.webhook, embed=e, - username=message.author.display_name, + username=f"{message.author.display_name} ({message.author.id})", avatar_url=message.author.avatar_url ) except discord.HTTPException: -- cgit v1.2.3 From 476d4070940830d14859f7cc8970a14409d142a6 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 16 Jul 2020 20:27:32 +0200 Subject: Incidents: reduce log level of 403 exception In addition to 404, this shouldn't send Sentry notifs. Co-authored-by: MarkKoz --- bot/cogs/moderation/incidents.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/cogs/moderation/incidents.py b/bot/cogs/moderation/incidents.py index 2d5f26f20..3605ab1d2 100644 --- a/bot/cogs/moderation/incidents.py +++ b/bot/cogs/moderation/incidents.py @@ -51,12 +51,13 @@ async def download_file(attachment: discord.Attachment) -> t.Optional[discord.Fi Download & return `attachment` file. If the download fails, the reason is logged and None will be returned. + 404 and 403 errors are only logged at debug level. """ log.debug(f"Attempting to download attachment: {attachment.filename}") try: return await attachment.to_file() - except discord.NotFound as not_found: - log.debug(f"Failed to download attachment: {not_found}") + except (discord.NotFound, discord.Forbidden) as exc: + log.debug(f"Failed to download attachment: {exc}") except Exception: log.exception("Failed to download attachment") -- cgit v1.2.3 From cdf4e2595b8321158bcb514936b7c2a23a88cd0d Mon Sep 17 00:00:00 2001 From: Kieran Siek Date: Sun, 19 Jul 2020 13:10:14 +0800 Subject: Add whitespace to improve readability Co-authored-by: Mark --- bot/cogs/reminders.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 9eddd283b..5f76164cd 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -121,6 +121,7 @@ class Reminders(Cog): Conditions: - Role reminders are Mods+ - Reminders for other users are Helpers+ + If mentions aren't allowed, also return the type of mention(s) disallowed. """ if without_role_check(ctx, *STAFF_ROLES): -- cgit v1.2.3 From 1d9efd32278688adebd539b15c4d16d4dd88e74c Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 13:17:45 +0800 Subject: Namespace Member and Role to avoid extra imports --- bot/cogs/reminders.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 9eddd283b..ae387f09a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -9,7 +9,6 @@ from operator import itemgetter import discord from dateutil.parser import isoparse from dateutil.relativedelta import relativedelta -from discord import Member, Role from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot @@ -25,7 +24,7 @@ log = logging.getLogger(__name__) WHITELISTED_CHANNELS = Guild.reminder_whitelist MAXIMUM_REMINDERS = 5 -Mentionable = t.Union[Member, Role] +Mentionable = t.Union[discord.Member, discord.Role] class Reminders(Cog): @@ -126,7 +125,7 @@ class Reminders(Cog): if without_role_check(ctx, *STAFF_ROLES): return False, "members/roles" elif without_role_check(ctx, *MODERATION_ROLES): - return all(isinstance(mention, Member) for mention in mentions), "roles" + return all(isinstance(mention, discord.Member) for mention in mentions), "roles" else: return True, "" -- cgit v1.2.3 From ced9117e848a9eb1e003576d4f355ba7aa220cd8 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 13:31:00 +0800 Subject: Extract `send_denial` to a utility function --- bot/cogs/reminders.py | 21 ++++++--------------- bot/utils/messages.py | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index ae387f09a..f36b67f5a 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -12,10 +12,11 @@ from dateutil.relativedelta import relativedelta from discord.ext.commands import Cog, Context, Greedy, group from bot.bot import Bot -from bot.constants import Guild, Icons, MODERATION_ROLES, NEGATIVE_REPLIES, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator from bot.utils.checks import without_role_check +from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -102,16 +103,6 @@ class Reminders(Cog): await ctx.send(embed=embed) - @staticmethod - async def _send_denial(ctx: Context, reason: str) -> None: - """Send an embed denying the user from creating a reminder.""" - embed = discord.Embed() - embed.colour = discord.Colour.red() - embed.title = random.choice(NEGATIVE_REPLIES) - embed.description = reason - - await ctx.send(embed=embed) - @staticmethod async def allow_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: """ @@ -220,7 +211,7 @@ class Reminders(Cog): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: - return await self._send_denial(ctx, "Sorry, you can't do that here!") + return await send_denial(ctx, "Sorry, you can't do that here!") # Get their current active reminders active_reminders = await self.bot.api_client.get( @@ -233,13 +224,13 @@ class Reminders(Cog): # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: - return await self._send_denial(ctx, "You have too many active reminders!") + return await send_denial(ctx, "You have too many active reminders!") # Filter mentions to see if the user can mention members/roles if mentions: mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) if not mentions_allowed: - return await self._send_denial( + return await send_denial( ctx, f"You can't mention other {disallowed_mentions} in your reminder!" ) @@ -389,7 +380,7 @@ class Reminders(Cog): # Filter mentions to see if the user can mention members/roles mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) if not mentions_allowed: - return await self._send_denial( + return await send_denial( ctx, f"You can't mention other {disallowed_mentions} in your reminder!" ) diff --git a/bot/utils/messages.py b/bot/utils/messages.py index a40a12e98..670289941 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -1,15 +1,17 @@ import asyncio import contextlib import logging +import random import re from io import BytesIO from typing import List, Optional, Sequence, Union -from discord import Client, Embed, File, Member, Message, Reaction, TextChannel, Webhook +from discord import Client, Colour, Embed, File, Member, Message, Reaction, TextChannel, Webhook from discord.abc import Snowflake from discord.errors import HTTPException +from discord.ext.commands import Context -from bot.constants import Emojis +from bot.constants import Emojis, NEGATIVE_REPLIES log = logging.getLogger(__name__) @@ -132,3 +134,13 @@ def sub_clyde(username: Optional[str]) -> Optional[str]: return re.sub(r"(clyd)(e)", replace_e, username, flags=re.I) else: return username # Empty string or None + + +async def send_denial(ctx: Context, reason: str) -> None: + """Send an embed denying the user with the given reason.""" + embed = Embed() + embed.colour = Colour.red() + embed.title = random.choice(NEGATIVE_REPLIES) + embed.description = reason + + await ctx.send(embed=embed) -- cgit v1.2.3 From 010373673700f821b5860bd749f40fdf8d59d134 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 13:39:24 +0800 Subject: Fix incorrect typehint and shorten method name --- bot/cogs/reminders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index f36b67f5a..d36494a69 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -120,7 +120,7 @@ class Reminders(Cog): else: return True, "" - def get_mentionables_from_ids(self, mention_ids: t.List[str]) -> t.Iterator[Mentionable]: + def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: """Converts Role and Member ids to their corresponding objects if possible.""" guild = self.bot.get_guild(Guild.id) for mention_id in mention_ids: @@ -182,7 +182,7 @@ class Reminders(Cog): ) additional_mentions = ' '.join( - mentionable.mention for mentionable in self.get_mentionables_from_ids(reminder["mentions"]) + mentionable.mention for mentionable in self.get_mentionables(reminder["mentions"]) ) await channel.send( @@ -295,7 +295,7 @@ class Reminders(Cog): mentions = ", ".join( # Both Role and User objects have the `name` attribute - mention.name for mention in self.get_mentionables_from_ids(mentions) + mention.name for mention in self.get_mentionables(mentions) ) mention_string = f"\n**Mentions:** {mentions}" if mentions else "" -- cgit v1.2.3 From 4ef9770ed26b46bc5916f4147acce1cf57f4f9af Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 21:13:08 +0800 Subject: Rename method to improve readability --- bot/cogs/reminders.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index d36494a69..6755993a0 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -104,7 +104,7 @@ class Reminders(Cog): await ctx.send(embed=embed) @staticmethod - async def allow_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: + async def check_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: """ Returns whether or not the list of mentions is allowed. @@ -228,7 +228,7 @@ class Reminders(Cog): # Filter mentions to see if the user can mention members/roles if mentions: - mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) + mentions_allowed, disallowed_mentions = await self.check_mentions(ctx, mentions) if not mentions_allowed: return await send_denial( ctx, f"You can't mention other {disallowed_mentions} in your reminder!" @@ -378,7 +378,7 @@ class Reminders(Cog): async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: """Edit one of your reminder's mentions.""" # Filter mentions to see if the user can mention members/roles - mentions_allowed, disallowed_mentions = await self.allow_mentions(ctx, mentions) + mentions_allowed, disallowed_mentions = await self.check_mentions(ctx, mentions) if not mentions_allowed: return await send_denial( ctx, f"You can't mention other {disallowed_mentions} in your reminder!" -- cgit v1.2.3 From ff7707f8306c26ef9d14944e2826671bbcfcf113 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:17:48 +0800 Subject: Refactor reminder edits to reduce code duplication The reminder expiration returnedfrom the API call is also now parsed again even when the edit is to the duration since it does not matter and trying to keep it DRY while still doing that check is a pain. --- bot/cogs/reminders.py | 65 ++++++++++++++++++++------------------------------- 1 file changed, 25 insertions(+), 40 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 6755993a0..d99979ace 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -148,6 +148,19 @@ class Reminders(Cog): # Now we can remove it from the schedule list self.scheduler.cancel(reminder_id) + async def _edit_reminder(self, reminder_id: int, payload: dict) -> dict: + """ + Edits a reminder in the database given the ID and payload. + + Returns the edited reminder. + """ + # Send the request to update the reminder in the database + reminder = await self.bot.api_client.patch( + 'bot/reminders/' + str(reminder_id), + json=payload + ) + return reminder + async def _reschedule_reminder(self, reminder: dict) -> None: """Reschedule a reminder object.""" log.trace(f"Cancelling old task #{reminder['id']}") @@ -300,7 +313,7 @@ class Reminders(Cog): mention_string = f"\n**Mentions:** {mentions}" if mentions else "" text = textwrap.dedent(f""" - **Reminder #{id_}:** *expires in {time}* (ID: {id_}) {mention_string} + **Reminder #{id_}:** *expires in {time}* (ID: {id_}){mention_string} {content} """).strip() @@ -333,46 +346,16 @@ class Reminders(Cog): @edit_reminder_group.command(name="duration", aliases=("time",)) async def edit_reminder_duration(self, ctx: Context, id_: int, expiration: Duration) -> None: """ - Edit one of your reminder's expiration. + Edit one of your reminder's expiration. Expiration is parsed per: http://strftime.org/ """ - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(id_), - json={'expiration': expiration.isoformat()} - ) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - delivery_dt=expiration, - ) - - await self._reschedule_reminder(reminder) + 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: """Edit one of your reminder's content.""" - # Send the request to update the reminder in the database - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(id_), - json={'content': content} - ) - - # Parse the reminder expiration back into a datetime for the confirmation message - expiration = isoparse(reminder['expiration']).replace(tzinfo=None) - - # Send a confirmation message to the channel - await self._send_confirmation( - ctx, - on_success="That reminder has been edited successfully!", - reminder_id=id_, - delivery_dt=expiration, - ) - await self._reschedule_reminder(reminder) + await self.edit_reminder(ctx, id_, {"content": content}) @edit_reminder_group.command(name="mentions", aliases=("pings",)) async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: @@ -385,13 +368,15 @@ class Reminders(Cog): ) mention_ids = [mention.id for mention in mentions] - reminder = await self.bot.api_client.patch( - 'bot/reminders/' + str(id_), - json={"mentions": mention_ids} - ) - # Parse the reminder expiration back into a datetime for the confirmation message - expiration = isoparse(reminder['expiration']).replace(tzinfo=None) + await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) + + async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: + """Edits a reminder with the given payload, then sends a confirmation message.""" + reminder = await self._edit_reminder(id_, payload) + + # Parse the reminder expiration back into a datetime + expiration = isoparse(reminder["expiration"]).replace(tzinfo=None) # Send a confirmation message to the channel await self._send_confirmation( -- cgit v1.2.3 From c491d054daaa9f3e2ff3ecffba626b9991f93005 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:37:08 +0800 Subject: Move mentions validation to another method --- bot/cogs/reminders.py | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index d99979ace..cc20897e0 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -104,7 +104,7 @@ class Reminders(Cog): await ctx.send(embed=embed) @staticmethod - async def check_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: + async def _check_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: """ Returns whether or not the list of mentions is allowed. @@ -120,6 +120,21 @@ class Reminders(Cog): else: return True, "" + @staticmethod + async def validate_mentions(ctx: Context, mentions: t.List[Mentionable]) -> bool: + """ + Filter mentions to see if the user can mention, and sends a denial if not allowed. + + Returns whether or not the validation is successful. + """ + mentions_allowed, disallowed_mentions = await Reminders._check_mentions(ctx, mentions) + + if not mentions or mentions_allowed: + return True + else: + await send_denial(ctx, f"You can't mention other {disallowed_mentions} in your reminder!") + return False + def get_mentionables(self, mention_ids: t.List[int]) -> t.Iterator[Mentionable]: """Converts Role and Member ids to their corresponding objects if possible.""" guild = self.bot.get_guild(Guild.id) @@ -240,12 +255,8 @@ class Reminders(Cog): return await send_denial(ctx, "You have too many active reminders!") # Filter mentions to see if the user can mention members/roles - if mentions: - mentions_allowed, disallowed_mentions = await self.check_mentions(ctx, mentions) - if not mentions_allowed: - return await send_denial( - ctx, f"You can't mention other {disallowed_mentions} in your reminder!" - ) + if not await self.validate_mentions(ctx, mentions): + return mention_ids = [mention.id for mention in mentions] @@ -361,14 +372,10 @@ class Reminders(Cog): async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: """Edit one of your reminder's mentions.""" # Filter mentions to see if the user can mention members/roles - mentions_allowed, disallowed_mentions = await self.check_mentions(ctx, mentions) - if not mentions_allowed: - return await send_denial( - ctx, f"You can't mention other {disallowed_mentions} in your reminder!" - ) + if not await self.validate_mentions(ctx, mentions): + return mention_ids = [mention.id for mention in mentions] - await self.edit_reminder(ctx, id_, {"mentions": mention_ids}) async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: -- cgit v1.2.3 From daff90ef6ff5de8c9f08b8394979da758e484001 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:39:12 +0800 Subject: Refactor commands return type --- bot/cogs/reminders.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index cc20897e0..1410bfea6 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -228,7 +228,7 @@ class Reminders(Cog): @remind_group.command(name="new", aliases=("add", "create")) async def new_reminder( self, ctx: Context, mentions: Greedy[Mentionable], expiration: Duration, *, content: str - ) -> t.Optional[discord.Message]: + ) -> None: """ Set yourself a simple reminder. @@ -239,7 +239,8 @@ class Reminders(Cog): # If they don't have permission to set a reminder in this channel if ctx.channel.id not in WHITELISTED_CHANNELS: - return await send_denial(ctx, "Sorry, you can't do that here!") + await send_denial(ctx, "Sorry, you can't do that here!") + return # Get their current active reminders active_reminders = await self.bot.api_client.get( @@ -252,7 +253,8 @@ class Reminders(Cog): # Let's limit this, so we don't get 10 000 # reminders from kip or something like that :P if len(active_reminders) > MAXIMUM_REMINDERS: - return await send_denial(ctx, "You have too many active reminders!") + await send_denial(ctx, "You have too many active reminders!") + return # Filter mentions to see if the user can mention members/roles if not await self.validate_mentions(ctx, mentions): @@ -291,7 +293,7 @@ class Reminders(Cog): self.schedule_reminder(reminder) @remind_group.command(name="list") - async def list_reminders(self, ctx: Context) -> t.Optional[discord.Message]: + async def list_reminders(self, ctx: Context) -> None: """View a paginated embed of all reminders for your user.""" # Get all the user's reminders from the database. data = await self.bot.api_client.get( @@ -337,7 +339,8 @@ class Reminders(Cog): # Remind the user that they have no reminders :^) if not lines: embed.description = "No active reminders could be found." - return await ctx.send(embed=embed) + await ctx.send(embed=embed) + return # Construct the embed and paginate it. embed.colour = discord.Colour.blurple() -- cgit v1.2.3 From d658fabaa1238eda9d26175af751e2e8b7f1fc13 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:51:51 +0800 Subject: Remove duplicate mentions from reminder arguments This also accounts for the author passing themselves to mention, and therefore avoids the user from being told they're not allowed to mention themselves even though they could. --- bot/cogs/reminders.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 1410bfea6..60fa70d74 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -104,7 +104,7 @@ class Reminders(Cog): await ctx.send(embed=embed) @staticmethod - async def _check_mentions(ctx: Context, mentions: t.List[Mentionable]) -> t.Tuple[bool, str]: + async def _check_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> t.Tuple[bool, str]: """ Returns whether or not the list of mentions is allowed. @@ -121,7 +121,7 @@ class Reminders(Cog): return True, "" @staticmethod - async def validate_mentions(ctx: Context, mentions: t.List[Mentionable]) -> bool: + async def validate_mentions(ctx: Context, mentions: t.Iterable[Mentionable]) -> bool: """ Filter mentions to see if the user can mention, and sends a denial if not allowed. @@ -256,6 +256,10 @@ class Reminders(Cog): await send_denial(ctx, "You have too many active reminders!") return + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + # Filter mentions to see if the user can mention members/roles if not await self.validate_mentions(ctx, mentions): return @@ -374,6 +378,10 @@ class Reminders(Cog): @edit_reminder_group.command(name="mentions", aliases=("pings",)) async def edit_reminder_mentions(self, ctx: Context, id_: int, mentions: Greedy[Mentionable]) -> None: """Edit one of your reminder's mentions.""" + # Remove duplicate mentions + mentions = set(mentions) + mentions.discard(ctx.author) + # Filter mentions to see if the user can mention members/roles if not await self.validate_mentions(ctx, mentions): return -- cgit v1.2.3 From 69e3a2e31e4c91c1932efe5a584708a3a370bb35 Mon Sep 17 00:00:00 2001 From: kosayoda Date: Sun, 19 Jul 2020 22:54:10 +0800 Subject: Revert "Remove duplicate reminder deletion." This reverts commit 776b4379c478284803a4a526b5f14fe63d8e7c01. This is already being fixed in #835, and therefore is no longer required. --- bot/cogs/reminders.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 60fa70d74..219c52659 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -58,7 +58,6 @@ class Reminders(Cog): if remind_at < now: late = relativedelta(now, remind_at) await self.send_reminder(reminder, late) - await self._delete_reminder(reminder["id"]) else: self.schedule_reminder(reminder) @@ -217,6 +216,7 @@ class Reminders(Cog): content=f"{user.mention} {additional_mentions}", embed=embed ) + await self._delete_reminder(reminder["id"]) @group(name="remind", aliases=("reminder", "reminders", "remindme"), invoke_without_command=True) async def remind_group( -- cgit v1.2.3 From 10b17a5026489c6fa28ed93edef340e4cacdc8c3 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 20 Jul 2020 14:58:00 +0100 Subject: Removed python formatting from returned codeblock --- bot/cogs/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 662f90869..52c8b6f88 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -202,7 +202,7 @@ class Snekbox(Cog): output, paste_link = await self.format_output(results["stdout"]) icon = self.get_status_emoji(results) - msg = f"{ctx.author.mention} {icon} {msg}.\n\n```py\n{output}\n```" + msg = f"{ctx.author.mention} {icon} {msg}.\n\n```\n{output}\n```" if paste_link: msg = f"{msg}\nFull output: {paste_link}" -- cgit v1.2.3 From e93fdaf57d1d35394b466a6bd1c84712e29415d7 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Mon, 20 Jul 2020 16:05:32 +0100 Subject: Edited tests to reflect changes (removed py formatting) --- tests/bot/cogs/test_snekbox.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/bot/cogs/test_snekbox.py b/tests/bot/cogs/test_snekbox.py index 98dee7a1b..343e37db9 100644 --- a/tests/bot/cogs/test_snekbox.py +++ b/tests/bot/cogs/test_snekbox.py @@ -239,7 +239,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```py\n[No output]\n```' + '@LemonLemonishBeard#0042 :yay!: Return code 0.\n\n```\n[No output]\n```' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': '', 'returncode': 0}) @@ -265,7 +265,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( '@LemonLemonishBeard#0042 :yay!: Return code 0.' - '\n\n```py\nWay too long beard\n```\nFull output: lookatmybeard.com' + '\n\n```\nWay too long beard\n```\nFull output: lookatmybeard.com' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': 'Way too long beard', 'returncode': 0}) @@ -289,7 +289,7 @@ class SnekboxTests(unittest.IsolatedAsyncioTestCase): await self.cog.send_eval(ctx, 'MyAwesomeCode') ctx.send.assert_called_once_with( - '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```py\nBeard got stuck in the eval\n```' + '@LemonLemonishBeard#0042 :nope!: Return code 127.\n\n```\nBeard got stuck in the eval\n```' ) self.cog.post_eval.assert_called_once_with('MyAwesomeCode') self.cog.get_status_emoji.assert_called_once_with({'stdout': 'ERROR', 'returncode': 127}) -- cgit v1.2.3 From b6f11f518acc29cf0bc025f2c89005556a06553a Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 21 Jul 2020 01:26:09 +0100 Subject: Use max_units for time since join in user command instead of precision --- bot/cogs/information.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index f0bd1afdb..d6090d481 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -226,7 +226,7 @@ class Information(Cog): if user.nick: name = f"{user.nick} ({name})" - joined = time_since(user.joined_at, precision="days") + joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) description = [ -- cgit v1.2.3