From db79b6acb8c4204ef2dad7053d94f0ddcec3c283 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 17 Jul 2020 21:30:39 +0200 Subject: Kaizen: Move OffTopicName to converters.py. --- bot/cogs/off_topic_names.py | 31 ++----------------------------- bot/converters.py | 26 ++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/bot/cogs/off_topic_names.py b/bot/cogs/off_topic_names.py index 201579a0b..ce95450e0 100644 --- a/bot/cogs/off_topic_names.py +++ b/bot/cogs/off_topic_names.py @@ -4,46 +4,19 @@ import logging from datetime import datetime, timedelta from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, Converter, group +from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError from bot.bot import Bot from bot.constants import Channels, MODERATION_ROLES +from bot.converters import OffTopicName from bot.decorators import with_role from bot.pagination import LinePaginator - CHANNELS = (Channels.off_topic_0, Channels.off_topic_1, Channels.off_topic_2) log = logging.getLogger(__name__) -class OffTopicName(Converter): - """A converter that ensures an added off-topic name is valid.""" - - @staticmethod - async def convert(ctx: Context, argument: str) -> str: - """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" - allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" - - # Chain multiple words to a single one - argument = "-".join(argument.split()) - - if not (2 <= len(argument) <= 96): - raise BadArgument("Channel name must be between 2 and 96 chars long") - - elif not all(c.isalnum() or c in allowed_characters for c in argument): - raise BadArgument( - "Channel name must only consist of " - "alphanumeric characters, minus signs or apostrophes." - ) - - # Replace invalid characters with unicode alternatives. - table = str.maketrans( - allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-' - ) - return argument.translate(table) - - async def update_names(bot: Bot) -> None: """Background updater task that performs the daily channel name update.""" while True: diff --git a/bot/converters.py b/bot/converters.py index 4a0633951..406fd0d68 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -237,6 +237,32 @@ class Duration(DurationDelta): raise BadArgument(f"`{duration}` results in a datetime outside the supported range.") +class OffTopicName(Converter): + """A converter that ensures an added off-topic name is valid.""" + + async def convert(self, ctx: Context, argument: str) -> str: + """Attempt to replace any invalid characters with their approximate Unicode equivalent.""" + allowed_characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ!?'`-" + + # Chain multiple words to a single one + argument = "-".join(argument.split()) + + if not (2 <= len(argument) <= 96): + raise BadArgument("Channel name must be between 2 and 96 chars long") + + elif not all(c.isalnum() or c in allowed_characters for c in argument): + raise BadArgument( + "Channel name must only consist of " + "alphanumeric characters, minus signs or apostrophes." + ) + + # Replace invalid characters with unicode alternatives. + table = str.maketrans( + allowed_characters, '𝖠𝖡𝖢𝖣𝖤𝖥𝖦𝖧𝖨𝖩𝖪𝖫𝖬𝖭𝖮𝖯𝖰𝖱𝖲𝖳𝖴𝖵𝖶𝖷𝖸𝖹ǃ?’’-' + ) + return argument.translate(table) + + class ISODateTime(Converter): """Converts an ISO-8601 datetime string into a datetime.datetime.""" -- cgit v1.2.3 From 98c325f316038536270c87d0f767e2c18c215df7 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 17 Jul 2020 21:33:14 +0200 Subject: Cache AllowDenyList data at bot startup. We shouldn't be making an API call for every single message posted, so what we're gonna do is cache the data in the Bot, and then update the cache whenever we make changes to it via our new AllowDenyList cog. Since this cog will be the only way to make changes to this, this level of lazy caching should be enough to always keep the cache up to date. --- bot/bot.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 313652d11..b170be6d3 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -49,6 +49,10 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") + async def _cache_allow_deny_list_data(self) -> None: + """Cache all the data in the AllowDenyList on the site.""" + self.allow_deny_list_cache = await self.api_client.get('bot/allow_deny_lists') + async def _create_redis_session(self) -> None: """ Create the Redis connection pool, and then open the redis event gate. @@ -159,6 +163,9 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(force=True, connector=self._connector) + # Build the AllowDenyList cache + self.loop.create_task(self._cache_allow_deny_list_data()) + async def on_guild_available(self, guild: discord.Guild) -> None: """ Set the internal guild available event when constants.Guild.id becomes available. -- cgit v1.2.3 From d83417432324019b16d0450cdb0c71db9452c52f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 17 Jul 2020 21:34:40 +0200 Subject: Add ValidAllowDenyListType converter. We'll use this to ensure the input is valid when people try to whitelist or blacklist stuff. It will fetch its data from an Enum maintained on the site, so that the types of lists we support will only need to be maintained in a single place, instead of duplicating that data in the bot and the site. --- bot/converters.py | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 406fd0d68..4d2acb910 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -7,7 +7,7 @@ from ssl import CertificateError import dateutil.parser import dateutil.tz import discord -from aiohttp import ClientConnectorError +from aiohttp import ClientConnectorError, ContentTypeError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Context, Converter, UserConverter @@ -34,6 +34,32 @@ def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], s return converter +class ValidAllowDenyListType(Converter): + """ + A converter that checks whether the given string is a valid AllowDenyList type. + + Raises `BadArgument` if the argument is not a valid AllowDenyList type, and simply + passes through the given argument otherwise. + """ + + async def convert(self, ctx: Context, list_type: str) -> str: + """Checks whether the given string is a valid AllowDenyList type.""" + try: + valid_types = await ctx.bot.api_client.get('bot/allow_deny_lists/get_types') + except ContentTypeError: + raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") + + valid_types = [enum for enum, classname in valid_types] + list_type = list_type.upper() + + if list_type not in valid_types: + raise BadArgument( + f"You have provided an invalid AllowDenyList type!\n\n" + f"Please provide one of the following: \n{', '.join(valid_types)}." + ) + return list_type + + class ValidPythonIdentifier(Converter): """ A converter that checks whether the given string is a valid Python identifier. -- cgit v1.2.3 From 0d22a0483e619788f59b6dfe2f8e6f64ec76e326 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 17 Jul 2020 21:40:16 +0200 Subject: Kaizen: Make error_handler.py more embeddy. Currently, some types of errors are returning plain strings that repeat the input (which can be exploited to deliver stuff like mentions), and others are returning generic messages that don't give any exception information. This commit unifies our approach around putting as much information as we can (including the exception message), but always putting it inside an embed, so that stuff like pings will not fire. This, combined with the 1.4.0a `allowed_mentions` functionality, seems like a reasonable compromise between security and usability. --- bot/cogs/error_handler.py | 32 +++++++++++++++++++++++++------- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/bot/cogs/error_handler.py b/bot/cogs/error_handler.py index 233851e41..f9d4de638 100644 --- a/bot/cogs/error_handler.py +++ b/bot/cogs/error_handler.py @@ -2,12 +2,13 @@ import contextlib import logging import typing as t +from discord import Embed from discord.ext.commands import Cog, Context, errors from sentry_sdk import push_scope from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels +from bot.constants import Channels, Colours from bot.converters import TagNameConverter from bot.utils.checks import InWhitelistCheckFailure @@ -20,6 +21,14 @@ class ErrorHandler(Cog): def __init__(self, bot: Bot): self.bot = bot + def _get_error_embed(self, title: str, body: str) -> Embed: + """Return an embed that contains the exception.""" + return Embed( + title=title, + colour=Colours.soft_red, + description=body + ) + @Cog.listener() async def on_command_error(self, ctx: Context, e: errors.CommandError) -> None: """ @@ -162,25 +171,34 @@ class ErrorHandler(Cog): prepared_help_command = self.get_help_command(ctx) if isinstance(e, errors.MissingRequiredArgument): - await ctx.send(f"Missing required argument `{e.param.name}`.") + embed = self._get_error_embed("Missing required argument", e.param.name) + await ctx.send(embed=embed) await prepared_help_command self.bot.stats.incr("errors.missing_required_argument") elif isinstance(e, errors.TooManyArguments): - await ctx.send("Too many arguments provided.") + embed = self._get_error_embed("Too many arguments", str(e)) + await ctx.send(embed=embed) 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") + embed = self._get_error_embed("Bad argument", str(e)) + await ctx.send(embed=embed) await prepared_help_command self.bot.stats.incr("errors.bad_argument") elif isinstance(e, errors.BadUnionArgument): - await ctx.send(f"Bad argument: {e}\n```{e.errors[-1]}```") + embed = self._get_error_embed("Bad argument", f"{e}\n{e.errors[-1]}") + await ctx.send(embed=embed) self.bot.stats.incr("errors.bad_union_argument") elif isinstance(e, errors.ArgumentParsingError): - await ctx.send(f"Argument parsing error: {e}") + embed = self._get_error_embed("Argument parsing error", str(e)) + await ctx.send(embed=embed) self.bot.stats.incr("errors.argument_parsing_error") else: - await ctx.send("Something about your input seems off. Check the arguments:") + embed = self._get_error_embed( + "Input error", + "Something about your input seems off. Check the arguments and try again." + ) + await ctx.send(embed=embed) await prepared_help_command self.bot.stats.incr("errors.other_user_input_error") -- cgit v1.2.3 From ccc2e7abe8762dd394a0e548a47d881dbffdc917 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 12:50:04 +0200 Subject: Better BadArgument exception text. --- bot/converters.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 4d2acb910..429546ba2 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -50,12 +50,13 @@ class ValidAllowDenyListType(Converter): raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") valid_types = [enum for enum, classname in valid_types] + valid_types_lower = [type_.lower() for type_ in valid_types] list_type = list_type.upper() if list_type not in valid_types: raise BadArgument( - f"You have provided an invalid AllowDenyList type!\n\n" - f"Please provide one of the following: \n{', '.join(valid_types)}." + f"You have provided an invalid list type!\n\n" + f"Please provide one of the following: \n{', '.join(valid_types_lower)}." ) return list_type -- cgit v1.2.3 From 7d7fdd7bd27aba48edf65cb8f9da1974ea0aac0b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 13:44:59 +0200 Subject: Bulletlist with valid file types in converter. --- bot/converters.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 429546ba2..edac67be2 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -50,13 +50,13 @@ class ValidAllowDenyListType(Converter): raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") valid_types = [enum for enum, classname in valid_types] - valid_types_lower = [type_.lower() for type_ in valid_types] + valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) list_type = list_type.upper() if list_type not in valid_types: raise BadArgument( f"You have provided an invalid list type!\n\n" - f"Please provide one of the following: \n{', '.join(valid_types_lower)}." + f"Please provide one of the following: \n{valid_types_list}" ) return list_type -- cgit v1.2.3 From b1311ea71adbc3c4c5568363aa971a08f21b2522 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 13:46:10 +0200 Subject: Make the cache more convenient to access. Instead of just dumping the JSON response from the site, we'll build a data structure that it will be convenient to access from our new cog, and from the Filtering cog. --- bot/bot.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/bot/bot.py b/bot/bot.py index b170be6d3..6c02e72a7 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -51,7 +51,19 @@ class Bot(commands.Bot): async def _cache_allow_deny_list_data(self) -> None: """Cache all the data in the AllowDenyList on the site.""" - self.allow_deny_list_cache = await self.api_client.get('bot/allow_deny_lists') + full_cache = await self.api_client.get('bot/allow_deny_lists') + self.allow_deny_list_cache = {} + + for item in full_cache: + type_ = item.get("type") + allowed = item.get("allowed") + metadata = { + "content": item.get("content"), + "id": item.get("id"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + self.allow_deny_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) async def _create_redis_session(self) -> None: """ -- cgit v1.2.3 From 4d1b6a3abee00d9729ce333a25a2440d00d509f1 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 13:47:40 +0200 Subject: Add AllowDenyLists cog. This includes commands to add, remove and show the items in the whitelists and blacklists for the different list types. Commands are limited to Moderators+. --- bot/__main__.py | 1 + bot/cogs/allow_deny_lists.py | 144 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 bot/cogs/allow_deny_lists.py diff --git a/bot/__main__.py b/bot/__main__.py index 49388455a..932aa705c 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -53,6 +53,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") +bot.load_extension("bot.cogs.allow_deny_lists") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py new file mode 100644 index 000000000..d03c774ec --- /dev/null +++ b/bot/cogs/allow_deny_lists.py @@ -0,0 +1,144 @@ +import logging + +from discord import Colour, Embed +from discord.ext.commands import BadArgument, Cog, Context, group + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.converters import ValidAllowDenyListType +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + + +class AllowDenyLists(Cog): + """Commands for blacklisting and whitelisting things.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + async def _add_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: + """Add an item to an allow or denylist.""" + payload = { + 'allowed': allowed, + 'type': list_type, + 'content': content, + } + allow_type = "whitelist" if allowed else "blacklist" + + # Try to add the item to the database + try: + item = await self.bot.api_client.post( + "bot/allow_deny_lists", + json=payload + ) + except ResponseCodeError as e: + if e.status == 500: + await ctx.message.add_reaction("❌") + raise BadArgument( + f"Unable to add the item to the {allow_type}. " + "The item probably already exists. Keep in mind that a " + "blacklist and a whitelist for the same item cannot co-exist, " + "and we do not permit any duplicates." + ) + raise + + # Insert the item into the cache + type_ = item.get("type") + allowed = item.get("allowed") + metadata = { + "content": item.get("content"), + "id": item.get("id"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + self.bot.allow_deny_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + await ctx.message.add_reaction("✅") + + async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: + """Remove an item from an allow or denylist.""" + item = None + + for allow_list in self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []): + if content == allow_list.get("content"): + item = allow_list + break + + if item is not None: + await self.bot.api_client.delete( + f"bot/allow_deny_lists/{item.get('id')}" + ) + self.bot.allow_deny_list_cache[f"{list_type}.{allowed}"].remove(item) + await ctx.message.add_reaction("✅") + + async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType) -> None: + """Paginate and display all items in an allow or denylist.""" + result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) + lines = sorted(f"• {item.get('content')}" for item in result) + allowed_string = "Whitelisted" if allowed else "Blacklisted" + embed = Embed( + title=f"{allowed_string} {list_type.lower()} items ({len(result)} total)", + colour=Colour.blue() + ) + + if result: + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + + @group(aliases=("allowlist", "allow", "al", "wl")) + async def whitelist(self, ctx: Context) -> None: + """Group for whitelisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @group(aliases=("denylist", "deny", "bl", "dl")) + async def blacklist(self, ctx: Context) -> None: + """Group for blacklisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @whitelist.command(name="add", aliases=("a", "set")) + async def allow_add(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + """Add an item to the specified allowlist.""" + await self._add_data(ctx, True, list_type, content) + + @blacklist.command(name="add", aliases=("a", "set")) + async def deny_add(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + """Add an item to the specified denylist.""" + await self._add_data(ctx, False, list_type, content) + + @whitelist.command(name="remove", aliases=("delete", "rm",)) + async def allow_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + """Remove an item from the specified allowlist.""" + await self._delete_data(ctx, True, list_type, content) + + @blacklist.command(name="remove", aliases=("delete", "rm",)) + async def deny_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + """Remove an item from the specified denylist.""" + await self._delete_data(ctx, False, list_type, content) + + @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def allow_get(self, ctx: Context, list_type: ValidAllowDenyListType) -> None: + """Get the contents of a specified allowlist.""" + await self._list_all_data(ctx, True, list_type) + + @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def deny_get(self, ctx: Context, list_type: ValidAllowDenyListType) -> None: + """Get the contents of a specified denylist.""" + await self._list_all_data(ctx, False, list_type) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + checks = [ + with_role_check(ctx, *constants.MODERATION_ROLES), + ] + return all(checks) + + +def setup(bot: Bot) -> None: + """Load the AllowDenyLists cog.""" + bot.add_cog(AllowDenyLists(bot)) -- cgit v1.2.3 From 2228b4229aa2c866616e2452af2c6a2f85c21fef Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 14:20:24 +0200 Subject: Add more logging to AllowDenyLists cog. --- bot/cogs/allow_deny_lists.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index d03c774ec..6558990a7 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -29,6 +29,7 @@ class AllowDenyLists(Cog): allow_type = "whitelist" if allowed else "blacklist" # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") try: item = await self.bot.api_client.post( "bot/allow_deny_lists", @@ -37,6 +38,10 @@ class AllowDenyLists(Cog): except ResponseCodeError as e: if e.status == 500: await ctx.message.add_reaction("❌") + log.debug( + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 500, " + "probably because the request violated the UniqueConstraint." + ) raise BadArgument( f"Unable to add the item to the {allow_type}. " "The item probably already exists. Keep in mind that a " @@ -60,6 +65,9 @@ class AllowDenyLists(Cog): async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: """Remove an item from an allow or denylist.""" item = None + allow_type = "whitelist" if allowed else "blacklist" + + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") for allow_list in self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []): if content == allow_list.get("content"): @@ -77,11 +85,12 @@ class AllowDenyLists(Cog): """Paginate and display all items in an allow or denylist.""" result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) lines = sorted(f"• {item.get('content')}" for item in result) - allowed_string = "Whitelisted" if allowed else "Blacklisted" + allow_type = "whitelist" if allowed else "blacklist" embed = Embed( - title=f"{allowed_string} {list_type.lower()} items ({len(result)} total)", + title=f"{allow_type.title()}ed {list_type.lower()} items ({len(result)} total)", colour=Colour.blue() ) + log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") if result: await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) -- cgit v1.2.3 From d07b1af634787f53ee381d31a4c125498af52beb Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 15:55:56 +0200 Subject: Remove Filtering constants, use cache data. Instead of fetching the guild invite IDs from config-default.yml, we will now be using the AllowDenyList cache to check these. --- bot/cogs/filtering.py | 62 ++++++++++++++++--------------- bot/constants.py | 4 -- config-default.yml | 101 -------------------------------------------------- 3 files changed, 32 insertions(+), 135 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index bd665f424..9e35a83d1 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -22,6 +22,7 @@ from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) +# Regular expressions INVITE_RE = re.compile( r"(?:discord(?:[\.,]|dot)gg|" # Could be discord.gg/ r"discord(?:[\.,]|dot)com(?:\/|slash)invite|" # or discord.com/invite/ @@ -37,25 +38,8 @@ SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") -WORD_WATCHLIST_PATTERNS = [ - re.compile(fr'\b{expression}\b', flags=re.IGNORECASE) for expression in Filter.word_watchlist -] -TOKEN_WATCHLIST_PATTERNS = [ - re.compile(fr'{expression}', flags=re.IGNORECASE) for expression in Filter.token_watchlist -] -WATCHLIST_PATTERNS = WORD_WATCHLIST_PATTERNS + TOKEN_WATCHLIST_PATTERNS - +# Other constants. DAYS_BETWEEN_ALERTS = 3 - - -def expand_spoilers(text: str) -> str: - """Return a string containing all interpretations of a spoilered message.""" - split_text = SPOILER_RE.split(text) - return ''.join( - split_text[0::2] + split_text[1::2] + split_text - ) - - OFFENSIVE_MSG_DELETE_TIME = timedelta(days=Filter.offensive_msg_delete_days) @@ -125,6 +109,23 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) + def _get_allowlist_items(self, allow: bool, list_type: str, compiled: Optional[bool] = False) -> list: + """Fetch items from the allow_deny_list_cache.""" + items = self.bot.allow_deny_list_cache[f"{list_type}.{allow}"] + + if compiled: + return [re.compile(fr'{item.get("content")}', flags=re.IGNORECASE) for item in items] + else: + return [item.get("content") for item in items] + + @staticmethod + def _expand_spoilers(text: str) -> str: + """Return a string containing all interpretations of a spoilered message.""" + split_text = SPOILER_RE.split(text) + return ''.join( + split_text[0::2] + split_text[1::2] + split_text + ) + @property def mod_log(self) -> ModLog: """Get currently loaded ModLog cog instance.""" @@ -149,11 +150,11 @@ 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]: + def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - for pattern in WATCHLIST_PATTERNS: + watchlist_patterns = self._get_allowlist_items(False, 'word_watchlist', compiled=True) + for pattern in watchlist_patterns: if match := pattern.search(name): matches.append(match) return matches @@ -403,8 +404,7 @@ class Filtering(Cog): and not msg.author.bot # Author not a bot ) - @staticmethod - async def _has_watch_regex_match(text: str) -> Union[bool, re.Match]: + async def _has_watch_regex_match(self, text: str) -> Union[bool, re.Match]: """ Return True if `text` matches any regex from `word_watchlist` or `token_watchlist` configs. @@ -412,26 +412,27 @@ class Filtering(Cog): matched as-is. Spoilers are expanded, if any, and URLs are ignored. """ if SPOILER_RE.search(text): - text = expand_spoilers(text) + text = self._expand_spoilers(text) # Make sure it's not a URL if URL_RE.search(text): return False - for pattern in WATCHLIST_PATTERNS: + watchlist_patterns = self._get_allowlist_items(False, 'word_watchlist', compiled=True) + for pattern in watchlist_patterns: match = pattern.search(text) if match: return match - @staticmethod - async def _has_urls(text: str) -> bool: + async def _has_urls(self, text: str) -> bool: """Returns True if the text contains one of the blacklisted URLs from the config file.""" if not URL_RE.search(text): return False text = text.lower() + domain_blacklist = self._get_allowlist_items(False, "domain_name") - for url in Filter.domain_blacklist: + for url in domain_blacklist: if url.lower() in text: return True @@ -476,9 +477,10 @@ class Filtering(Cog): # between invalid and expired invites return True - guild_id = int(guild.get("id")) + guild_id = guild.get("id") + guild_invite_whitelist = self._get_allowlist_items(True, "guild_invite_id") - if guild_id not in Filter.guild_invite_whitelist: + if guild_id not in guild_invite_whitelist: guild_icon_hash = guild["icon"] guild_icon = ( "https://cdn.discordapp.com/icons/" diff --git a/bot/constants.py b/bot/constants.py index 778bc093c..f5245ca50 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -227,10 +227,6 @@ class Filter(metaclass=YAMLGetter): ping_everyone: bool offensive_msg_delete_days: int - guild_invite_whitelist: List[int] - domain_blacklist: List[str] - word_watchlist: List[str] - token_watchlist: List[str] channel_whitelist: List[int] role_whitelist: List[int] diff --git a/config-default.yml b/config-default.yml index f2eb17b89..81c8c40d5 100644 --- a/config-default.yml +++ b/config-default.yml @@ -272,107 +272,6 @@ filter: ping_everyone: true offensive_msg_delete_days: 7 # How many days before deleting an offensive message? - guild_invite_whitelist: - - 280033776820813825 # Functional Programming - - 267624335836053506 # Python Discord - - 440186186024222721 # Python Discord: Emojis 1 - - 578587418123304970 # Python Discord: Emojis 2 - - 273944235143593984 # STEM - - 348658686962696195 # RLBot - - 531221516914917387 # Pallets - - 249111029668249601 # Gentoo - - 327254708534116352 # Adafruit - - 544525886180032552 # kennethreitz.org - - 590806733924859943 # Discord Hack Week - - 423249981340778496 # Kivy - - 197038439483310086 # Discord Testers - - 286633898581164032 # Ren'Py - - 349505959032389632 # PyGame - - 438622377094414346 # Pyglet - - 524691714909274162 # Panda3D - - 336642139381301249 # discord.py - - 405403391410438165 # Sentdex - - 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 - - liveleak.com - - grabify.link - - bmwforum.co - - leancoding.co - - spottyfly.com - - stopify.co - - yoütu.be - - discörd.com - - minecräft.com - - freegiftcards.co - - disçordapp.com - - fortnight.space - - fortnitechat.site - - joinmy.site - - curiouscat.club - - catsnthings.fun - - yourtube.site - - youtubeshort.watch - - catsnthing.com - - youtubeshort.pro - - canadianlumberjacks.online - - poweredbydialup.club - - poweredbydialup.online - - poweredbysecurity.org - - poweredbysecurity.online - - ssteam.site - - steamwalletgift.com - - discord.gift - - lmgtfy.com - - word_watchlist: - - goo+ks* - - ky+s+ - - ki+ke+s* - - beaner+s? - - coo+ns* - - nig+lets* - - slant-eyes* - - towe?l-?head+s* - - chi*n+k+s* - - spick*s* - - kill* +(?:yo)?urself+ - - jew+s* - - suicide - - rape - - (re+)tar+(d+|t+)(ed)? - - ta+r+d+ - - cunts* - - trann*y - - shemale - - token_watchlist: - - fa+g+s* - - 卐 - - 卍 - - cuck(?!oo+) - - nigg+(?:e*r+|a+h*?|u+h+)s? - - fag+o+t+s* - # Censor doesn't apply to these channel_whitelist: - *ADMINS -- cgit v1.2.3 From 1c569f2f38fe18d6210deec001046cf9ee68ea53 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sat, 18 Jul 2020 16:54:01 +0200 Subject: Remove AntiMalWare constants, use cache data. Also updates the tests for this cog. --- bot/bot.py | 2 +- bot/cogs/antimalware.py | 24 ++++++++++++++---------- bot/constants.py | 6 ------ config-default.yml | 29 ----------------------------- tests/bot/cogs/test_antimalware.py | 24 +++++++++++++++--------- 5 files changed, 30 insertions(+), 55 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 6c02e72a7..962c8dd93 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -34,6 +34,7 @@ class Bot(commands.Bot): self.redis_ready = asyncio.Event() self.redis_closed = False self.api_client = api.APIClient(loop=self.loop) + self.allow_deny_list_cache = {} self._connector = None self._resolver = None @@ -52,7 +53,6 @@ class Bot(commands.Bot): async def _cache_allow_deny_list_data(self) -> None: """Cache all the data in the AllowDenyList on the site.""" full_cache = await self.api_client.get('bot/allow_deny_lists') - self.allow_deny_list_cache = {} for item in full_cache: type_ = item.get("type") diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index ea257442e..38ff1133d 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -6,7 +6,7 @@ from discord import Embed, Message, NotFound from discord.ext.commands import Cog from bot.bot import Bot -from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES, URLs +from bot.constants import Channels, STAFF_ROLES, URLs log = logging.getLogger(__name__) @@ -27,7 +27,7 @@ TXT_EMBED_DESCRIPTION = ( DISALLOWED_EMBED_DESCRIPTION = ( "It looks like you tried to attach file type(s) that we do not allow ({blocked_extensions_str}). " - f"We currently allow the following file types: **{', '.join(AntiMalwareConfig.whitelist)}**.\n\n" + "We currently allow the following file types: **{joined_whitelist}**.\n\n" "Feel free to ask in {meta_channel_mention} if you think this is a mistake." ) @@ -38,6 +38,16 @@ class AntiMalware(Cog): def __init__(self, bot: Bot): self.bot = bot + def _get_whitelisted_file_formats(self) -> list: + """Get the file formats currently on the whitelist.""" + return [item.get('content') for item in self.bot.allow_deny_list_cache['file_format.True']] + + def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: + """Get an iterable containing all the disallowed extensions of attachments.""" + file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} + extensions_blocked = file_extensions - set(self._get_whitelisted_file_formats()) + return extensions_blocked + @Cog.listener() async def on_message(self, message: Message) -> None: """Identify messages with prohibited attachments.""" @@ -51,7 +61,7 @@ class AntiMalware(Cog): return embed = Embed() - extensions_blocked = self.get_disallowed_extensions(message) + extensions_blocked = self._get_disallowed_extensions(message) blocked_extensions_str = ', '.join(extensions_blocked) if ".py" in extensions_blocked: # Short-circuit on *.py files to provide a pastebin link @@ -63,6 +73,7 @@ class AntiMalware(Cog): elif extensions_blocked: meta_channel = self.bot.get_channel(Channels.meta) embed.description = DISALLOWED_EMBED_DESCRIPTION.format( + joined_whitelist=', '.join(self._get_whitelisted_file_formats()), blocked_extensions_str=blocked_extensions_str, meta_channel_mention=meta_channel.mention, ) @@ -81,13 +92,6 @@ class AntiMalware(Cog): except NotFound: log.info(f"Tried to delete message `{message.id}`, but message could not be found.") - @classmethod - def get_disallowed_extensions(cls, message: Message) -> t.Iterable[str]: - """Get an iterable containing all the disallowed extensions of attachments.""" - file_extensions = {splitext(attachment.filename.lower())[1] for attachment in message.attachments} - extensions_blocked = file_extensions - set(AntiMalwareConfig.whitelist) - return extensions_blocked - def setup(bot: Bot) -> None: """Load the AntiMalware cog.""" diff --git a/bot/constants.py b/bot/constants.py index f5245ca50..857e6c4f0 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -527,12 +527,6 @@ class AntiSpam(metaclass=YAMLGetter): rules: Dict[str, Dict[str, int]] -class AntiMalware(metaclass=YAMLGetter): - section = "anti_malware" - - whitelist: list - - class BigBrother(metaclass=YAMLGetter): section = 'big_brother' diff --git a/config-default.yml b/config-default.yml index 81c8c40d5..503cc2b52 100644 --- a/config-default.yml +++ b/config-default.yml @@ -386,35 +386,6 @@ anti_spam: max: 3 -anti_malware: - whitelist: - - '.3gp' - - '.3g2' - - '.avi' - - '.bmp' - - '.gif' - - '.h264' - - '.jpg' - - '.jpeg' - - '.m4v' - - '.mkv' - - '.mov' - - '.mp4' - - '.mpeg' - - '.mpg' - - '.png' - - '.tiff' - - '.wmv' - - '.svg' - - '.psd' # Photoshop - - '.ai' # Illustrator - - '.aep' # After Effects - - '.xcf' # GIMP - - '.mp3' - - '.wav' - - '.ogg' - - reddit: subreddits: - 'r/Python' diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index f219fc1ba..1e010d2ce 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -1,28 +1,33 @@ import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock from discord import NotFound from bot.cogs import antimalware -from bot.constants import AntiMalware as AntiMalwareConfig, Channels, STAFF_ROLES +from bot.constants import Channels, STAFF_ROLES from tests.helpers import MockAttachment, MockBot, MockMessage, MockRole -MODULE = "bot.cogs.antimalware" - -@patch(f"{MODULE}.AntiMalwareConfig.whitelist", new=[".first", ".second", ".third"]) class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): """Test the AntiMalware cog.""" def setUp(self): """Sets up fresh objects for each test.""" self.bot = MockBot() + self.bot.allow_deny_list_cache = { + "file_format.True": [ + {"content": ".first"}, + {"content": ".second"}, + {"content": ".third"} + ] + } self.cog = antimalware.AntiMalware(self.bot) self.message = MockMessage() + self.whitelist = [".first", ".second", ".third"] async def test_message_with_allowed_attachment(self): """Messages with allowed extensions should not be deleted""" - attachment = MockAttachment(filename=f"python{AntiMalwareConfig.whitelist[0]}") + attachment = MockAttachment(filename="python.first") self.message.attachments = [attachment] await self.cog.on_message(self.message) @@ -93,7 +98,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.description, antimalware.TXT_EMBED_DESCRIPTION.format.return_value) antimalware.TXT_EMBED_DESCRIPTION.format.assert_called_with(cmd_channel_mention=cmd_channel.mention) - async def test_other_disallowed_extention_embed_description(self): + async def test_other_disallowed_extension_embed_description(self): """Test the description for a non .py/.txt disallowed extension.""" attachment = MockAttachment(filename="python.disallowed") self.message.attachments = [attachment] @@ -109,6 +114,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): self.assertEqual(embed.description, antimalware.DISALLOWED_EMBED_DESCRIPTION.format.return_value) antimalware.DISALLOWED_EMBED_DESCRIPTION.format.assert_called_with( + joined_whitelist=", ".join(self.whitelist), blocked_extensions_str=".disallowed", meta_channel_mention=meta_channel.mention ) @@ -135,7 +141,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): """The return value should include all non-whitelisted extensions.""" test_values = ( ([], []), - (AntiMalwareConfig.whitelist, []), + (self.whitelist, []), ([".first"], []), ([".first", ".disallowed"], [".disallowed"]), ([".disallowed"], [".disallowed"]), @@ -145,7 +151,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): for extensions, expected_disallowed_extensions in test_values: with self.subTest(extensions=extensions, expected_disallowed_extensions=expected_disallowed_extensions): self.message.attachments = [MockAttachment(filename=f"filename{extension}") for extension in extensions] - disallowed_extensions = self.cog.get_disallowed_extensions(self.message) + disallowed_extensions = self.cog._get_disallowed_extensions(self.message) self.assertCountEqual(disallowed_extensions, expected_disallowed_extensions) -- cgit v1.2.3 From 0d51d357a5a9f192c8ed71d40726838b7fb5136e Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 10:41:49 +0200 Subject: Fix an absolutely terrible comment. --- 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 9e35a83d1..d94c19471 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -456,7 +456,7 @@ class Filtering(Cog): Attempts to catch some of common ways to try to cheat the system. """ - # Remove backslashes to prevent escape character around fuckery like + # Remove backslashes to prevent escape character aroundfuckery like # discord\.gg/gdudes-pony-farm text = text.replace("\\", "") -- cgit v1.2.3 From da260365d3a6d9b92a630b16e32397b52d64e6c3 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 12:19:34 +0200 Subject: Include the guild ID in mod-log embed. This gives easier access to the Guild ID in the place where you're most likely to want to use the whitelist command. --- bot/cogs/filtering.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index d94c19471..4d51bba2e 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -111,7 +111,7 @@ class Filtering(Cog): def _get_allowlist_items(self, allow: bool, list_type: str, compiled: Optional[bool] = False) -> list: """Fetch items from the allow_deny_list_cache.""" - items = self.bot.allow_deny_list_cache[f"{list_type}.{allow}"] + items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allow}", []) if compiled: return [re.compile(fr'{item.get("content")}', flags=re.IGNORECASE) for item in items] @@ -371,14 +371,14 @@ class Filtering(Cog): # 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(): + for _, 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}") + embed.set_footer(text=f"Guild ID: {data['id']}") additional_embeds.append(embed) additional_embeds_msg = "For the following guild(s):" @@ -489,6 +489,7 @@ class Filtering(Cog): invite_data[invite] = { "name": guild["name"], + "id": guild['id'], "icon": guild_icon, "members": response["approximate_member_count"], "active": response["approximate_presence_count"] -- cgit v1.2.3 From 064130f7838647ab7bb63824446d93ba50833126 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 12:31:04 +0200 Subject: Support the new AllowDenyList field, 'comment'. --- bot/bot.py | 1 + bot/cogs/allow_deny_lists.py | 55 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 46 insertions(+), 10 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 962c8dd93..d834c151b 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -59,6 +59,7 @@ class Bot(commands.Bot): allowed = item.get("allowed") metadata = { "content": item.get("content"), + "comment": item.get("comment"), "id": item.get("id"), "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index 6558990a7..8b3c892f5 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -1,4 +1,5 @@ import logging +from typing import Optional from discord import Colour, Embed from discord.ext.commands import BadArgument, Cog, Context, group @@ -19,17 +20,26 @@ class AllowDenyLists(Cog): def __init__(self, bot: Bot) -> None: self.bot = bot - async def _add_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: + async def _add_data( + self, + ctx: Context, + allowed: bool, + list_type: ValidAllowDenyListType, + content: str, + comment: Optional[str] = None, + ) -> None: """Add an item to an allow or denylist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { 'allowed': allowed, 'type': list_type, 'content': content, + 'comment': comment, } - allow_type = "whitelist" if allowed else "blacklist" - # Try to add the item to the database - log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") try: item = await self.bot.api_client.post( "bot/allow_deny_lists", @@ -55,6 +65,7 @@ class AllowDenyLists(Cog): allowed = item.get("allowed") metadata = { "content": item.get("content"), + "comment": item.get("comment"), "id": item.get("id"), "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), @@ -83,9 +94,21 @@ class AllowDenyLists(Cog): async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType) -> None: """Paginate and display all items in an allow or denylist.""" - result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) - lines = sorted(f"• {item.get('content')}" for item in result) allow_type = "whitelist" if allowed else "blacklist" + result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) + + # Build a list of lines we want to show in the paginator + lines = [] + for item in result: + line = f"• {item.get('content')}" + + if item.get("comment"): + line += f" ({item.get('comment')})" + + lines.append(line) + lines = sorted(lines) + + # Build the embed embed = Embed( title=f"{allow_type.title()}ed {list_type.lower()} items ({len(result)} total)", colour=Colour.blue() @@ -111,14 +134,26 @@ class AllowDenyLists(Cog): await ctx.send_help(ctx.command) @whitelist.command(name="add", aliases=("a", "set")) - async def allow_add(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + async def allow_add( + self, + ctx: Context, + list_type: ValidAllowDenyListType, + content: str, + comment: Optional[str] = None, + ) -> None: """Add an item to the specified allowlist.""" - await self._add_data(ctx, True, list_type, content) + await self._add_data(ctx, True, list_type, content, comment) @blacklist.command(name="add", aliases=("a", "set")) - async def deny_add(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: + async def deny_add( + self, + ctx: Context, + list_type: ValidAllowDenyListType, + content: str, + comment: Optional[str] = None, + ) -> None: """Add an item to the specified denylist.""" - await self._add_data(ctx, False, list_type, content) + await self._add_data(ctx, False, list_type, content, comment) @whitelist.command(name="remove", aliases=("delete", "rm",)) async def allow_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: -- cgit v1.2.3 From f99d27074e8088f7ea8abe4957321490875aa249 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 19:39:20 +0200 Subject: Validation of guild invites. We will now validate and convert any standard discord server invite to a guild ID, and automatically add the name of the server as a comment. This will ensure that the list of whitelisted guild IDs will be readable and nice. This also makes minor changes to list output aesthetics. --- bot/cogs/allow_deny_lists.py | 29 +++++++++++++++++++++++++---- bot/cogs/filtering.py | 14 ++------------ bot/converters.py | 40 +++++++++++++++++++++++++++++++++++++++- bot/utils/regex.py | 12 ++++++++++++ 4 files changed, 78 insertions(+), 17 deletions(-) create mode 100644 bot/utils/regex.py diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index 8b3c892f5..d82d175cf 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -7,7 +7,7 @@ from discord.ext.commands import BadArgument, Cog, Context, group from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.converters import ValidAllowDenyListType +from bot.converters import ValidAllowDenyListType, ValidDiscordServerInvite from bot.pagination import LinePaginator from bot.utils.checks import with_role_check @@ -31,6 +31,24 @@ class AllowDenyLists(Cog): """Add an item to an allow or denylist.""" allow_type = "whitelist" if allowed else "blacklist" + # If this is a server invite, we gotta validate it. + if list_type == "GUILD_INVITE": + log.trace(f"{content} is a guild invite, attempting to validate.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, content) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's convert the content to an ID. + log.trace(f"{content} validated as server invite. Converting to ID.") + content = guild_data.get("id") + + # Unless the user has specified another comment, let's + # use the server name as the comment so that the list + # of guild IDs will be more easily readable when we + # display it. + if not comment: + comment = guild_data.get("name") + # Try to add the item to the database log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { @@ -100,17 +118,18 @@ class AllowDenyLists(Cog): # Build a list of lines we want to show in the paginator lines = [] for item in result: - line = f"• {item.get('content')}" + line = f"• `{item.get('content')}`" if item.get("comment"): - line += f" ({item.get('comment')})" + line += f" - {item.get('comment')}" lines.append(line) lines = sorted(lines) # Build the embed + list_type_plural = list_type.lower().replace("_", " ").title() + "s" embed = Embed( - title=f"{allow_type.title()}ed {list_type.lower()} items ({len(result)} total)", + title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", colour=Colour.blue() ) log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") @@ -139,6 +158,7 @@ class AllowDenyLists(Cog): ctx: Context, list_type: ValidAllowDenyListType, content: str, + *, comment: Optional[str] = None, ) -> None: """Add an item to the specified allowlist.""" @@ -150,6 +170,7 @@ class AllowDenyLists(Cog): ctx: Context, list_type: ValidAllowDenyListType, content: str, + *, comment: Optional[str] = None, ) -> None: """Add an item to the specified denylist.""" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 4d51bba2e..3ebb47a0f 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -18,22 +18,12 @@ from bot.constants import ( Filter, Icons, URLs ) from bot.utils.redis_cache import RedisCache +from bot.utils.regex import INVITE_RE from bot.utils.scheduling import Scheduler log = logging.getLogger(__name__) # Regular expressions -INVITE_RE = re.compile( - 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 - flags=re.IGNORECASE -) - SPOILER_RE = re.compile(r"(\|\|.+?\|\|)", re.DOTALL) URL_RE = re.compile(r"(https?://[^\s]+)", flags=re.IGNORECASE) ZALGO_RE = re.compile(r"[\u0300-\u036F\u0489]") @@ -478,7 +468,7 @@ class Filtering(Cog): return True guild_id = guild.get("id") - guild_invite_whitelist = self._get_allowlist_items(True, "guild_invite_id") + guild_invite_whitelist = self._get_allowlist_items(True, "guild_invite") if guild_id not in guild_invite_whitelist: guild_icon_hash = guild["icon"] diff --git a/bot/converters.py b/bot/converters.py index edac67be2..7e21c1542 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -9,8 +9,10 @@ import dateutil.tz import discord from aiohttp import ClientConnectorError, ContentTypeError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Context, Converter, UserConverter +from discord.ext.commands import BadArgument, Context, Converter, IDConverter, UserConverter +from bot.constants import URLs +from bot.utils.regex import INVITE_RE log = logging.getLogger(__name__) @@ -34,6 +36,42 @@ def allowed_strings(*values, preserve_case: bool = False) -> t.Callable[[str], s return converter +class ValidDiscordServerInvite(Converter): + """ + A converter that validates whether a given string is a valid Discord server invite. + + Raises 'BadArgument' if: + - The string is not a valid Discord server invite. + - The string is valid, but is an invite for a group DM. + - The string is valid, but is expired. + + Returns a (partial) guild object if: + - The string is a valid vanity + - The string is a full invite URI + - The string contains the invite code (the stuff after discord.gg/) + + See the Discord API docs for documentation on the guild object: + https://discord.com/developers/docs/resources/guild#guild-object + """ + + async def convert(self, ctx: Context, server_invite: str) -> dict: + """Check whether the string is a valid Discord server invite.""" + invite_code = INVITE_RE.match(server_invite) + if invite_code: + response = await ctx.bot.http_session.get( + f"{URLs.discord_invite_api}/{invite_code[1]}" + ) + if response.status != 404: + invite_data = await response.json() + return invite_data.get("guild") + + id_converter = IDConverter() + if id_converter._get_id_match(server_invite): + raise BadArgument("Guild IDs are not supported, only invites.") + + raise BadArgument("This does not appear to be a valid Discord server invite.") + + class ValidAllowDenyListType(Converter): """ A converter that checks whether the given string is a valid AllowDenyList type. diff --git a/bot/utils/regex.py b/bot/utils/regex.py new file mode 100644 index 000000000..d194f93cb --- /dev/null +++ b/bot/utils/regex.py @@ -0,0 +1,12 @@ +import re + +INVITE_RE = re.compile( + 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 + flags=re.IGNORECASE +) -- cgit v1.2.3 From f771222df165d90aff0f2d9d44bd9ba86b265574 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 19:57:40 +0200 Subject: Validation of guild invites for delete. We want to support deletion of both IDs and guild invites, so we need a bit of special handling for that. --- bot/cogs/allow_deny_lists.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index d82d175cf..71a032ea5 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -2,7 +2,7 @@ import logging from typing import Optional from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, group +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group from bot import constants from bot.api import ResponseCodeError @@ -95,9 +95,21 @@ class AllowDenyLists(Cog): """Remove an item from an allow or denylist.""" item = None allow_type = "whitelist" if allowed else "blacklist" + id_converter = IDConverter() - log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") + # If this is a server invite, we need to convert it. + if list_type == "GUILD_INVITE" and not id_converter._get_id_match(content): + log.trace(f"{content} is a guild invite, attempting to validate.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, content) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's convert the content to an ID. + log.trace(f"{content} validated as server invite. Converting to ID.") + content = guild_data.get("id") + # Find the content and delete it. + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") for allow_list in self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []): if content == allow_list.get("content"): item = allow_list -- cgit v1.2.3 From 73b12fa63877a26bfe324e968f00337969f1f6cf Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 19 Jul 2020 20:44:51 +0200 Subject: Implement new guild invite filtering logic. We now filter guild invites the following way: - Whitelisted invites are always permitted. - Blacklisted invites are never permitted. - If the invite is not blacklisted, it is permitted only if it is a Verified or a Partnered server, otherwise not. This strategy was decided on during the June 7th staff meeting, see https://github.com/python-discord/organisation/issues/261 --- bot/cogs/filtering.py | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 3ebb47a0f..b5b1c823a 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -99,9 +99,9 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - def _get_allowlist_items(self, allow: bool, list_type: str, compiled: Optional[bool] = False) -> list: + def _get_allowlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: """Fetch items from the allow_deny_list_cache.""" - items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allow}", []) + items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allowed}", []) if compiled: return [re.compile(fr'{item.get("content")}', flags=re.IGNORECASE) for item in items] @@ -143,7 +143,7 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - watchlist_patterns = self._get_allowlist_items(False, 'word_watchlist', compiled=True) + watchlist_patterns = self._get_allowlist_items('word_watchlist', allowed=False, compiled=True) for pattern in watchlist_patterns: if match := pattern.search(name): matches.append(match) @@ -408,7 +408,7 @@ class Filtering(Cog): if URL_RE.search(text): return False - watchlist_patterns = self._get_allowlist_items(False, 'word_watchlist', compiled=True) + watchlist_patterns = self._get_allowlist_items('word_watchlist', allowed=False, compiled=True) for pattern in watchlist_patterns: match = pattern.search(text) if match: @@ -420,7 +420,7 @@ class Filtering(Cog): return False text = text.lower() - domain_blacklist = self._get_allowlist_items(False, "domain_name") + domain_blacklist = self._get_allowlist_items("domain_name", allowed=False) for url in domain_blacklist: if url.lower() in text: @@ -468,9 +468,21 @@ class Filtering(Cog): return True guild_id = guild.get("id") - guild_invite_whitelist = self._get_allowlist_items(True, "guild_invite") + guild_invite_whitelist = self._get_allowlist_items("guild_invite", allowed=True) + guild_invite_blacklist = self._get_allowlist_items("guild_invite", allowed=False) - if guild_id not in guild_invite_whitelist: + # Is this invite allowed? + guild_partnered_or_verified = ( + 'PARTNERED' in guild.get("features") + or 'VERIFIED' in guild.get("features") + ) + invite_not_allowed = ( + guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. + or guild_id not in guild_invite_whitelist # Whitelisted guilds are always permitted. + and not guild_partnered_or_verified # Otherwise guilds have to be Verified or Partnered. + ) + + if invite_not_allowed: guild_icon_hash = guild["icon"] guild_icon = ( "https://cdn.discordapp.com/icons/" -- cgit v1.2.3 From 2723949b2fbc065bf870e7fee1275782071a180b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 09:47:49 +0200 Subject: Catch ResponseCodeError in the ValidAllowDenyListType converter. --- bot/converters.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 7e21c1542..55cc630f7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,16 +1,16 @@ +import dateutil.parser +import dateutil.tz +import discord import logging import re import typing as t +from aiohttp import ClientConnectorError from datetime import datetime -from ssl import CertificateError - -import dateutil.parser -import dateutil.tz -import discord -from aiohttp import ClientConnectorError, ContentTypeError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Context, Converter, IDConverter, UserConverter +from ssl import CertificateError +from bot.api import ResponseCodeError from bot.constants import URLs from bot.utils.regex import INVITE_RE @@ -84,7 +84,7 @@ class ValidAllowDenyListType(Converter): """Checks whether the given string is a valid AllowDenyList type.""" try: valid_types = await ctx.bot.api_client.get('bot/allow_deny_lists/get_types') - except ContentTypeError: + except ResponseCodeError: raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") valid_types = [enum for enum, classname in valid_types] -- cgit v1.2.3 From 6f066fe2e18495425f6fbe90518d25b00b4276b4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 10:07:20 +0200 Subject: Put valid_types_list inside the conditional. --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 55cc630f7..41cd3f3e5 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -88,10 +88,10 @@ class ValidAllowDenyListType(Converter): raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") valid_types = [enum for enum, classname in valid_types] - valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) list_type = list_type.upper() if list_type not in valid_types: + valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) raise BadArgument( f"You have provided an invalid list type!\n\n" f"Please provide one of the following: \n{valid_types_list}" -- cgit v1.2.3 From be52d33e5466f83fbf86d0bec3553f788bc08c27 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 10:26:41 +0200 Subject: No need for all() in cog_check for AllowDenyLists. --- bot/cogs/allow_deny_lists.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py index 71a032ea5..e28e32bd6 100644 --- a/bot/cogs/allow_deny_lists.py +++ b/bot/cogs/allow_deny_lists.py @@ -210,10 +210,7 @@ class AllowDenyLists(Cog): def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" - checks = [ - with_role_check(ctx, *constants.MODERATION_ROLES), - ] - return all(checks) + return with_role_check(ctx, *constants.MODERATION_ROLES) def setup(bot: Bot) -> None: -- cgit v1.2.3 From 3aa33f3ec84a104ac13c0c60c21f4f149da5af78 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 10:39:21 +0200 Subject: Add sanity to partner and verification check in filtering.py. --- 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 b5b1c823a..98a60f489 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -473,8 +473,8 @@ class Filtering(Cog): # Is this invite allowed? guild_partnered_or_verified = ( - 'PARTNERED' in guild.get("features") - or 'VERIFIED' in guild.get("features") + 'PARTNERED' in guild.get("features", []) + or 'VERIFIED' in guild.get("features", []) ) invite_not_allowed = ( guild_id in guild_invite_blacklist # Blacklisted guilds are never permitted. -- cgit v1.2.3 From 02e7672623dd1aea11a715e5187eaef7f8633d17 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Fri, 24 Jul 2020 10:42:09 +0200 Subject: More explicit dict indexing Addresses reviews from MarkKoz Co-authored-by: Mark --- bot/cogs/antimalware.py | 2 +- bot/cogs/filtering.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 38ff1133d..5b56f937f 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -40,7 +40,7 @@ class AntiMalware(Cog): def _get_whitelisted_file_formats(self) -> list: """Get the file formats currently on the whitelist.""" - return [item.get('content') for item in self.bot.allow_deny_list_cache['file_format.True']] + return [item['content'] for item in self.bot.allow_deny_list_cache['file_format.True']] def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: """Get an iterable containing all the disallowed extensions of attachments.""" diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 98a60f489..8897cbaf9 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -104,9 +104,9 @@ class Filtering(Cog): items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allowed}", []) if compiled: - return [re.compile(fr'{item.get("content")}', flags=re.IGNORECASE) for item in items] + return [re.compile(fr'{item["content"]}', flags=re.IGNORECASE) for item in items] else: - return [item.get("content") for item in items] + return [item["content"] for item in items] @staticmethod def _expand_spoilers(text: str) -> str: -- cgit v1.2.3 From 3d5faa421756fadb42590db92e8fee64578390d4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 27 Jul 2020 10:26:10 +0200 Subject: Rename AllowDenyList to FilterLists --- bot/__main__.py | 2 +- bot/bot.py | 14 +-- bot/cogs/allow_deny_lists.py | 218 ------------------------------------- bot/cogs/antimalware.py | 2 +- bot/cogs/filter_lists.py | 218 +++++++++++++++++++++++++++++++++++++ bot/cogs/filtering.py | 16 +-- bot/converters.py | 10 +- tests/bot/cogs/test_antimalware.py | 2 +- 8 files changed, 241 insertions(+), 241 deletions(-) delete mode 100644 bot/cogs/allow_deny_lists.py create mode 100644 bot/cogs/filter_lists.py diff --git a/bot/__main__.py b/bot/__main__.py index 932aa705c..c2271cd16 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -53,7 +53,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") -bot.load_extension("bot.cogs.allow_deny_lists") +bot.load_extension("bot.cogs.filter_lists") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.dm_relay") bot.load_extension("bot.cogs.duck_pond") diff --git a/bot/bot.py b/bot/bot.py index d834c151b..3dfb4e948 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -34,7 +34,7 @@ class Bot(commands.Bot): self.redis_ready = asyncio.Event() self.redis_closed = False self.api_client = api.APIClient(loop=self.loop) - self.allow_deny_list_cache = {} + self.filter_list_cache = {} self._connector = None self._resolver = None @@ -50,9 +50,9 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") - async def _cache_allow_deny_list_data(self) -> None: - """Cache all the data in the AllowDenyList on the site.""" - full_cache = await self.api_client.get('bot/allow_deny_lists') + async def _cache_filter_list_data(self) -> None: + """Cache all the data in the FilterList on the site.""" + full_cache = await self.api_client.get('bot/filter-lists') for item in full_cache: type_ = item.get("type") @@ -64,7 +64,7 @@ class Bot(commands.Bot): "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), } - self.allow_deny_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + self.filter_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) async def _create_redis_session(self) -> None: """ @@ -176,8 +176,8 @@ class Bot(commands.Bot): self.http_session = aiohttp.ClientSession(connector=self._connector) self.api_client.recreate(force=True, connector=self._connector) - # Build the AllowDenyList cache - self.loop.create_task(self._cache_allow_deny_list_data()) + # Build the FilterList cache + self.loop.create_task(self._cache_filter_list_data()) async def on_guild_available(self, guild: discord.Guild) -> None: """ diff --git a/bot/cogs/allow_deny_lists.py b/bot/cogs/allow_deny_lists.py deleted file mode 100644 index e28e32bd6..000000000 --- a/bot/cogs/allow_deny_lists.py +++ /dev/null @@ -1,218 +0,0 @@ -import logging -from typing import Optional - -from discord import Colour, Embed -from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group - -from bot import constants -from bot.api import ResponseCodeError -from bot.bot import Bot -from bot.converters import ValidAllowDenyListType, ValidDiscordServerInvite -from bot.pagination import LinePaginator -from bot.utils.checks import with_role_check - -log = logging.getLogger(__name__) - - -class AllowDenyLists(Cog): - """Commands for blacklisting and whitelisting things.""" - - def __init__(self, bot: Bot) -> None: - self.bot = bot - - async def _add_data( - self, - ctx: Context, - allowed: bool, - list_type: ValidAllowDenyListType, - content: str, - comment: Optional[str] = None, - ) -> None: - """Add an item to an allow or denylist.""" - allow_type = "whitelist" if allowed else "blacklist" - - # If this is a server invite, we gotta validate it. - if list_type == "GUILD_INVITE": - log.trace(f"{content} is a guild invite, attempting to validate.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, content) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's convert the content to an ID. - log.trace(f"{content} validated as server invite. Converting to ID.") - content = guild_data.get("id") - - # Unless the user has specified another comment, let's - # use the server name as the comment so that the list - # of guild IDs will be more easily readable when we - # display it. - if not comment: - comment = guild_data.get("name") - - # Try to add the item to the database - log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") - payload = { - 'allowed': allowed, - 'type': list_type, - 'content': content, - 'comment': comment, - } - - try: - item = await self.bot.api_client.post( - "bot/allow_deny_lists", - json=payload - ) - except ResponseCodeError as e: - if e.status == 500: - await ctx.message.add_reaction("❌") - log.debug( - f"{ctx.author} tried to add data to a {allow_type}, but the API returned 500, " - "probably because the request violated the UniqueConstraint." - ) - raise BadArgument( - f"Unable to add the item to the {allow_type}. " - "The item probably already exists. Keep in mind that a " - "blacklist and a whitelist for the same item cannot co-exist, " - "and we do not permit any duplicates." - ) - raise - - # Insert the item into the cache - type_ = item.get("type") - allowed = item.get("allowed") - metadata = { - "content": item.get("content"), - "comment": item.get("comment"), - "id": item.get("id"), - "created_at": item.get("created_at"), - "updated_at": item.get("updated_at"), - } - self.bot.allow_deny_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) - await ctx.message.add_reaction("✅") - - async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType, content: str) -> None: - """Remove an item from an allow or denylist.""" - item = None - allow_type = "whitelist" if allowed else "blacklist" - id_converter = IDConverter() - - # If this is a server invite, we need to convert it. - if list_type == "GUILD_INVITE" and not id_converter._get_id_match(content): - log.trace(f"{content} is a guild invite, attempting to validate.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, content) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's convert the content to an ID. - log.trace(f"{content} validated as server invite. Converting to ID.") - content = guild_data.get("id") - - # Find the content and delete it. - log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - for allow_list in self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []): - if content == allow_list.get("content"): - item = allow_list - break - - if item is not None: - await self.bot.api_client.delete( - f"bot/allow_deny_lists/{item.get('id')}" - ) - self.bot.allow_deny_list_cache[f"{list_type}.{allowed}"].remove(item) - await ctx.message.add_reaction("✅") - - async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidAllowDenyListType) -> None: - """Paginate and display all items in an allow or denylist.""" - allow_type = "whitelist" if allowed else "blacklist" - result = self.bot.allow_deny_list_cache.get(f"{list_type}.{allowed}", []) - - # Build a list of lines we want to show in the paginator - lines = [] - for item in result: - line = f"• `{item.get('content')}`" - - if item.get("comment"): - line += f" - {item.get('comment')}" - - lines.append(line) - lines = sorted(lines) - - # Build the embed - list_type_plural = list_type.lower().replace("_", " ").title() + "s" - embed = Embed( - title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", - colour=Colour.blue() - ) - log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") - - if result: - await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) - else: - embed.description = "Hmmm, seems like there's nothing here yet." - await ctx.send(embed=embed) - - @group(aliases=("allowlist", "allow", "al", "wl")) - async def whitelist(self, ctx: Context) -> None: - """Group for whitelisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @group(aliases=("denylist", "deny", "bl", "dl")) - async def blacklist(self, ctx: Context) -> None: - """Group for blacklisting commands.""" - if not ctx.invoked_subcommand: - await ctx.send_help(ctx.command) - - @whitelist.command(name="add", aliases=("a", "set")) - async def allow_add( - self, - ctx: Context, - list_type: ValidAllowDenyListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified allowlist.""" - await self._add_data(ctx, True, list_type, content, comment) - - @blacklist.command(name="add", aliases=("a", "set")) - async def deny_add( - self, - ctx: Context, - list_type: ValidAllowDenyListType, - content: str, - *, - comment: Optional[str] = None, - ) -> None: - """Add an item to the specified denylist.""" - await self._add_data(ctx, False, list_type, content, comment) - - @whitelist.command(name="remove", aliases=("delete", "rm",)) - async def allow_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: - """Remove an item from the specified allowlist.""" - await self._delete_data(ctx, True, list_type, content) - - @blacklist.command(name="remove", aliases=("delete", "rm",)) - async def deny_delete(self, ctx: Context, list_type: ValidAllowDenyListType, content: str) -> None: - """Remove an item from the specified denylist.""" - await self._delete_data(ctx, False, list_type, content) - - @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def allow_get(self, ctx: Context, list_type: ValidAllowDenyListType) -> None: - """Get the contents of a specified allowlist.""" - await self._list_all_data(ctx, True, list_type) - - @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) - async def deny_get(self, ctx: Context, list_type: ValidAllowDenyListType) -> None: - """Get the contents of a specified denylist.""" - await self._list_all_data(ctx, False, list_type) - - def cog_check(self, ctx: Context) -> bool: - """Only allow moderators to invoke the commands in this cog.""" - return with_role_check(ctx, *constants.MODERATION_ROLES) - - -def setup(bot: Bot) -> None: - """Load the AllowDenyLists cog.""" - bot.add_cog(AllowDenyLists(bot)) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 5b56f937f..9a100b3fc 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -40,7 +40,7 @@ class AntiMalware(Cog): def _get_whitelisted_file_formats(self) -> list: """Get the file formats currently on the whitelist.""" - return [item['content'] for item in self.bot.allow_deny_list_cache['file_format.True']] + return [item['content'] for item in self.bot.filter_list_cache['file_format.True']] def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: """Get an iterable containing all the disallowed extensions of attachments.""" diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py new file mode 100644 index 000000000..d1db9830e --- /dev/null +++ b/bot/cogs/filter_lists.py @@ -0,0 +1,218 @@ +import logging +from typing import Optional + +from discord import Colour, Embed +from discord.ext.commands import BadArgument, Cog, Context, IDConverter, group + +from bot import constants +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.converters import ValidDiscordServerInvite, ValidFilterListType +from bot.pagination import LinePaginator +from bot.utils.checks import with_role_check + +log = logging.getLogger(__name__) + + +class FilterLists(Cog): + """Commands for blacklisting and whitelisting things.""" + + def __init__(self, bot: Bot) -> None: + self.bot = bot + + async def _add_data( + self, + ctx: Context, + allowed: bool, + list_type: ValidFilterListType, + content: str, + comment: Optional[str] = None, + ) -> None: + """Add an item to a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + + # If this is a server invite, we gotta validate it. + if list_type == "GUILD_INVITE": + log.trace(f"{content} is a guild invite, attempting to validate.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, content) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's convert the content to an ID. + log.trace(f"{content} validated as server invite. Converting to ID.") + content = guild_data.get("id") + + # Unless the user has specified another comment, let's + # use the server name as the comment so that the list + # of guild IDs will be more easily readable when we + # display it. + if not comment: + comment = guild_data.get("name") + + # Try to add the item to the database + log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") + payload = { + 'allowed': allowed, + 'type': list_type, + 'content': content, + 'comment': comment, + } + + try: + item = await self.bot.api_client.post( + "bot/filter-lists", + json=payload + ) + except ResponseCodeError as e: + if e.status == 500: + await ctx.message.add_reaction("❌") + log.debug( + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 500, " + "probably because the request violated the UniqueConstraint." + ) + raise BadArgument( + f"Unable to add the item to the {allow_type}. " + "The item probably already exists. Keep in mind that a " + "blacklist and a whitelist for the same item cannot co-exist, " + "and we do not permit any duplicates." + ) + raise + + # Insert the item into the cache + type_ = item.get("type") + allowed = item.get("allowed") + metadata = { + "content": item.get("content"), + "comment": item.get("comment"), + "id": item.get("id"), + "created_at": item.get("created_at"), + "updated_at": item.get("updated_at"), + } + self.bot.filter_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + await ctx.message.add_reaction("✅") + + async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from a filterlist.""" + item = None + allow_type = "whitelist" if allowed else "blacklist" + id_converter = IDConverter() + + # If this is a server invite, we need to convert it. + if list_type == "GUILD_INVITE" and not id_converter._get_id_match(content): + log.trace(f"{content} is a guild invite, attempting to validate.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, content) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's convert the content to an ID. + log.trace(f"{content} validated as server invite. Converting to ID.") + content = guild_data.get("id") + + # Find the content and delete it. + log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") + for allow_list in self.bot.filter_list_cache.get(f"{list_type}.{allowed}", []): + if content == allow_list.get("content"): + item = allow_list + break + + if item is not None: + await self.bot.api_client.delete( + f"bot/filter-lists/{item.get('id')}" + ) + self.bot.filter_list_cache[f"{list_type}.{allowed}"].remove(item) + await ctx.message.add_reaction("✅") + + async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: + """Paginate and display all items in a filterlist.""" + allow_type = "whitelist" if allowed else "blacklist" + result = self.bot.filter_list_cache.get(f"{list_type}.{allowed}", []) + + # Build a list of lines we want to show in the paginator + lines = [] + for item in result: + line = f"• `{item.get('content')}`" + + if item.get("comment"): + line += f" - {item.get('comment')}" + + lines.append(line) + lines = sorted(lines) + + # Build the embed + list_type_plural = list_type.lower().replace("_", " ").title() + "s" + embed = Embed( + title=f"{allow_type.title()}ed {list_type_plural} ({len(result)} total)", + colour=Colour.blue() + ) + log.trace(f"Trying to list {len(result)} items from the {list_type.lower()} {allow_type}") + + if result: + await LinePaginator.paginate(lines, ctx, embed, max_lines=15, empty=False) + else: + embed.description = "Hmmm, seems like there's nothing here yet." + await ctx.send(embed=embed) + + @group(aliases=("allowlist", "allow", "al", "wl")) + async def whitelist(self, ctx: Context) -> None: + """Group for whitelisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @group(aliases=("denylist", "deny", "bl", "dl")) + async def blacklist(self, ctx: Context) -> None: + """Group for blacklisting commands.""" + if not ctx.invoked_subcommand: + await ctx.send_help(ctx.command) + + @whitelist.command(name="add", aliases=("a", "set")) + async def allow_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified allowlist.""" + await self._add_data(ctx, True, list_type, content, comment) + + @blacklist.command(name="add", aliases=("a", "set")) + async def deny_add( + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, + ) -> None: + """Add an item to the specified denylist.""" + await self._add_data(ctx, False, list_type, content, comment) + + @whitelist.command(name="remove", aliases=("delete", "rm",)) + async def allow_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified allowlist.""" + await self._delete_data(ctx, True, list_type, content) + + @blacklist.command(name="remove", aliases=("delete", "rm",)) + async def deny_delete(self, ctx: Context, list_type: ValidFilterListType, content: str) -> None: + """Remove an item from the specified denylist.""" + await self._delete_data(ctx, False, list_type, content) + + @whitelist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def allow_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified allowlist.""" + await self._list_all_data(ctx, True, list_type) + + @blacklist.command(name="get", aliases=("list", "ls", "fetch", "show")) + async def deny_get(self, ctx: Context, list_type: ValidFilterListType) -> None: + """Get the contents of a specified denylist.""" + await self._list_all_data(ctx, False, list_type) + + def cog_check(self, ctx: Context) -> bool: + """Only allow moderators to invoke the commands in this cog.""" + return with_role_check(ctx, *constants.MODERATION_ROLES) + + +def setup(bot: Bot) -> None: + """Load the FilterLists cog.""" + bot.add_cog(FilterLists(bot)) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 8897cbaf9..652af5ff5 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -99,9 +99,9 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - def _get_allowlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: - """Fetch items from the allow_deny_list_cache.""" - items = self.bot.allow_deny_list_cache.get(f"{list_type.upper()}.{allowed}", []) + def _get_filterlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: + """Fetch items from the filter_list_cache.""" + items = self.bot.filter_list_cache.get(f"{list_type.upper()}.{allowed}", []) if compiled: return [re.compile(fr'{item["content"]}', flags=re.IGNORECASE) for item in items] @@ -143,7 +143,7 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - watchlist_patterns = self._get_allowlist_items('word_watchlist', allowed=False, compiled=True) + watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False, compiled=True) for pattern in watchlist_patterns: if match := pattern.search(name): matches.append(match) @@ -408,7 +408,7 @@ class Filtering(Cog): if URL_RE.search(text): return False - watchlist_patterns = self._get_allowlist_items('word_watchlist', allowed=False, compiled=True) + watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False, compiled=True) for pattern in watchlist_patterns: match = pattern.search(text) if match: @@ -420,7 +420,7 @@ class Filtering(Cog): return False text = text.lower() - domain_blacklist = self._get_allowlist_items("domain_name", allowed=False) + domain_blacklist = self._get_filterlist_items("domain_name", allowed=False) for url in domain_blacklist: if url.lower() in text: @@ -468,8 +468,8 @@ class Filtering(Cog): return True guild_id = guild.get("id") - guild_invite_whitelist = self._get_allowlist_items("guild_invite", allowed=True) - guild_invite_blacklist = self._get_allowlist_items("guild_invite", allowed=False) + guild_invite_whitelist = self._get_filterlist_items("guild_invite", allowed=True) + guild_invite_blacklist = self._get_filterlist_items("guild_invite", allowed=False) # Is this invite allowed? guild_partnered_or_verified = ( diff --git a/bot/converters.py b/bot/converters.py index 41cd3f3e5..158bf1a16 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -72,18 +72,18 @@ class ValidDiscordServerInvite(Converter): raise BadArgument("This does not appear to be a valid Discord server invite.") -class ValidAllowDenyListType(Converter): +class ValidFilterListType(Converter): """ - A converter that checks whether the given string is a valid AllowDenyList type. + A converter that checks whether the given string is a valid FilterList type. - Raises `BadArgument` if the argument is not a valid AllowDenyList type, and simply + Raises `BadArgument` if the argument is not a valid FilterList type, and simply passes through the given argument otherwise. """ async def convert(self, ctx: Context, list_type: str) -> str: - """Checks whether the given string is a valid AllowDenyList type.""" + """Checks whether the given string is a valid FilterList type.""" try: - valid_types = await ctx.bot.api_client.get('bot/allow_deny_lists/get_types') + valid_types = await ctx.bot.api_client.get('bot/filter-lists/get-types') except ResponseCodeError: raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index 1e010d2ce..664fa8f19 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -14,7 +14,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): def setUp(self): """Sets up fresh objects for each test.""" self.bot = MockBot() - self.bot.allow_deny_list_cache = { + self.bot.filter_list_cache = { "file_format.True": [ {"content": ".first"}, {"content": ".second"}, -- cgit v1.2.3 From ba00d4f1525340141b0f6c85fbfb32793f5bdfdd Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 27 Jul 2020 10:26:49 +0200 Subject: Bump flake8 version to 3.8 This is necessary to support walrus operators. --- Pipfile | 2 +- Pipfile.lock | 362 +++++++++++++++++++++++++++++++---------------------------- 2 files changed, 191 insertions(+), 173 deletions(-) diff --git a/Pipfile b/Pipfile index 2d6b45aa9..4db8a238b 100644 --- a/Pipfile +++ b/Pipfile @@ -28,7 +28,7 @@ statsd = "~=3.3" [dev-packages] coverage = "~=5.0" -flake8 = "~=3.7" +flake8 = "~=3.8" flake8-annotations = "~=2.0" flake8-bugbear = "~=20.1" flake8-docstrings = "~=1.4" diff --git a/Pipfile.lock b/Pipfile.lock index 4b9d092d4..c8cd96d3d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "8a53baefbbd2a0f3fbaf831f028b23d257a5e28b5efa1260661d74604f4113b8" + "sha256": "eab4852974d26bd2c10362540c3e01d34af62446cb4e1915ec9a0bf2bddf4d94" }, "pipfile-spec": 6, "requires": { @@ -115,36 +115,36 @@ }, "cffi": { "hashes": [ - "sha256:001bf3242a1bb04d985d63e138230802c6c8d4db3668fb545fb5005ddf5bb5ff", - "sha256:00789914be39dffba161cfc5be31b55775de5ba2235fe49aa28c148236c4e06b", - "sha256:028a579fc9aed3af38f4892bdcc7390508adabc30c6af4a6e4f611b0c680e6ac", - "sha256:14491a910663bf9f13ddf2bc8f60562d6bc5315c1f09c704937ef17293fb85b0", - "sha256:1cae98a7054b5c9391eb3249b86e0e99ab1e02bb0cc0575da191aedadbdf4384", - "sha256:2089ed025da3919d2e75a4d963d008330c96751127dd6f73c8dc0c65041b4c26", - "sha256:2d384f4a127a15ba701207f7639d94106693b6cd64173d6c8988e2c25f3ac2b6", - "sha256:337d448e5a725bba2d8293c48d9353fc68d0e9e4088d62a9571def317797522b", - "sha256:399aed636c7d3749bbed55bc907c3288cb43c65c4389964ad5ff849b6370603e", - "sha256:3b911c2dbd4f423b4c4fcca138cadde747abdb20d196c4a48708b8a2d32b16dd", - "sha256:3d311bcc4a41408cf5854f06ef2c5cab88f9fded37a3b95936c9879c1640d4c2", - "sha256:62ae9af2d069ea2698bf536dcfe1e4eed9090211dbaafeeedf5cb6c41b352f66", - "sha256:66e41db66b47d0d8672d8ed2708ba91b2f2524ece3dee48b5dfb36be8c2f21dc", - "sha256:675686925a9fb403edba0114db74e741d8181683dcf216be697d208857e04ca8", - "sha256:7e63cbcf2429a8dbfe48dcc2322d5f2220b77b2e17b7ba023d6166d84655da55", - "sha256:8a6c688fefb4e1cd56feb6c511984a6c4f7ec7d2a1ff31a10254f3c817054ae4", - "sha256:8c0ffc886aea5df6a1762d0019e9cb05f825d0eec1f520c51be9d198701daee5", - "sha256:95cd16d3dee553f882540c1ffe331d085c9e629499ceadfbda4d4fde635f4b7d", - "sha256:99f748a7e71ff382613b4e1acc0ac83bf7ad167fb3802e35e90d9763daba4d78", - "sha256:b8c78301cefcf5fd914aad35d3c04c2b21ce8629b5e4f4e45ae6812e461910fa", - "sha256:c420917b188a5582a56d8b93bdd8e0f6eca08c84ff623a4c16e809152cd35793", - "sha256:c43866529f2f06fe0edc6246eb4faa34f03fe88b64a0a9a942561c8e22f4b71f", - "sha256:cab50b8c2250b46fe738c77dbd25ce017d5e6fb35d3407606e7a4180656a5a6a", - "sha256:cef128cb4d5e0b3493f058f10ce32365972c554572ff821e175dbc6f8ff6924f", - "sha256:cf16e3cf6c0a5fdd9bc10c21687e19d29ad1fe863372b5543deaec1039581a30", - "sha256:e56c744aa6ff427a607763346e4170629caf7e48ead6921745986db3692f987f", - "sha256:e577934fc5f8779c554639376beeaa5657d54349096ef24abe8c74c5d9c117c3", - "sha256:f2b0fa0c01d8a0c7483afd9f31d7ecf2d71760ca24499c8697aeb5ca37dc090c" - ], - "version": "==1.14.0" + "sha256:267adcf6e68d77ba154334a3e4fc921b8e63cbb38ca00d33d40655d4228502bc", + "sha256:26f33e8f6a70c255767e3c3f957ccafc7f1f706b966e110b855bfe944511f1f9", + "sha256:3cd2c044517f38d1b577f05927fb9729d3396f1d44d0c659a445599e79519792", + "sha256:4a03416915b82b81af5502459a8a9dd62a3c299b295dcdf470877cb948d655f2", + "sha256:4ce1e995aeecf7cc32380bc11598bfdfa017d592259d5da00fc7ded11e61d022", + "sha256:4f53e4128c81ca3212ff4cf097c797ab44646a40b42ec02a891155cd7a2ba4d8", + "sha256:4fa72a52a906425416f41738728268072d5acfd48cbe7796af07a923236bcf96", + "sha256:66dd45eb9530e3dde8f7c009f84568bc7cac489b93d04ac86e3111fb46e470c2", + "sha256:6923d077d9ae9e8bacbdb1c07ae78405a9306c8fd1af13bfa06ca891095eb995", + "sha256:833401b15de1bb92791d7b6fb353d4af60dc688eaa521bd97203dcd2d124a7c1", + "sha256:8416ed88ddc057bab0526d4e4e9f3660f614ac2394b5e019a628cdfff3733849", + "sha256:892daa86384994fdf4856cb43c93f40cbe80f7f95bb5da94971b39c7f54b3a9c", + "sha256:98be759efdb5e5fa161e46d404f4e0ce388e72fbf7d9baf010aff16689e22abe", + "sha256:a6d28e7f14ecf3b2ad67c4f106841218c8ab12a0683b1528534a6c87d2307af3", + "sha256:b1d6ebc891607e71fd9da71688fcf332a6630b7f5b7f5549e6e631821c0e5d90", + "sha256:b2a2b0d276a136146e012154baefaea2758ef1f56ae9f4e01c612b0831e0bd2f", + "sha256:b87dfa9f10a470eee7f24234a37d1d5f51e5f5fa9eeffda7c282e2b8f5162eb1", + "sha256:bac0d6f7728a9cc3c1e06d4fcbac12aaa70e9379b3025b27ec1226f0e2d404cf", + "sha256:c991112622baee0ae4d55c008380c32ecfd0ad417bcd0417ba432e6ba7328caa", + "sha256:cda422d54ee7905bfc53ee6915ab68fe7b230cacf581110df4272ee10462aadc", + "sha256:d3148b6ba3923c5850ea197a91a42683f946dba7e8eb82dfa211ab7e708de939", + "sha256:d6033b4ffa34ef70f0b8086fd4c3df4bf801fee485a8a7d4519399818351aa8e", + "sha256:ddff0b2bd7edcc8c82d1adde6dbbf5e60d57ce985402541cd2985c27f7bec2a0", + "sha256:e23cb7f1d8e0f93addf0cae3c5b6f00324cccb4a7949ee558d7b6ca973ab8ae9", + "sha256:effd2ba52cee4ceff1a77f20d2a9f9bf8d50353c854a282b8760ac15b9833168", + "sha256:f90c2267101010de42f7273c94a1f026e56cbc043f9330acd8a80e64300aba33", + "sha256:f960375e9823ae6a07072ff7f8a85954e5a6434f97869f50d0e41649a1c8144f", + "sha256:fcf32bf76dc25e30ed793145a57426064520890d7c02866eb93d3e4abe516948" + ], + "version": "==1.14.1" }, "chardet": { "hashes": [ @@ -216,49 +216,55 @@ }, "hiredis": { "hashes": [ - "sha256:01b577f84c20ecc9c07fc4c184231b08e3c3942de096fa99978e053de231c423", - "sha256:01ff0900134166961c9e339df77c33b72f7edc5cb41739f0babcd9faa345926e", - "sha256:03ed34a13316d0c34213c4fd46e0fa3a5299073f4d4f08e93fed8c2108b399b3", - "sha256:040436e91df5143aff9e0debb49530d0b17a6bd52200ce568621c31ef581b10d", - "sha256:091eb38fbf968d1c5b703e412bbbd25f43a7967d8400842cee33a5a07b33c27b", - "sha256:102f9b9dc6ed57feb3a7c9bdf7e71cb7c278fe8df1edfcfe896bc3e0c2be9447", - "sha256:2b4b392c7e3082860c8371fab3ae762139090f9115819e12d9f56060f9ede05d", - "sha256:2c9cc0b986397b833073f466e6b9e9c70d1d4dc2c2c1b3e9cae3a23102ff296c", - "sha256:2fa65a9df683bca72073cd77709ddeb289ea2b114d3775d225fbbcc5faf808c5", - "sha256:38437a681f17c975fd22349e72c29bc643f8e7eb2d6dc5df419eac59afa4d7ce", - "sha256:3b3428fa3cf1ee178807b52c9bee8950ab94cd4eaa9bfae8c1bbae3c49501d34", - "sha256:3dd8c2fae7f5494978facb0e93297dd627b1a3f536f3b070cf0a7d9157a07dcb", - "sha256:4414a96c212e732723b5c3d7c04d386ebbb2ec359e1de646322cbc3f875cbd0d", - "sha256:48c627581ad4ef60adbac980981407939acf13a0e18f093502c7b542223c4f19", - "sha256:4a60e71625a2d78d8ab84dfb2fa2cfd9458c964b6e6c04fea76d9ade153fb371", - "sha256:585ace09f434e43d8a8dbeb366865b1a044d7c06319b3c7372a0a00e63b860f4", - "sha256:74b364b3f06c9cf0a53f7df611045bc9437ed972a283fa1f0b12537236d23ddc", - "sha256:75c65c3850e89e9daa68d1b9bedd5806f177d60aa5a7b0953b4829481cfc1f72", - "sha256:7f052de8bf744730a9120dbdc67bfeb7605a01f69fb8e7ba5c475af33c24e145", - "sha256:8113a7d5e87ecf57cd4ae263cc9e429adb9a3e59f5a7768da5d3312a8d0a051a", - "sha256:84857ce239eb8ed191ac78e77ff65d52902f00f30f4ee83bf80eb71da73b70e6", - "sha256:8644a48ddc4a40b3e3a6b9443f396c2ee353afb2d45656c4fc68d04a82e8e3f7", - "sha256:936aa565e673536e8a211e43ec43197406f24cd1f290138bd143765079c8ba00", - "sha256:9afeb88c67bbc663b9f27385c496da056d06ad87f55df6e393e1516cfecb0461", - "sha256:9d62cc7880110e4f83b0a51d218f465d3095e2751fbddd34e553dbd106a929ff", - "sha256:a1fadd062fc8d647ff39220c57ea2b48c99bb73f18223828ec97f88fc27e7898", - "sha256:a7754a783b1e5d6f627c19d099b178059c62f782ab62b4d8ba165b9fbc2ee34c", - "sha256:aa59dd63bb3f736de4fc2d080114429d5d369dfb3265f771778e8349d67a97a4", - "sha256:ae2ee0992f8de249715435942137843a93db204dd7db1e7cc9bdc5a8436443e8", - "sha256:b36842d7cf32929d568f37ec5b3173b72b2ec6572dec4d6be6ce774762215aee", - "sha256:bcbf9379c553b5facc6c04c1e5569b44b38ff16bcbf354676287698d61ee0c92", - "sha256:cbccbda6f1c62ab460449d9c85fdf24d0d32a6bf45176581151e53cc26a5d910", - "sha256:d0caf98dfb8af395d6732bd16561c0a2458851bea522e39f12f04802dbf6f502", - "sha256:d6456afeddba036def1a36d8a2758eca53202308d83db20ab5d0b66590919627", - "sha256:dbaef9a21a4f10bc281684ee4124f169e62bb533c2a92b55f8c06f64f9af7b8f", - "sha256:dce84916c09aaece006272b37234ae84a8ed13abb3a4d341a23933b8701abfb5", - "sha256:eb8c9c8b9869539d58d60ff4a28373a22514d40495911451343971cb4835b7a9", - "sha256:efc98b14ee3a8595e40b1425e8d42f5fd26f11a7b215a81ef9259068931754f4", - "sha256:fa2dc05b87d97acc1c6ae63f3e0f39eae5246565232484b08db6bf2dc1580678", - "sha256:fe7d6ce9f6a5fbe24f09d95ea93e9c7271abc4e1565da511e1449b107b4d7848" + "sha256:06a039208f83744a702279b894c8cf24c14fd63c59cd917dcde168b79eef0680", + "sha256:0a909bf501459062aa1552be1461456518f367379fdc9fdb1f2ca5e4a1fdd7c0", + "sha256:18402d9e54fb278cb9a8c638df6f1550aca36a009d47ecf5aa263a38600f35b0", + "sha256:1e4cbbc3858ec7e680006e5ca590d89a5e083235988f26a004acf7244389ac01", + "sha256:23344e3c2177baf6975fbfa361ed92eb7d36d08f454636e5054b3faa7c2aff8a", + "sha256:289b31885b4996ce04cadfd5fc03d034dce8e2a8234479f7c9e23b9e245db06b", + "sha256:2c1c570ae7bf1bab304f29427e2475fe1856814312c4a1cf1cd0ee133f07a3c6", + "sha256:2c227c0ed371771ffda256034427320870e8ea2e4fd0c0a618c766e7c49aad73", + "sha256:3bb9b63d319402cead8bbd9dd55dca3b667d2997e9a0d8a1f9b6cc274db4baee", + "sha256:3ef2183de67b59930d2db8b8e8d4d58e00a50fcc5e92f4f678f6eed7a1c72d55", + "sha256:43b8ed3dbfd9171e44c554cb4acf4ee4505caa84c5e341858b50ea27dd2b6e12", + "sha256:47bcf3c5e6c1e87ceb86cdda2ee983fa0fe56a999e6185099b3c93a223f2fa9b", + "sha256:5263db1e2e1e8ae30500cdd75a979ff99dcc184201e6b4b820d0de74834d2323", + "sha256:5b1451727f02e7acbdf6aae4e06d75f66ee82966ff9114550381c3271a90f56c", + "sha256:6996883a8a6ff9117cbb3d6f5b0dcbbae6fb9e31e1a3e4e2f95e0214d9a1c655", + "sha256:6c96f64a54f030366657a54bb90b3093afc9c16c8e0dfa29fc0d6dbe169103a5", + "sha256:7332d5c3e35154cd234fd79573736ddcf7a0ade7a986db35b6196b9171493e75", + "sha256:7885b6f32c4a898e825bb7f56f36a02781ac4a951c63e4169f0afcf9c8c30dfb", + "sha256:7b0f63f10a166583ab744a58baad04e0f52cfea1ac27bfa1b0c21a48d1003c23", + "sha256:819f95d4eba3f9e484dd115ab7ab72845cf766b84286a00d4ecf76d33f1edca1", + "sha256:8968eeaa4d37a38f8ca1f9dbe53526b69628edc9c42229a5b2f56d98bb828c1f", + "sha256:89ebf69cb19a33d625db72d2ac589d26e936b8f7628531269accf4a3196e7872", + "sha256:8daecd778c1da45b8bd54fd41ffcd471a86beed3d8e57a43acf7a8d63bba4058", + "sha256:955ba8ea73cf3ed8bd2f963b4cb9f8f0dcb27becd2f4b3dd536fd24c45533454", + "sha256:964f18a59f5a64c0170f684c417f4fe3e695a536612e13074c4dd5d1c6d7c882", + "sha256:969843fbdfbf56cdb71da6f0bdf50f9985b8b8aeb630102945306cf10a9c6af2", + "sha256:996021ef33e0f50b97ff2d6b5f422a0fe5577de21a8873b58a779a5ddd1c3132", + "sha256:9e9c9078a7ce07e6fce366bd818be89365a35d2e4b163268f0ca9ba7e13bb2f6", + "sha256:a04901757cb0fb0f5602ac11dda48f5510f94372144d06c2563ba56c480b467c", + "sha256:a7bf1492429f18d205f3a818da3ff1f242f60aa59006e53dee00b4ef592a3363", + "sha256:aa0af2deb166a5e26e0d554b824605e660039b161e37ed4f01b8d04beec184f3", + "sha256:abfb15a6a7822f0fae681785cb38860e7a2cb1616a708d53df557b3d76c5bfd4", + "sha256:b253fe4df2afea4dfa6b1fa8c5fef212aff8bcaaeb4207e81eed05cb5e4a7919", + "sha256:b27f082f47d23cffc4cf1388b84fdc45c4ef6015f906cd7e0d988d9e35d36349", + "sha256:b33aea449e7f46738811fbc6f0b3177c6777a572207412bbbf6f525ffed001ae", + "sha256:b44f9421c4505c548435244d74037618f452844c5d3c67719d8a55e2613549da", + "sha256:bcc371151d1512201d0214c36c0c150b1dc64f19c2b1a8c9cb1d7c7c15ebd93f", + "sha256:c2851deeabd96d3f6283e9c6b26e0bfed4de2dc6fb15edf913e78b79fc5909ed", + "sha256:cdfd501c7ac5b198c15df800a3a34c38345f5182e5f80770caf362bccca65628", + "sha256:d2c0caffa47606d6d7c8af94ba42547bd2a441f06c74fd90a1ffe328524a6c64", + "sha256:dcb2db95e629962db5a355047fb8aefb012df6c8ae608930d391619dbd96fd86", + "sha256:e0eeb9c112fec2031927a1745788a181d0eecbacbed941fc5c4f7bc3f7b273bf", + "sha256:e154891263306200260d7f3051982774d7b9ef35af3509d5adbbe539afd2610c", + "sha256:e2e023a42dcbab8ed31f97c2bcdb980b7fbe0ada34037d87ba9d799664b58ded", + "sha256:e64be68255234bb489a574c4f2f8df7029c98c81ec4d160d6cd836e7f0679390", + "sha256:e82d6b930e02e80e5109b678c663a9ed210680ded81c1abaf54635d88d1da298" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.0.1" + "version": "==1.1.0" }, "humanfriendly": { "hashes": [ @@ -294,36 +300,40 @@ }, "lxml": { "hashes": [ - "sha256:06748c7192eab0f48e3d35a7adae609a329c6257495d5e53878003660dc0fec6", - "sha256:0790ddca3f825dd914978c94c2545dbea5f56f008b050e835403714babe62a5f", - "sha256:1aa7a6197c1cdd65d974f3e4953764eee3d9c7b67e3966616b41fab7f8f516b7", - "sha256:22c6d34fdb0e65d5f782a4d1a1edb52e0a8365858dafb1c08cb1d16546cf0786", - "sha256:2754d4406438c83144f9ffd3628bbe2dcc6d62b20dbc5c1ec4bc4385e5d44b42", - "sha256:27ee0faf8077c7c1a589573b1450743011117f1aa1a91d5ae776bbc5ca6070f2", - "sha256:2b02c106709466a93ed424454ce4c970791c486d5fcdf52b0d822a7e29789626", - "sha256:2d1ddce96cf15f1254a68dba6935e6e0f1fe39247de631c115e84dd404a6f031", - "sha256:4f282737d187ae723b2633856085c31ae5d4d432968b7f3f478a48a54835f5c4", - "sha256:51bb4edeb36d24ec97eb3e6a6007be128b720114f9a875d6b370317d62ac80b9", - "sha256:7eee37c1b9815e6505847aa5e68f192e8a1b730c5c7ead39ff317fde9ce29448", - "sha256:7fd88cb91a470b383aafad554c3fe1ccf6dfb2456ff0e84b95335d582a799804", - "sha256:9144ce36ca0824b29ebc2e02ca186e54040ebb224292072250467190fb613b96", - "sha256:925baf6ff1ef2c45169f548cc85204433e061360bfa7d01e1be7ae38bef73194", - "sha256:a636346c6c0e1092ffc202d97ec1843a75937d8c98aaf6771348ad6422e44bb0", - "sha256:a87dbee7ad9dce3aaefada2081843caf08a44a8f52e03e0a4cc5819f8398f2f4", - "sha256:a9e3b8011388e7e373565daa5e92f6c9cb844790dc18e43073212bb3e76f7007", - "sha256:afb53edf1046599991fb4a7d03e601ab5f5422a5435c47ee6ba91ec3b61416a6", - "sha256:b26719890c79a1dae7d53acac5f089d66fd8cc68a81f4e4bd355e45470dc25e1", - "sha256:b7462cdab6fffcda853338e1741ce99706cdf880d921b5a769202ea7b94e8528", - "sha256:b77975465234ff49fdad871c08aa747aae06f5e5be62866595057c43f8d2f62c", - "sha256:c47a8a5d00060122ca5908909478abce7bbf62d812e3fc35c6c802df8fb01fe7", - "sha256:c79e5debbe092e3c93ca4aee44c9a7631bdd407b2871cb541b979fd350bbbc29", - "sha256:d8d40e0121ca1606aa9e78c28a3a7d88a05c06b3ca61630242cded87d8ce55fa", - "sha256:ee2be8b8f72a2772e72ab926a3bccebf47bb727bda41ae070dc91d1fb759b726", - "sha256:f95d28193c3863132b1f55c1056036bf580b5a488d908f7d22a04ace8935a3a9", - "sha256:fadd2a63a2bfd7fb604508e553d1cf68eca250b2fbdbd81213b5f6f2fbf23529" - ], - "index": "pypi", - "version": "==4.5.1" + "sha256:05a444b207901a68a6526948c7cc8f9fe6d6f24c70781488e32fd74ff5996e3f", + "sha256:08fc93257dcfe9542c0a6883a25ba4971d78297f63d7a5a26ffa34861ca78730", + "sha256:107781b213cf7201ec3806555657ccda67b1fccc4261fb889ef7fc56976db81f", + "sha256:121b665b04083a1e85ff1f5243d4a93aa1aaba281bc12ea334d5a187278ceaf1", + "sha256:1fa21263c3aba2b76fd7c45713d4428dbcc7644d73dcf0650e9d344e433741b3", + "sha256:2b30aa2bcff8e958cd85d907d5109820b01ac511eae5b460803430a7404e34d7", + "sha256:4b4a111bcf4b9c948e020fd207f915c24a6de3f1adc7682a2d92660eb4e84f1a", + "sha256:5591c4164755778e29e69b86e425880f852464a21c7bb53c7ea453bbe2633bbe", + "sha256:59daa84aef650b11bccd18f99f64bfe44b9f14a08a28259959d33676554065a1", + "sha256:5a9c8d11aa2c8f8b6043d845927a51eb9102eb558e3f936df494e96393f5fd3e", + "sha256:5dd20538a60c4cc9a077d3b715bb42307239fcd25ef1ca7286775f95e9e9a46d", + "sha256:74f48ec98430e06c1fa8949b49ebdd8d27ceb9df8d3d1c92e1fdc2773f003f20", + "sha256:786aad2aa20de3dbff21aab86b2fb6a7be68064cbbc0219bde414d3a30aa47ae", + "sha256:7ad7906e098ccd30d8f7068030a0b16668ab8aa5cda6fcd5146d8d20cbaa71b5", + "sha256:80a38b188d20c0524fe8959c8ce770a8fdf0e617c6912d23fc97c68301bb9aba", + "sha256:8f0ec6b9b3832e0bd1d57af41f9238ea7709bbd7271f639024f2fc9d3bb01293", + "sha256:92282c83547a9add85ad658143c76a64a8d339028926d7dc1998ca029c88ea6a", + "sha256:94150231f1e90c9595ccc80d7d2006c61f90a5995db82bccbca7944fd457f0f6", + "sha256:9dc9006dcc47e00a8a6a029eb035c8f696ad38e40a27d073a003d7d1443f5d88", + "sha256:a76979f728dd845655026ab991df25d26379a1a8fc1e9e68e25c7eda43004bed", + "sha256:aa8eba3db3d8761db161003e2d0586608092e217151d7458206e243be5a43843", + "sha256:bea760a63ce9bba566c23f726d72b3c0250e2fa2569909e2d83cda1534c79443", + "sha256:c3f511a3c58676147c277eff0224c061dd5a6a8e1373572ac817ac6324f1b1e0", + "sha256:c9d317efde4bafbc1561509bfa8a23c5cab66c44d49ab5b63ff690f5159b2304", + "sha256:cc411ad324a4486b142c41d9b2b6a722c534096963688d879ea6fa8a35028258", + "sha256:cdc13a1682b2a6241080745b1953719e7fe0850b40a5c71ca574f090a1391df6", + "sha256:cfd7c5dd3c35c19cec59c63df9571c67c6d6e5c92e0fe63517920e97f61106d1", + "sha256:e1cacf4796b20865789083252186ce9dc6cc59eca0c2e79cca332bdff24ac481", + "sha256:e70d4e467e243455492f5de463b72151cc400710ac03a0678206a5f27e79ddef", + "sha256:ecc930ae559ea8a43377e8b60ca6f8d61ac532fc57efb915d899de4a67928efd", + "sha256:f161af26f596131b63b236372e4ce40f3167c1b5b5d459b29d2514bd8c9dc9ee" + ], + "index": "pypi", + "version": "==4.5.2" }, "markdownify": { "hashes": [ @@ -532,11 +542,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:da06bc3641e81ec2c942f87a0676cd9180044fa3d1697524a0005345997542e2", - "sha256:e80d61af85d99a1222c1a3e2a24023618374cd50a99673aa7fa3cf920e7d813b" + "sha256:2de15b13836fa3522815a933bd9c887c77f4868071043349f94f1b896c1bcfb8", + "sha256:38bb09d0277117f76507c8728d9a5156f09a47ac5175bb8072513859d19a593b" ], "index": "pypi", - "version": "==0.16.0" + "version": "==0.16.2" }, "six": { "hashes": [ @@ -632,13 +642,21 @@ "index": "pypi", "version": "==3.3.0" }, + "typing-extensions": { + "hashes": [ + "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", + "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", + "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" + ], + "version": "==3.7.4.2" + }, "urllib3": { "hashes": [ - "sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527", - "sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115" + "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", + "sha256:e7983572181f5e1522d9c98453462384ee92a0be7fac5f1413a1e35c56cc0461" ], "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" + "version": "==1.25.10" }, "websockets": { "hashes": [ @@ -670,26 +688,26 @@ }, "yarl": { "hashes": [ - "sha256:0c2ab325d33f1b824734b3ef51d4d54a54e0e7a23d13b86974507602334c2cce", - "sha256:0ca2f395591bbd85ddd50a82eb1fde9c1066fafe888c5c7cc1d810cf03fd3cc6", - "sha256:2098a4b4b9d75ee352807a95cdf5f10180db903bc5b7270715c6bbe2551f64ce", - "sha256:25e66e5e2007c7a39541ca13b559cd8ebc2ad8fe00ea94a2aad28a9b1e44e5ae", - "sha256:26d7c90cb04dee1665282a5d1a998defc1a9e012fdca0f33396f81508f49696d", - "sha256:308b98b0c8cd1dfef1a0311dc5e38ae8f9b58349226aa0533f15a16717ad702f", - "sha256:3ce3d4f7c6b69c4e4f0704b32eca8123b9c58ae91af740481aa57d7857b5e41b", - "sha256:58cd9c469eced558cd81aa3f484b2924e8897049e06889e8ff2510435b7ef74b", - "sha256:5b10eb0e7f044cf0b035112446b26a3a2946bca9d7d7edb5e54a2ad2f6652abb", - "sha256:6faa19d3824c21bcbfdfce5171e193c8b4ddafdf0ac3f129ccf0cdfcb083e462", - "sha256:944494be42fa630134bf907714d40207e646fd5a94423c90d5b514f7b0713fea", - "sha256:a161de7e50224e8e3de6e184707476b5a989037dcb24292b391a3d66ff158e70", - "sha256:a4844ebb2be14768f7994f2017f70aca39d658a96c786211be5ddbe1c68794c1", - "sha256:c2b509ac3d4b988ae8769901c66345425e361d518aecbe4acbfc2567e416626a", - "sha256:c9959d49a77b0e07559e579f38b2f3711c2b8716b8410b320bf9713013215a1b", - "sha256:d8cdee92bc930d8b09d8bd2043cedd544d9c8bd7436a77678dd602467a993080", - "sha256:e15199cdb423316e15f108f51249e44eb156ae5dba232cb73be555324a1d49c2" + "sha256:1707230e1ea48ea06a3e20acb4ce05a38d2465bd9566c21f48f6212a88e47536", + "sha256:1f269e8e6676193a94635399a77c9059e1826fb6265c9204c9e5a8ccd36006e1", + "sha256:2657716c1fc998f5f2675c0ee6ce91282e0da0ea9e4a94b584bb1917e11c1559", + "sha256:431faa6858f0ea323714d8b7b4a7da1db2eeb9403607f0eaa3800ab2c5a4b627", + "sha256:5bbcb195da7de57f4508b7508c33f7593e9516e27732d08b9aad8586c7b8c384", + "sha256:5c82f5b1499342339f22c83b97dbe2b8a09e47163fab86cd934a8dd46620e0fb", + "sha256:5d410f69b4f92c5e1e2a8ffb73337cd8a274388c6975091735795588a538e605", + "sha256:66b4f345e9573e004b1af184bc00431145cf5e089a4dcc1351505c1f5750192c", + "sha256:875b2a741ce0208f3b818008a859ab5d0f461e98a32bbdc6af82231a9e761c55", + "sha256:9a3266b047d15e78bba38c8455bf68b391c040231ca5965ef867f7cbbc60bde5", + "sha256:9a592c4aa642249e9bdaf76897d90feeb08118626b363a6be8788a9b300274b5", + "sha256:a1772068401d425e803999dada29a6babf041786e08be5e79ef63c9ecc4c9575", + "sha256:b065a5c3e050395ae563019253cc6c769a50fd82d7fa92d07476273521d56b7c", + "sha256:b325fefd574ebef50e391a1072d1712a60348ca29c183e1d546c9d87fec2cd32", + "sha256:cf5eb664910d759bbae0b76d060d6e21f8af5098242d66c448bbebaf2a7bfa70", + "sha256:f058b6541477022c7b54db37229f87dacf3b565de4f901ff5a0a78556a174fea", + "sha256:f5cfed0766837303f688196aa7002730d62c5cc802d98c6395ea1feb87252727" ], "markers": "python_version >= '3.5'", - "version": "==1.4.2" + "version": "==1.5.0" } }, "develop": { @@ -718,43 +736,43 @@ }, "coverage": { "hashes": [ - "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" + "sha256:098a703d913be6fbd146a8c50cc76513d726b022d170e5e98dc56d958fd592fb", + "sha256:16042dc7f8e632e0dcd5206a5095ebd18cb1d005f4c89694f7f8aafd96dd43a3", + "sha256:1adb6be0dcef0cf9434619d3b892772fdb48e793300f9d762e480e043bd8e716", + "sha256:27ca5a2bc04d68f0776f2cdcb8bbd508bbe430a7bf9c02315cd05fb1d86d0034", + "sha256:28f42dc5172ebdc32622a2c3f7ead1b836cdbf253569ae5673f499e35db0bac3", + "sha256:2fcc8b58953d74d199a1a4d633df8146f0ac36c4e720b4a1997e9b6327af43a8", + "sha256:304fbe451698373dc6653772c72c5d5e883a4aadaf20343592a7abb2e643dae0", + "sha256:30bc103587e0d3df9e52cd9da1dd915265a22fad0b72afe54daf840c984b564f", + "sha256:40f70f81be4d34f8d491e55936904db5c527b0711b2a46513641a5729783c2e4", + "sha256:4186fc95c9febeab5681bc3248553d5ec8c2999b8424d4fc3a39c9cba5796962", + "sha256:46794c815e56f1431c66d81943fa90721bb858375fb36e5903697d5eef88627d", + "sha256:4869ab1c1ed33953bb2433ce7b894a28d724b7aa76c19b11e2878034a4e4680b", + "sha256:4f6428b55d2916a69f8d6453e48a505c07b2245653b0aa9f0dee38785939f5e4", + "sha256:52f185ffd3291196dc1aae506b42e178a592b0b60a8610b108e6ad892cfc1bb3", + "sha256:538f2fd5eb64366f37c97fdb3077d665fa946d2b6d95447622292f38407f9258", + "sha256:64c4f340338c68c463f1b56e3f2f0423f7b17ba6c3febae80b81f0e093077f59", + "sha256:675192fca634f0df69af3493a48224f211f8db4e84452b08d5fcebb9167adb01", + "sha256:700997b77cfab016533b3e7dbc03b71d33ee4df1d79f2463a318ca0263fc29dd", + "sha256:8505e614c983834239f865da2dd336dcf9d72776b951d5dfa5ac36b987726e1b", + "sha256:962c44070c281d86398aeb8f64e1bf37816a4dfc6f4c0f114756b14fc575621d", + "sha256:9e536783a5acee79a9b308be97d3952b662748c4037b6a24cbb339dc7ed8eb89", + "sha256:9ea749fd447ce7fb1ac71f7616371f04054d969d412d37611716721931e36efd", + "sha256:a34cb28e0747ea15e82d13e14de606747e9e484fb28d63c999483f5d5188e89b", + "sha256:a3ee9c793ffefe2944d3a2bd928a0e436cd0ac2d9e3723152d6fd5398838ce7d", + "sha256:aab75d99f3f2874733946a7648ce87a50019eb90baef931698f96b76b6769a46", + "sha256:b1ed2bdb27b4c9fc87058a1cb751c4df8752002143ed393899edb82b131e0546", + "sha256:b360d8fd88d2bad01cb953d81fd2edd4be539df7bfec41e8753fe9f4456a5082", + "sha256:b8f58c7db64d8f27078cbf2a4391af6aa4e4767cc08b37555c4ae064b8558d9b", + "sha256:c1bbb628ed5192124889b51204de27c575b3ffc05a5a91307e7640eff1d48da4", + "sha256:c2ff24df02a125b7b346c4c9078c8936da06964cc2d276292c357d64378158f8", + "sha256:c890728a93fffd0407d7d37c1e6083ff3f9f211c83b4316fae3778417eab9811", + "sha256:c96472b8ca5dc135fb0aa62f79b033f02aa434fb03a8b190600a5ae4102df1fd", + "sha256:ce7866f29d3025b5b34c2e944e66ebef0d92e4a4f2463f7266daa03a1332a651", + "sha256:e26c993bd4b220429d4ec8c1468eca445a4064a61c74ca08da7429af9bc53bb0" + ], + "index": "pypi", + "version": "==5.2.1" }, "distlib": { "hashes": [ @@ -780,11 +798,11 @@ }, "flake8-annotations": { "hashes": [ - "sha256:babc81a17a5f1a63464195917e20d3e8663fb712b3633d4522dbfc407cff31b3", - "sha256:fcd833b415726a7a374922c95a5c47a7a4d8ea71cb4a586369c665e7476146e1" + "sha256:7816a5d8f65ffdf37b8e21e5b17e0fd1e492aa92638573276de066e889a22b26", + "sha256:8d18db74a750dd97f40b483cc3ef80d07d03f687525bad8fd83365dcd3bfd414" ], "index": "pypi", - "version": "==2.2.0" + "version": "==2.3.0" }, "flake8-bugbear": { "hashes": [ @@ -842,11 +860,11 @@ }, "identify": { "hashes": [ - "sha256:c4d07f2b979e3931894170a9e0d4b8281e6905ea6d018c326f7ffefaf20db680", - "sha256:dac33eff90d57164e289fb20bf4e131baef080947ee9bf45efcd0da8d19064bf" + "sha256:110ed090fec6bce1aabe3c72d9258a9de82207adeaa5a05cd75c635880312f9a", + "sha256:ccd88716b890ecbe10920659450a635d2d25de499b9a638525a48b48261d989b" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==1.4.21" + "version": "==1.4.25" }, "mccabe": { "hashes": [ @@ -950,11 +968,11 @@ }, "virtualenv": { "hashes": [ - "sha256:c11a475400e98450403c0364eb3a2d25d42f71cf1493da64390487b666de4324", - "sha256:e10cc66f40cbda459720dfe1d334c4dc15add0d80f09108224f171006a97a172" + "sha256:688a61d7976d82b92f7906c367e83bb4b3f0af96f8f75bfcd3da95608fe8ac6c", + "sha256:8f582a030156282a9ee9d319984b759a232b07f86048c1d6a9e394afa44e78c8" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.26" + "version": "==20.0.28" } } } -- cgit v1.2.3 From 1b6be865eddeab9199177d74ec07f8cd22051ca4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:17:44 +0200 Subject: Expect status 400 for duplicates. --- bot/cogs/filter_lists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index d1db9830e..9bd2da330 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -64,10 +64,10 @@ class FilterLists(Cog): json=payload ) except ResponseCodeError as e: - if e.status == 500: + if e.status == 400: await ctx.message.add_reaction("❌") log.debug( - f"{ctx.author} tried to add data to a {allow_type}, but the API returned 500, " + f"{ctx.author} tried to add data to a {allow_type}, but the API returned 400, " "probably because the request violated the UniqueConstraint." ) raise BadArgument( -- cgit v1.2.3 From 222cdce0b9771b0c121da39b5f38363baf8bce09 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:25:00 +0200 Subject: Use a defaultdict(list) for filter_list_cache. --- bot/bot.py | 5 +++-- bot/cogs/filter_lists.py | 6 +++--- bot/cogs/filtering.py | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 3dfb4e948..a309e7192 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -2,6 +2,7 @@ import asyncio import logging import socket import warnings +from collections import defaultdict from typing import Optional import aiohttp @@ -34,7 +35,7 @@ class Bot(commands.Bot): self.redis_ready = asyncio.Event() self.redis_closed = False self.api_client = api.APIClient(loop=self.loop) - self.filter_list_cache = {} + self.filter_list_cache = defaultdict(list) self._connector = None self._resolver = None @@ -64,7 +65,7 @@ class Bot(commands.Bot): "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), } - self.filter_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + self.filter_list_cache[f"{type_}.{allowed}"].append(metadata) async def _create_redis_session(self) -> None: """ diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 9bd2da330..63d74e421 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -88,7 +88,7 @@ class FilterLists(Cog): "created_at": item.get("created_at"), "updated_at": item.get("updated_at"), } - self.bot.filter_list_cache.setdefault(f"{type_}.{allowed}", []).append(metadata) + self.bot.filter_list_cache[f"{type_}.{allowed}"].append(metadata) await ctx.message.add_reaction("✅") async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: @@ -110,7 +110,7 @@ class FilterLists(Cog): # Find the content and delete it. log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - for allow_list in self.bot.filter_list_cache.get(f"{list_type}.{allowed}", []): + for allow_list in self.bot.filter_list_cache[f"{list_type}.{allowed}"]: if content == allow_list.get("content"): item = allow_list break @@ -125,7 +125,7 @@ class FilterLists(Cog): async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: """Paginate and display all items in a filterlist.""" allow_type = "whitelist" if allowed else "blacklist" - result = self.bot.filter_list_cache.get(f"{list_type}.{allowed}", []) + result = self.bot.filter_list_cache[f"{list_type}.{allowed}"] # Build a list of lines we want to show in the paginator lines = [] diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 652af5ff5..9f9bcc464 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -101,7 +101,7 @@ class Filtering(Cog): def _get_filterlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: """Fetch items from the filter_list_cache.""" - items = self.bot.filter_list_cache.get(f"{list_type.upper()}.{allowed}", []) + items = self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"] if compiled: return [re.compile(fr'{item["content"]}', flags=re.IGNORECASE) for item in items] -- cgit v1.2.3 From 1a0b2938dab931b6dc482a6b7a4b17549e0cf36f Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:30:48 +0200 Subject: Kaizen - group private methods together. --- bot/bot.py | 86 +++++++++++++++++++++++++++++++------------------------------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index a309e7192..3da5c0bb8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -91,6 +91,49 @@ class Bot(commands.Bot): self.redis_closed = False self.redis_ready.set() + def _recreate(self) -> None: + """Re-create the connector, aiohttp session, the APIClient and the Redis session.""" + # Use asyncio for DNS resolution instead of threads so threads aren't spammed. + # Doesn't seem to have any state with regards to being closed, so no need to worry? + self._resolver = aiohttp.AsyncResolver() + + # Its __del__ does send a warning but it doesn't always show up for some reason. + if self._connector and not self._connector._closed: + log.warning( + "The previous connector was not closed; it will remain open and be overwritten" + ) + + if self.redis_session and not self.redis_session.closed: + log.warning( + "The previous redis pool was not closed; it will remain open and be overwritten" + ) + + # Create the redis session + self.loop.create_task(self._create_redis_session()) + + # Use AF_INET as its socket family to prevent HTTPS related problems both locally + # and in production. + self._connector = aiohttp.TCPConnector( + resolver=self._resolver, + family=socket.AF_INET, + ) + + # Client.login() will call HTTPClient.static_login() which will create a session using + # this connector attribute. + self.http.connector = self._connector + + # Its __del__ does send a warning but it doesn't always show up for some reason. + if self.http_session and not self.http_session.closed: + log.warning( + "The previous session was not closed; it will remain open and be overwritten" + ) + + self.http_session = aiohttp.ClientSession(connector=self._connector) + self.api_client.recreate(force=True, connector=self._connector) + + # Build the FilterList cache + self.loop.create_task(self._cache_filter_list_data()) + def add_cog(self, cog: commands.Cog) -> None: """Adds a "cog" to the bot and logs the operation.""" super().add_cog(cog) @@ -137,49 +180,6 @@ class Bot(commands.Bot): await self.stats.create_socket() await super().login(*args, **kwargs) - def _recreate(self) -> None: - """Re-create the connector, aiohttp session, the APIClient and the Redis session.""" - # Use asyncio for DNS resolution instead of threads so threads aren't spammed. - # Doesn't seem to have any state with regards to being closed, so no need to worry? - self._resolver = aiohttp.AsyncResolver() - - # Its __del__ does send a warning but it doesn't always show up for some reason. - if self._connector and not self._connector._closed: - log.warning( - "The previous connector was not closed; it will remain open and be overwritten" - ) - - if self.redis_session and not self.redis_session.closed: - log.warning( - "The previous redis pool was not closed; it will remain open and be overwritten" - ) - - # Create the redis session - self.loop.create_task(self._create_redis_session()) - - # Use AF_INET as its socket family to prevent HTTPS related problems both locally - # and in production. - self._connector = aiohttp.TCPConnector( - resolver=self._resolver, - family=socket.AF_INET, - ) - - # Client.login() will call HTTPClient.static_login() which will create a session using - # this connector attribute. - self.http.connector = self._connector - - # Its __del__ does send a warning but it doesn't always show up for some reason. - if self.http_session and not self.http_session.closed: - log.warning( - "The previous session was not closed; it will remain open and be overwritten" - ) - - self.http_session = aiohttp.ClientSession(connector=self._connector) - self.api_client.recreate(force=True, connector=self._connector) - - # Build the FilterList cache - self.loop.create_task(self._cache_filter_list_data()) - async def on_guild_available(self, guild: discord.Guild) -> None: """ Set the internal guild available event when constants.Guild.id becomes available. -- cgit v1.2.3 From a23b273734ddee5fd082bda4fa14aebfff1317ca Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:39:05 +0200 Subject: Make a helper for inserting filter lists. --- bot/bot.py | 26 +++++++++++++++----------- bot/cogs/filter_lists.py | 11 +---------- 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 3da5c0bb8..203b35ba0 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -3,7 +3,7 @@ import logging import socket import warnings from collections import defaultdict -from typing import Optional +from typing import Any, Dict, Optional import aiohttp import aioredis @@ -56,16 +56,7 @@ class Bot(commands.Bot): full_cache = await self.api_client.get('bot/filter-lists') for item in full_cache: - type_ = item.get("type") - allowed = item.get("allowed") - metadata = { - "content": item.get("content"), - "comment": item.get("comment"), - "id": item.get("id"), - "created_at": item.get("created_at"), - "updated_at": item.get("updated_at"), - } - self.filter_list_cache[f"{type_}.{allowed}"].append(metadata) + self.insert_item_into_filter_list_cache(item) async def _create_redis_session(self) -> None: """ @@ -174,6 +165,19 @@ class Bot(commands.Bot): self.redis_ready.clear() await self.redis_session.wait_closed() + def insert_item_into_filter_list_cache(self, item: Dict[Any]) -> None: + """Add an item to the bots filter_list_cache.""" + type_ = item["type"] + allowed = item["allowed"] + metadata = { + "id": item["id"], + "content": item["content"], + "comment": item["comment"], + "created_at": item["created_at"], + "updated_at": item["updated_at"], + } + self.filter_list_cache[f"{type_}.{allowed}"].append(metadata) + async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" self._recreate() diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 63d74e421..e0d057595 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -79,16 +79,7 @@ class FilterLists(Cog): raise # Insert the item into the cache - type_ = item.get("type") - allowed = item.get("allowed") - metadata = { - "content": item.get("content"), - "comment": item.get("comment"), - "id": item.get("id"), - "created_at": item.get("created_at"), - "updated_at": item.get("updated_at"), - } - self.bot.filter_list_cache[f"{type_}.{allowed}"].append(metadata) + self.bot.insert_item_into_filter_list_cache(item) await ctx.message.add_reaction("✅") async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: -- cgit v1.2.3 From a7a3e29ca901b84570e5a1ff1e4c2bcf22b86552 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 14:55:59 +0200 Subject: Make a helper for validating guild invites. --- bot/cogs/filter_lists.py | 34 ++++++++++++++++++---------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index e0d057595..a93de2de9 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -33,13 +33,7 @@ class FilterLists(Cog): # If this is a server invite, we gotta validate it. if list_type == "GUILD_INVITE": - log.trace(f"{content} is a guild invite, attempting to validate.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, content) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's convert the content to an ID. - log.trace(f"{content} validated as server invite. Converting to ID.") + guild_data = await self._validate_guild_invite(ctx, content) content = guild_data.get("id") # Unless the user has specified another comment, let's @@ -86,17 +80,10 @@ class FilterLists(Cog): """Remove an item from a filterlist.""" item = None allow_type = "whitelist" if allowed else "blacklist" - id_converter = IDConverter() # If this is a server invite, we need to convert it. - if list_type == "GUILD_INVITE" and not id_converter._get_id_match(content): - log.trace(f"{content} is a guild invite, attempting to validate.") - validator = ValidDiscordServerInvite() - guild_data = await validator.convert(ctx, content) - - # If we make it this far without raising a BadArgument, the invite is - # valid. Let's convert the content to an ID. - log.trace(f"{content} validated as server invite. Converting to ID.") + if list_type == "GUILD_INVITE" and not IDConverter()._get_id_match(content): + guild_data = await self._validate_guild_invite(ctx, content) content = guild_data.get("id") # Find the content and delete it. @@ -143,6 +130,21 @@ class FilterLists(Cog): embed.description = "Hmmm, seems like there's nothing here yet." await ctx.send(embed=embed) + async def _validate_guild_invite(self, ctx: Context, invite: str) -> dict: + """ + Validates a guild invite, and returns the guild info as a dict. + + Will raise a BadArgument if the guild invite is invalid. + """ + log.trace(f"Attempting to validate whether or not {invite} is a guild invite.") + validator = ValidDiscordServerInvite() + guild_data = await validator.convert(ctx, invite) + + # If we make it this far without raising a BadArgument, the invite is + # valid. Let's return a dict of guild information. + log.trace(f"{invite} validated as server invite. Converting to ID.") + return guild_data + @group(aliases=("allowlist", "allow", "al", "wl")) async def whitelist(self, ctx: Context) -> None: """Group for whitelisting commands.""" -- cgit v1.2.3 From 4e1609695762524bc707b6a8d39e88c2710cff6b Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 15:03:15 +0200 Subject: Refactor filtering: use non-compiled expressions. --- 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 9f9bcc464..7787d396d 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -99,14 +99,10 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) - def _get_filterlist_items(self, list_type: str, *, allowed: bool, compiled: Optional[bool] = False) -> list: + def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: """Fetch items from the filter_list_cache.""" items = self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"] - - if compiled: - return [re.compile(fr'{item["content"]}', flags=re.IGNORECASE) for item in items] - else: - return [item["content"] for item in items] + return [item["content"] for item in items] @staticmethod def _expand_spoilers(text: str) -> str: @@ -143,9 +139,9 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False, compiled=True) + watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False) for pattern in watchlist_patterns: - if match := pattern.search(name): + if match := re.search(pattern, name, flags=re.IGNORECASE): matches.append(match) return matches @@ -408,9 +404,9 @@ class Filtering(Cog): if URL_RE.search(text): return False - watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False, compiled=True) + watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False) for pattern in watchlist_patterns: - match = pattern.search(text) + match = re.search(pattern, text, flags=re.IGNORECASE) if match: return match -- cgit v1.2.3 From e73589a0cc490187cb7aa3039628a29e1c1650c9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 15:19:04 +0200 Subject: Fix imports in converters.py --- bot/converters.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index 158bf1a16..77d0bead7 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -1,14 +1,15 @@ -import dateutil.parser -import dateutil.tz -import discord import logging import re import typing as t -from aiohttp import ClientConnectorError from datetime import datetime +from ssl import CertificateError + +import dateutil.parser +import dateutil.tz +import discord +from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta from discord.ext.commands import BadArgument, Context, Converter, IDConverter, UserConverter -from ssl import CertificateError from bot.api import ResponseCodeError from bot.constants import URLs -- cgit v1.2.3 From e93cdca80026704d540c87e36a56ce059e8d5499 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 15:38:20 +0200 Subject: Fix a bad type annotation. --- bot/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 203b35ba0..5deb986ec 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -3,7 +3,7 @@ import logging import socket import warnings from collections import defaultdict -from typing import Any, Dict, Optional +from typing import Dict, Optional import aiohttp import aioredis @@ -165,7 +165,7 @@ class Bot(commands.Bot): self.redis_ready.clear() await self.redis_session.wait_closed() - def insert_item_into_filter_list_cache(self, item: Dict[Any]) -> None: + def insert_item_into_filter_list_cache(self, item: Dict[str, str]) -> None: """Add an item to the bots filter_list_cache.""" type_ = item["type"] allowed = item["allowed"] -- cgit v1.2.3 From e0837f4f6dd7c5c2d6fc0811dccfaf1ecae768ba Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 20:14:52 +0200 Subject: Restructure bot.filter_list_cache. This is an optimization designed to eliminate all the list comprehensions we were doing inside antimalware and filtering. The cache is now structured so that the content is the key and the metadata is the value. --- bot/bot.py | 8 ++++---- bot/cogs/antimalware.py | 2 +- bot/cogs/filter_lists.py | 18 +++++++++--------- bot/cogs/filtering.py | 3 +-- tests/bot/cogs/test_antimalware.py | 10 +++++----- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 5deb986ec..4492feaa9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -35,7 +35,7 @@ class Bot(commands.Bot): self.redis_ready = asyncio.Event() self.redis_closed = False self.api_client = api.APIClient(loop=self.loop) - self.filter_list_cache = defaultdict(list) + self.filter_list_cache = defaultdict(dict) self._connector = None self._resolver = None @@ -169,14 +169,14 @@ class Bot(commands.Bot): """Add an item to the bots filter_list_cache.""" type_ = item["type"] allowed = item["allowed"] - metadata = { + content = item["content"] + + self.filter_list_cache[f"{type_}.{allowed}"][content] = { "id": item["id"], - "content": item["content"], "comment": item["comment"], "created_at": item["created_at"], "updated_at": item["updated_at"], } - self.filter_list_cache[f"{type_}.{allowed}"].append(metadata) async def login(self, *args, **kwargs) -> None: """Re-create the connector and set up sessions before logging into Discord.""" diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index 9a100b3fc..c76bd2c60 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -40,7 +40,7 @@ class AntiMalware(Cog): def _get_whitelisted_file_formats(self) -> list: """Get the file formats currently on the whitelist.""" - return [item['content'] for item in self.bot.filter_list_cache['file_format.True']] + return self.bot.filter_list_cache['FILE_FORMAT.True'].keys() def _get_disallowed_extensions(self, message: Message) -> t.Iterable[str]: """Get an iterable containing all the disallowed extensions of attachments.""" diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index a93de2de9..3331be014 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -88,16 +88,16 @@ class FilterLists(Cog): # Find the content and delete it. log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - for allow_list in self.bot.filter_list_cache[f"{list_type}.{allowed}"]: - if content == allow_list.get("content"): - item = allow_list + for allow_list, metadata in self.bot.filter_list_cache[f"{list_type}.{allowed}"].items(): + if content == allow_list: + item = metadata break if item is not None: await self.bot.api_client.delete( - f"bot/filter-lists/{item.get('id')}" + f"bot/filter-lists/{item['id']}" ) - self.bot.filter_list_cache[f"{list_type}.{allowed}"].remove(item) + del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] await ctx.message.add_reaction("✅") async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: @@ -107,11 +107,11 @@ class FilterLists(Cog): # Build a list of lines we want to show in the paginator lines = [] - for item in result: - line = f"• `{item.get('content')}`" + for content, metadata in result.items(): + line = f"• `{content}`" - if item.get("comment"): - line += f" - {item.get('comment')}" + if metadata.get("comment"): + line += f" - {metadata.get('comment')}" lines.append(line) lines = sorted(lines) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 7787d396d..0951cb740 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -101,8 +101,7 @@ class Filtering(Cog): def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: """Fetch items from the filter_list_cache.""" - items = self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"] - return [item["content"] for item in items] + return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() @staticmethod def _expand_spoilers(text: str) -> str: diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index 664fa8f19..82eadf226 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -15,11 +15,11 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): """Sets up fresh objects for each test.""" self.bot = MockBot() self.bot.filter_list_cache = { - "file_format.True": [ - {"content": ".first"}, - {"content": ".second"}, - {"content": ".third"} - ] + "file_format.True": { + ".first": {}, + ".second": {}, + ".third": {}, + } } self.cog = antimalware.AntiMalware(self.bot) self.message = MockMessage() -- cgit v1.2.3 From 48bc968d3c03032beed8ac110b76dc468262a4d3 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 20:15:17 +0200 Subject: word_watchlist -> filter_token in filtering.py. --- 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 0951cb740..8670e1c8c 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -138,7 +138,7 @@ class Filtering(Cog): def get_name_matches(self, name: str) -> List[re.Match]: """Check bad words from passed string (name). Return list of matches.""" matches = [] - watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False) + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) for pattern in watchlist_patterns: if match := re.search(pattern, name, flags=re.IGNORECASE): matches.append(match) @@ -403,7 +403,7 @@ class Filtering(Cog): if URL_RE.search(text): return False - watchlist_patterns = self._get_filterlist_items('word_watchlist', allowed=False) + watchlist_patterns = self._get_filterlist_items('filter_token', allowed=False) for pattern in watchlist_patterns: match = re.search(pattern, text, flags=re.IGNORECASE) if match: -- cgit v1.2.3 From 13a5f35273da39aafdcda7b257364a7756b028ff Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 20:15:48 +0200 Subject: We search for an invite instead of matching one. This means we can validate invites that start with https://, whereas before we could not. --- bot/converters.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/converters.py b/bot/converters.py index 77d0bead7..5912e3e61 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -57,7 +57,7 @@ class ValidDiscordServerInvite(Converter): async def convert(self, ctx: Context, server_invite: str) -> dict: """Check whether the string is a valid Discord server invite.""" - invite_code = INVITE_RE.match(server_invite) + invite_code = INVITE_RE.search(server_invite) if invite_code: response = await ctx.bot.http_session.get( f"{URLs.discord_invite_api}/{invite_code[1]}" -- cgit v1.2.3 From 0cfc918c6d68764c380f1188f3bc5508e6b27030 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 20:24:06 +0200 Subject: Fix broken antimalware tests. --- tests/bot/cogs/test_antimalware.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index 82eadf226..ecb7abf00 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -15,7 +15,7 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): """Sets up fresh objects for each test.""" self.bot = MockBot() self.bot.filter_list_cache = { - "file_format.True": { + "FILE_FORMAT.True": { ".first": {}, ".second": {}, ".third": {}, -- cgit v1.2.3 From dd3275e8a8552f9d7580f9e2a070e8fae1d41b5d Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 21:49:33 +0200 Subject: Apply suggested change from @MarkKoz. --- bot/cogs/filter_lists.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 3331be014..f133d53d9 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -88,10 +88,7 @@ class FilterLists(Cog): # Find the content and delete it. log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") - for allow_list, metadata in self.bot.filter_list_cache[f"{list_type}.{allowed}"].items(): - if content == allow_list: - item = metadata - break + item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) if item is not None: await self.bot.api_client.delete( -- cgit v1.2.3 From 4d1099938f4582330ce6c732dac4862df6ec68e4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 21:54:27 +0200 Subject: Make sure file formats have leading dots. --- bot/cogs/filter_lists.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index f133d53d9..8831a2143 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -43,6 +43,10 @@ class FilterLists(Cog): if not comment: comment = guild_data.get("name") + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + # Try to add the item to the database log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { @@ -86,6 +90,10 @@ class FilterLists(Cog): guild_data = await self._validate_guild_invite(ctx, content) content = guild_data.get("id") + # If it's a file format, let's make sure it has a leading dot. + elif list_type == "FILE_FORMAT" and not content.startswith("."): + content = f".{content}" + # Find the content and delete it. log.trace(f"Trying to delete the {content} item from the {list_type} {allow_type}") item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) -- cgit v1.2.3 From 0f8a89bd8be9b5bd6fbad989ad3aa57103a1f9da Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Wed, 29 Jul 2020 23:46:23 +0200 Subject: Dynamically amend types to filterlist docstrings. We want the !help invocations to give you all the information you need in order to use the command. That also means we need to provide the valid filterlist types, which are subject to change. So, we fetch the valid ones from the API and then dynamically insert them into the docstrings. --- bot/cogs/filter_lists.py | 24 ++++++++++++++++++++++++ bot/converters.py | 19 ++++++++++++++----- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 8831a2143..fbd070bb9 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -17,8 +17,32 @@ log = logging.getLogger(__name__) class FilterLists(Cog): """Commands for blacklisting and whitelisting things.""" + methods_with_filterlist_types = [ + "allow_add", + "allow_delete", + "allow_get", + "deny_add", + "deny_delete", + "deny_get", + ] + def __init__(self, bot: Bot) -> None: self.bot = bot + self.bot.loop.create_task(self._amend_docstrings()) + + async def _amend_docstrings(self) -> None: + """Add the valid FilterList types to the docstrings, so they'll appear in !help invocations.""" + await self.bot.wait_until_guild_available() + + # Add valid filterlist types to the docstrings + valid_types = await ValidFilterListType.get_valid_types(self.bot) + valid_types = [f"`{type_.lower()}`" for type_ in valid_types] + + for method_name in self.methods_with_filterlist_types: + command = getattr(self, method_name) + command.help = ( + f"{command.help}\n\nValid **list_type** values are {', '.join(valid_types)}." + ) async def _add_data( self, diff --git a/bot/converters.py b/bot/converters.py index 5912e3e61..c9f525dd1 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -9,7 +9,7 @@ import dateutil.tz import discord from aiohttp import ClientConnectorError from dateutil.relativedelta import relativedelta -from discord.ext.commands import BadArgument, Context, Converter, IDConverter, UserConverter +from discord.ext.commands import BadArgument, Bot, Context, Converter, IDConverter, UserConverter from bot.api import ResponseCodeError from bot.constants import URLs @@ -81,14 +81,23 @@ class ValidFilterListType(Converter): passes through the given argument otherwise. """ - async def convert(self, ctx: Context, list_type: str) -> str: - """Checks whether the given string is a valid FilterList type.""" + @staticmethod + async def get_valid_types(bot: Bot) -> list: + """ + Try to get a list of valid filter list types. + + Raise a BadArgument if the API can't respond. + """ try: - valid_types = await ctx.bot.api_client.get('bot/filter-lists/get-types') + valid_types = await bot.api_client.get('bot/filter-lists/get-types') except ResponseCodeError: raise BadArgument("Cannot validate list_type: Unable to fetch valid types from API.") - valid_types = [enum for enum, classname in valid_types] + return [enum for enum, classname in valid_types] + + async def convert(self, ctx: Context, list_type: str) -> str: + """Checks whether the given string is a valid FilterList type.""" + valid_types = await self.get_valid_types(ctx.bot) list_type = list_type.upper() if list_type not in valid_types: -- cgit v1.2.3 From 9795d680b50a704424959d581d1f137b28f4e859 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Thu, 30 Jul 2020 00:10:33 +0200 Subject: Add more explicit feedback to failures. For deleting and listing data, we now get some more feedback when things fail. --- bot/cogs/filter_lists.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index fbd070bb9..52db1fcb5 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -106,7 +106,6 @@ class FilterLists(Cog): async def _delete_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType, content: str) -> None: """Remove an item from a filterlist.""" - item = None allow_type = "whitelist" if allowed else "blacklist" # If this is a server invite, we need to convert it. @@ -123,11 +122,20 @@ class FilterLists(Cog): item = self.bot.filter_list_cache[f"{list_type}.{allowed}"].get(content) if item is not None: - await self.bot.api_client.delete( - f"bot/filter-lists/{item['id']}" - ) - del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] - await ctx.message.add_reaction("✅") + try: + await self.bot.api_client.delete( + f"bot/filter-lists/{item['id']}" + ) + del self.bot.filter_list_cache[f"{list_type}.{allowed}"][content] + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to delete an item with the id {item['id']}, but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") + else: + await ctx.message.add_reaction("❌") async def _list_all_data(self, ctx: Context, allowed: bool, list_type: ValidFilterListType) -> None: """Paginate and display all items in a filterlist.""" @@ -158,8 +166,10 @@ class FilterLists(Cog): else: embed.description = "Hmmm, seems like there's nothing here yet." await ctx.send(embed=embed) + await ctx.message.add_reaction("❌") - async def _validate_guild_invite(self, ctx: Context, invite: str) -> dict: + @staticmethod + async def _validate_guild_invite(ctx: Context, invite: str) -> dict: """ Validates a guild invite, and returns the guild info as a dict. -- cgit v1.2.3 From 6c934ebdf5dfa3025347a1b345b41f0f62ec76cb Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 10:04:58 +0200 Subject: Sort all load_extension groups alphabetically. --- bot/__main__.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/__main__.py b/bot/__main__.py index c2271cd16..fcef2239e 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -34,35 +34,34 @@ bot = Bot( ) # Internal/debug +bot.load_extension("bot.cogs.config_verifier") bot.load_extension("bot.cogs.error_handler") bot.load_extension("bot.cogs.filtering") bot.load_extension("bot.cogs.logging") bot.load_extension("bot.cogs.security") -bot.load_extension("bot.cogs.config_verifier") # Commands, etc bot.load_extension("bot.cogs.antimalware") bot.load_extension("bot.cogs.antispam") bot.load_extension("bot.cogs.bot") bot.load_extension("bot.cogs.clean") +bot.load_extension("bot.cogs.doc") bot.load_extension("bot.cogs.extensions") bot.load_extension("bot.cogs.help") - -bot.load_extension("bot.cogs.doc") bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") -bot.load_extension("bot.cogs.filter_lists") 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.filter_lists") bot.load_extension("bot.cogs.information") bot.load_extension("bot.cogs.jams") bot.load_extension("bot.cogs.moderation") -bot.load_extension("bot.cogs.python_news") bot.load_extension("bot.cogs.off_topic_names") +bot.load_extension("bot.cogs.python_news") bot.load_extension("bot.cogs.reddit") bot.load_extension("bot.cogs.reminders") bot.load_extension("bot.cogs.site") -- cgit v1.2.3 From fff5493b9cec4ed920acee82698c34eef76206a4 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 10:07:06 +0200 Subject: Adding a beautiful walrus to filter_lists.py. Thanks @Den4200! --- bot/cogs/filter_lists.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 52db1fcb5..496d45322 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -147,8 +147,8 @@ class FilterLists(Cog): for content, metadata in result.items(): line = f"• `{content}`" - if metadata.get("comment"): - line += f" - {metadata.get('comment')}" + if comment := metadata.get("comment"): + line += f" - {comment}" lines.append(line) lines = sorted(lines) -- cgit v1.2.3 From 5a339639e598f2e84ec9367dd2ea519befd4f011 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 10:10:17 +0200 Subject: Change some errant single quotes to doubles. --- bot/cogs/filter_lists.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 496d45322..e50411d51 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -74,10 +74,10 @@ class FilterLists(Cog): # Try to add the item to the database log.trace(f"Trying to add the {content} item to the {list_type} {allow_type}") payload = { - 'allowed': allowed, - 'type': list_type, - 'content': content, - 'comment': comment, + "allowed": allowed, + "type": list_type, + "content": content, + "comment": comment, } try: -- cgit v1.2.3 From 7a84ed8dbec5c1497a08865fa3144eb867dc1636 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 10:15:09 +0200 Subject: Move function params to 4-space indentation. --- bot/cogs/filter_lists.py | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index e50411d51..8aa5a0a08 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -45,12 +45,12 @@ class FilterLists(Cog): ) async def _add_data( - self, - ctx: Context, - allowed: bool, - list_type: ValidFilterListType, - content: str, - comment: Optional[str] = None, + self, + ctx: Context, + allowed: bool, + list_type: ValidFilterListType, + content: str, + comment: Optional[str] = None, ) -> None: """Add an item to a filterlist.""" allow_type = "whitelist" if allowed else "blacklist" @@ -198,24 +198,24 @@ class FilterLists(Cog): @whitelist.command(name="add", aliases=("a", "set")) async def allow_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, ) -> None: """Add an item to the specified allowlist.""" await self._add_data(ctx, True, list_type, content, comment) @blacklist.command(name="add", aliases=("a", "set")) async def deny_add( - self, - ctx: Context, - list_type: ValidFilterListType, - content: str, - *, - comment: Optional[str] = None, + self, + ctx: Context, + list_type: ValidFilterListType, + content: str, + *, + comment: Optional[str] = None, ) -> None: """Add an item to the specified denylist.""" await self._add_data(ctx, False, list_type, content, comment) -- cgit v1.2.3 From 134ea0e449005a771c8184189a8d319e5d4b26a0 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 13:33:17 +0200 Subject: Move function params to 4-space indentation. --- bot/bot.py | 4 ++-- bot/cogs/filter_lists.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 4492feaa9..756449293 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -51,7 +51,7 @@ class Bot(commands.Bot): self.stats = AsyncStatsClient(self.loop, statsd_url, 8125, prefix="bot") - async def _cache_filter_list_data(self) -> None: + async def cache_filter_list_data(self) -> None: """Cache all the data in the FilterList on the site.""" full_cache = await self.api_client.get('bot/filter-lists') @@ -123,7 +123,7 @@ class Bot(commands.Bot): self.api_client.recreate(force=True, connector=self._connector) # Build the FilterList cache - self.loop.create_task(self._cache_filter_list_data()) + self.loop.create_task(self.cache_filter_list_data()) def add_cog(self, cog: commands.Cog) -> None: """Adds a "cog" to the bot and logs the operation.""" diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 8aa5a0a08..6249774bb 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -168,6 +168,11 @@ class FilterLists(Cog): await ctx.send(embed=embed) await ctx.message.add_reaction("❌") + async def _sync_data(self) -> None: + """Syncs the filterlists with the API.""" + log.trace("Synchronizing FilterList cache with data from the API.") + await self.bot.cache_filter_list_data() + @staticmethod async def _validate_guild_invite(ctx: Context, invite: str) -> dict: """ @@ -240,6 +245,16 @@ class FilterLists(Cog): """Get the contents of a specified denylist.""" await self._list_all_data(ctx, False, list_type) + @whitelist.command(name="sync", aliases=("s",)) + async def allow_sync(self, _: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data() + + @blacklist.command(name="sync", aliases=("s",)) + async def deny_sync(self, _: Context) -> None: + """Syncs both allowlists and denylists with the API.""" + await self._sync_data() + def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" return with_role_check(ctx, *constants.MODERATION_ROLES) -- cgit v1.2.3 From 312d31d408e2580a08b5b36a6f885f6d1a5955b9 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 14:08:08 +0200 Subject: Add some feedback to the _sync_data helper. Previously, this would not provide any feedback at all, which is really terrible UX. Sorry about that. This also adds error handling in case the API call fails. --- bot/cogs/filter_lists.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bot/cogs/filter_lists.py b/bot/cogs/filter_lists.py index 6249774bb..c15adc461 100644 --- a/bot/cogs/filter_lists.py +++ b/bot/cogs/filter_lists.py @@ -168,10 +168,18 @@ class FilterLists(Cog): await ctx.send(embed=embed) await ctx.message.add_reaction("❌") - async def _sync_data(self) -> None: + async def _sync_data(self, ctx: Context) -> None: """Syncs the filterlists with the API.""" - log.trace("Synchronizing FilterList cache with data from the API.") - await self.bot.cache_filter_list_data() + try: + log.trace("Attempting to sync FilterList cache with data from the API.") + await self.bot.cache_filter_list_data() + await ctx.message.add_reaction("✅") + except ResponseCodeError as e: + log.debug( + f"{ctx.author} tried to sync FilterList cache data but " + f"the API raised an unexpected error: {e}" + ) + await ctx.message.add_reaction("❌") @staticmethod async def _validate_guild_invite(ctx: Context, invite: str) -> dict: @@ -246,14 +254,14 @@ class FilterLists(Cog): await self._list_all_data(ctx, False, list_type) @whitelist.command(name="sync", aliases=("s",)) - async def allow_sync(self, _: Context) -> None: + async def allow_sync(self, ctx: Context) -> None: """Syncs both allowlists and denylists with the API.""" - await self._sync_data() + await self._sync_data(ctx) @blacklist.command(name="sync", aliases=("s",)) - async def deny_sync(self, _: Context) -> None: + async def deny_sync(self, ctx: Context) -> None: """Syncs both allowlists and denylists with the API.""" - await self._sync_data() + await self._sync_data(ctx) def cog_check(self, ctx: Context) -> bool: """Only allow moderators to invoke the commands in this cog.""" -- cgit v1.2.3 From e73f77a34c3b2f0ec226acbdfc490f93896784d0 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Mon, 3 Aug 2020 17:40:09 +0200 Subject: Add support for plural FilterList types. This will allow mods to use '!whitelist get guild_invites' in addition to '!whitelist get guild_invite' This is just a naive implementation which works if the plural form is a simple s at the end of the word. It's implemented into the converter. --- bot/converters.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/bot/converters.py b/bot/converters.py index c9f525dd1..1358cbf1e 100644 --- a/bot/converters.py +++ b/bot/converters.py @@ -101,11 +101,23 @@ class ValidFilterListType(Converter): list_type = list_type.upper() if list_type not in valid_types: - valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) - raise BadArgument( - f"You have provided an invalid list type!\n\n" - f"Please provide one of the following: \n{valid_types_list}" - ) + + # Maybe the user is using the plural form of this type, + # e.g. "guild_invites" instead of "guild_invite". + # + # This code will support the simple plural form (a single 's' at the end), + # which works for all current list types, but if a list type is added in the future + # which has an irregular plural form (like 'ies'), this code will need to be + # refactored to support this. + if list_type.endswith("S") and list_type[:-1] in valid_types: + list_type = list_type[:-1] + + else: + valid_types_list = '\n'.join([f"• {type_.lower()}" for type_ in valid_types]) + raise BadArgument( + f"You have provided an invalid list type!\n\n" + f"Please provide one of the following: \n{valid_types_list}" + ) return list_type -- cgit v1.2.3 From 239fa5f43ab79435151657671ebcf21eac706fc6 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Tue, 4 Aug 2020 15:30:34 +0100 Subject: Revert "Disabled burst_shared filter temporarily" This reverts commit be14db91b1c70993773e67cfa663fef0cfa85666. --- config-default.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/config-default.yml b/config-default.yml index 14a073611..aacbe170f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -358,6 +358,10 @@ anti_spam: interval: 10 max: 7 + burst_shared: + interval: 10 + max: 20 + chars: interval: 5 max: 3_000 -- cgit v1.2.3 From e2a035cb75f049ed9deae0c88553cf5cece538e8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:11:35 -0700 Subject: Filtering: ignore webhooks for nickname filter Fixes #1027 --- bot/cogs/filtering.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 64afd184d..cdad1d01d 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -120,7 +120,10 @@ 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_bad_words_in_name(msg.author) + + # Ignore webhook messages. + if msg.webhook_id is None: + await self.check_bad_words_in_name(msg.author) @Cog.listener() async def on_message_edit(self, before: Message, after: Message) -> None: -- cgit v1.2.3 From 1344d3d6be3e6d244207996784987db5e48523a6 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:20:52 -0700 Subject: Utils: show error message for long poll titles Embeds have a maximum length of 256 for titles. Fixes #1079 Fixes BOT-7Q --- bot/cogs/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/utils.py b/bot/cogs/utils.py index 91c6cb36e..d96abbd5a 100644 --- a/bot/cogs/utils.py +++ b/bot/cogs/utils.py @@ -232,6 +232,8 @@ class Utils(Cog): A maximum of 20 options can be provided, as Discord supports a max of 20 reactions on a single message. """ + if len(title) > 256: + raise BadArgument("The title cannot be longer than 256 characters.") if len(options) < 2: raise BadArgument("Please provide at least 2 options.") if len(options) > 20: -- cgit v1.2.3 From f553785858e70ff78c6fef5a5a4fa3e75c09a55e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:32:44 -0700 Subject: HelpChannels: move unpinning to separate function --- bot/cogs/help_channels.py | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 1be980472..61e8d4384 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -551,18 +551,6 @@ class HelpChannels(commands.Cog): A caller argument is provided for metrics. """ - msg_id = await self.question_messages.pop(channel.id) - - try: - await self.bot.http.unpin_message(channel.id, msg_id) - except discord.HTTPException as e: - if e.code == 10008: - log.trace(f"Message {msg_id} don't exist, can't unpin.") - else: - log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") - else: - log.trace(f"Unpinned message {msg_id}.") - log.info(f"Moving #{channel} ({channel.id}) to the Dormant category.") await self.move_to_bottom_position( @@ -587,6 +575,8 @@ class HelpChannels(commands.Cog): embed = discord.Embed(description=DORMANT_MSG) await channel.send(embed=embed) + await self.unpin(channel) + log.trace(f"Pushing #{channel} ({channel.id}) into the channel queue.") self.channel_queue.put_nowait(channel) self.report_stats() @@ -863,6 +853,20 @@ class HelpChannels(commands.Cog): log.trace(f"Channel #{channel} ({channel_id}) retrieved.") return channel + async def unpin(self, channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await self.question_messages.pop(channel.id) + + try: + await self.bot.http.unpin_message(channel.id, msg_id) + except discord.HTTPException as e: + if e.code == 10008: + log.trace(f"Message {msg_id} don't exist, can't unpin.") + else: + log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") + else: + log.trace(f"Unpinned message {msg_id}.") + async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" log.trace("Waiting for a dormant channel.") -- cgit v1.2.3 From 9b9a4390111c1a87e0fff87eae134a0745c26345 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:36:14 -0700 Subject: HelpChannels: add more detail to unpin log messages --- bot/cogs/help_channels.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 61e8d4384..e281615c2 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -855,17 +855,21 @@ class HelpChannels(commands.Cog): async def unpin(self, channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" + channel_str = f"#{channel} ({channel.id})" + msg_id = await self.question_messages.pop(channel.id) try: await self.bot.http.unpin_message(channel.id, msg_id) except discord.HTTPException as e: if e.code == 10008: - log.trace(f"Message {msg_id} don't exist, can't unpin.") + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't unpin.") else: - log.warn(f"Got unexpected status {e.code} when unpinning message {msg_id}: {e.text}") + log.exception( + f"Error unpinning message {msg_id} in {channel_str}: {e.status} ({e.code})" + ) else: - log.trace(f"Unpinned message {msg_id}.") + log.trace(f"Unpinned message {msg_id} in {channel_str}.") async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" -- cgit v1.2.3 From 6f31a1141b513dd6031949467e5409df0d6a3181 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 12:44:18 -0700 Subject: HelpChannels: don't unpin message if ID is None Fixes #1082 Fixes BOT-7G --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index e281615c2..5e09e0a88 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -858,6 +858,9 @@ class HelpChannels(commands.Cog): channel_str = f"#{channel} ({channel.id})" msg_id = await self.question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"{channel_str} doesn't have a message pinned.") + return try: await self.bot.http.unpin_message(channel.id, msg_id) -- cgit v1.2.3 From a58b4e121eabeb85aeba5d778064f772f049e21b Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 13:04:31 -0700 Subject: HelpChannels: create a generic function to handle pin errors This can be used for both pinning and unpinning messages. The error handling code was largely similar between them. --- bot/cogs/help_channels.py | 37 ++++++++++++++++++++++++++----------- 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5e09e0a88..b452cc574 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -853,26 +853,41 @@ class HelpChannels(commands.Cog): log.trace(f"Channel #{channel} ({channel_id}) retrieved.") return channel - async def unpin(self, channel: discord.TextChannel) -> None: - """Unpin the initial question message sent in `channel`.""" - channel_str = f"#{channel} ({channel.id})" + async def pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: + """ + Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. - msg_id = await self.question_messages.pop(channel.id) - if msg_id is None: - log.debug(f"{channel_str} doesn't have a message pinned.") - return + Return True if successful and False otherwise. + """ + channel_str = f"#{channel} ({channel.id})" + if pin: + func = self.bot.http.pin_message + verb = "pin" + else: + func = self.bot.http.unpin_message + verb = "unpin" try: - await self.bot.http.unpin_message(channel.id, msg_id) + await func(channel.id, msg_id) except discord.HTTPException as e: if e.code == 10008: - log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't unpin.") + log.debug(f"Message {msg_id} in {channel_str} doesn't exist; can't {verb}.") else: log.exception( - f"Error unpinning message {msg_id} in {channel_str}: {e.status} ({e.code})" + f"Error {verb}ning message {msg_id} in {channel_str}: {e.status} ({e.code})" ) + return False + else: + log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") + return True + + async def unpin(self, channel: discord.TextChannel) -> None: + """Unpin the initial question message sent in `channel`.""" + msg_id = await self.question_messages.pop(channel.id) + if msg_id is None: + log.debug(f"#{channel} ({channel.id}) doesn't have a message pinned.") else: - log.trace(f"Unpinned message {msg_id} in {channel_str}.") + await self.pin_wrapper(msg_id, channel, pin=False) async def wait_for_dormant_channel(self) -> discord.TextChannel: """Wait for a dormant channel to become available in the queue and return it.""" -- cgit v1.2.3 From 4b7f19287c3d212a55276b0862f6a629269eaf92 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 13:09:20 -0700 Subject: HelpChannels: create separate function to pin a message --- bot/cogs/help_channels.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index b452cc574..d826463af 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -694,15 +694,8 @@ class HelpChannels(commands.Cog): log.info(f"Channel #{channel} was claimed by `{message.author.id}`.") await self.move_to_in_use(channel) await self.revoke_send_permissions(message.author) - # Pin message for better access and store this to cache - try: - await message.pin() - except discord.NotFound: - log.info(f"Pinning message {message.id} ({channel}) failed because message got deleted.") - except discord.HTTPException as e: - log.info(f"Pinning message {message.id} ({channel.id}) failed with code {e.code}", exc_info=e) - else: - await self.question_messages.set(channel.id, message.id) + + await self.pin(message) # Add user with channel for dormant check. await self.help_channel_claimants.set(channel.id, message.author.id) @@ -881,6 +874,11 @@ class HelpChannels(commands.Cog): log.trace(f"{verb.capitalize()}ned message {msg_id} in {channel_str}.") return True + async def pin(self, message: discord.Message) -> None: + """Pin an initial question `message` and store it in a cache.""" + if await self.pin_wrapper(message.id, message.channel, pin=True): + await self.question_messages.set(message.channel.id, message.id) + async def unpin(self, channel: discord.TextChannel) -> None: """Unpin the initial question message sent in `channel`.""" msg_id = await self.question_messages.pop(channel.id) -- cgit v1.2.3 From 2e46838aa561d93f70351d08ea275fd0c8b95de2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 13:50:19 -0700 Subject: HelpChannels: more accurate empty check The bot's pin message was being picked up as the last message, so the system was not considering the channel empty. --- bot/cogs/help_channels.py | 18 +++++++++++++++--- config-default.yml | 4 ++-- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index d826463af..5ecf40e54 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -737,9 +737,21 @@ class HelpChannels(commands.Cog): 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`.""" - msg = await self.get_last_message(channel) - return self.match_bot_embed(msg, AVAILABLE_MSG) + """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + found = False + + # A limit of 100 results in a single API call. + # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. + # Not gonna do an extensive search for it cause it's too expensive. + async for msg in channel.history(limit=100): + if not msg.author.bot: + return False + + if self.match_bot_embed(msg, AVAILABLE_MSG): + found = True + break + + return found async def check_cooldowns(self) -> None: """Remove expired cooldowns and re-schedule active ones.""" diff --git a/config-default.yml b/config-default.yml index aacbe170f..4bd90511c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -432,8 +432,8 @@ help_channels: # Allowed duration of inactivity before making a channel dormant idle_minutes: 30 - # Allowed duration of inactivity when question message deleted - # and no one other sent before message making channel dormant. + # Allowed duration of inactivity when channel is empty (due to deleted messages) + # before message making a channel dormant deleted_idle_minutes: 5 # Maximum number of channels to put in the available category -- cgit v1.2.3 From f2779147f1e3c99436c1437c9b405479e498c17f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 13:57:14 -0700 Subject: HelpChannels: add logging to is_empty --- bot/cogs/help_channels.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 5ecf40e54..a13207d20 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -738,6 +738,7 @@ class HelpChannels(commands.Cog): async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" + log.trace(f"Checking if #{channel} ({channel.id}) is empty.") found = False # A limit of 100 results in a single API call. @@ -745,9 +746,11 @@ class HelpChannels(commands.Cog): # Not gonna do an extensive search for it cause it's too expensive. async for msg in channel.history(limit=100): if not msg.author.bot: + log.trace(f"#{channel} ({channel.id}) has a non-bot message.") return False if self.match_bot_embed(msg, AVAILABLE_MSG): + log.trace(f"#{channel} ({channel.id}) has the available message embed.") found = True break -- cgit v1.2.3 From 3e5558a8ccf79dfeb3efbb63d48d807ba67c8377 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 17:31:50 -0700 Subject: Cancel scheduled tasks when cogs unload When cogs reload, they used new Scheduler instances, which aren't aware of previously scheduled tasks. This led to duplicate scheduled tasks when cogs re-scheduled tasks upon initialisation. Fixes #1080 Fixes BOT-7H --- bot/cogs/filtering.py | 4 ++++ bot/cogs/moderation/scheduler.py | 4 ++++ bot/cogs/moderation/silence.py | 3 ++- bot/cogs/reminders.py | 4 ++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 64afd184d..4ec95ad73 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -99,6 +99,10 @@ class Filtering(Cog): self.bot.loop.create_task(self.reschedule_offensive_msg_deletion()) + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + def _get_filterlist_items(self, list_type: str, *, allowed: bool) -> list: """Fetch items from the filter_list_cache.""" return self.bot.filter_list_cache[f"{list_type.upper()}.{allowed}"].keys() diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 601e238c9..75028d851 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -31,6 +31,10 @@ class InfractionScheduler: self.bot.loop.create_task(self.reschedule_infractions(supported_infractions)) + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + @property def mod_log(self) -> ModLog: """Get the currently loaded ModLog cog instance.""" diff --git a/bot/cogs/moderation/silence.py b/bot/cogs/moderation/silence.py index ae4fb7b64..f8a6592bc 100644 --- a/bot/cogs/moderation/silence.py +++ b/bot/cogs/moderation/silence.py @@ -152,7 +152,8 @@ class Silence(commands.Cog): return False def cog_unload(self) -> None: - """Send alert with silenced channels on unload.""" + """Send alert with silenced channels and cancel scheduled tasks on unload.""" + self.scheduler.cancel_all() if self.muted_channels: channels_string = ''.join(channel.mention for channel in self.muted_channels) message = f"<@&{Roles.moderators}> channels left silenced on cog unload: {channels_string}" diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index b5998cc0e..670493bcf 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -37,6 +37,10 @@ class Reminders(Cog): self.bot.loop.create_task(self.reschedule_reminders()) + def cog_unload(self) -> None: + """Cancel scheduled tasks.""" + self.scheduler.cancel_all() + async def reschedule_reminders(self) -> None: """Get all current reminders from the API and reschedule them.""" await self.bot.wait_until_guild_available() -- cgit v1.2.3 From 61400aabf6d6d30d09f16e91eb43894fa2b56ff7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 17:48:11 -0700 Subject: Source: raise BadArgument for dynamically-created objects Commands, cogs, etc. created via internal eval won't have a source file associated with them, making source retrieval impossible. Fixes #1083 Fixes BOT-7K --- bot/cogs/source.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index f1db745cd..89548613d 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -60,7 +60,11 @@ class BotSource(commands.Cog): 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.""" + """ + Build GitHub link of source item, return this link, file location and first line number. + + Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). + """ if isinstance(source_item, commands.HelpCommand): src = type(source_item) filename = inspect.getsourcefile(src) @@ -78,10 +82,17 @@ class BotSource(commands.Cog): filename = tags_cog._cache[source_item]["location"] else: src = type(source_item) - filename = inspect.getsourcefile(src) + try: + filename = inspect.getsourcefile(src) + except TypeError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") if not isinstance(source_item, str): - lines, first_line_no = inspect.getsourcelines(src) + try: + lines, first_line_no = inspect.getsourcelines(src) + except OSError: + raise commands.BadArgument("Cannot get source for a dynamically-created object.") + lines_extension = f"#L{first_line_no}-L{first_line_no+len(lines)-1}" else: first_line_no = None -- cgit v1.2.3 From bcb8f27cba8d1413d302d11e38d122f915f96e14 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 17:52:16 -0700 Subject: Source: remove redundant check for help commands The code is identical to the else block and there's no reason for help commands to have an explicit check. --- bot/cogs/source.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/bot/cogs/source.py b/bot/cogs/source.py index 89548613d..205e0ba81 100644 --- a/bot/cogs/source.py +++ b/bot/cogs/source.py @@ -65,10 +65,7 @@ class BotSource(commands.Cog): Raise BadArgument if `source_item` is a dynamically-created object (e.g. via internal eval). """ - if isinstance(source_item, commands.HelpCommand): - src = type(source_item) - filename = inspect.getsourcefile(src) - elif isinstance(source_item, commands.Command): + if 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("_", " ")) -- cgit v1.2.3 From 59c62162e0e0abad53dfbaad0e197a0fbab2f22f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 4 Aug 2020 18:09:13 -0700 Subject: HelpChannels: use more reliable check for claimed channel Using the channel's category isn't reliable since it may take Discord a while to actually move the channel once it's received a request from the bot. I suppose using redis technically has the same problem, but it should be much faster and less susceptible to lag than Discord. Fixes #1074 --- 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 1be980472..975043df9 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -694,7 +694,7 @@ class HelpChannels(commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - if not self.is_in_category(channel, constants.Categories.help_available): + if await self.help_channel_claimants.contains(channel.id): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." -- cgit v1.2.3 From bcd2ef98ab91a48ba7b8769f626ff7beb14db663 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 5 Aug 2020 15:26:54 +0200 Subject: Redis: remove erroneous `_redis` alias If a RedisCache instance was being accessed before bot has created the `redis_cache` instance, the `_redis` alias was being set to None, causing AttributeErrors in lookups. See: #1090 --- bot/utils/redis_cache.py | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/bot/utils/redis_cache.py b/bot/utils/redis_cache.py index 58cfe1df5..52b689b49 100644 --- a/bot/utils/redis_cache.py +++ b/bot/utils/redis_cache.py @@ -226,7 +226,6 @@ class RedisCache: for attribute in vars(instance).values(): if isinstance(attribute, Bot): self.bot = attribute - self._redis = self.bot.redis_session return self else: error_message = ( @@ -251,7 +250,7 @@ class RedisCache: value = self._value_to_typestring(value) log.trace(f"Setting {key} to {value}.") - await self._redis.hset(self._namespace, key, value) + await self.bot.redis_session.hset(self._namespace, key, value) async def get(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> Optional[RedisValueType]: """Get an item from the Redis cache.""" @@ -259,7 +258,7 @@ class RedisCache: key = self._key_to_typestring(key) log.trace(f"Attempting to retrieve {key}.") - value = await self._redis.hget(self._namespace, key) + value = await self.bot.redis_session.hget(self._namespace, key) if value is None: log.trace(f"Value not found, returning default value {default}") @@ -281,7 +280,7 @@ class RedisCache: key = self._key_to_typestring(key) log.trace(f"Attempting to delete {key}.") - return await self._redis.hdel(self._namespace, key) + return await self.bot.redis_session.hdel(self._namespace, key) async def contains(self, key: RedisKeyType) -> bool: """ @@ -291,7 +290,7 @@ class RedisCache: """ await self._validate_cache() key = self._key_to_typestring(key) - exists = await self._redis.hexists(self._namespace, key) + exists = await self.bot.redis_session.hexists(self._namespace, key) log.trace(f"Testing if {key} exists in the RedisCache - Result is {exists}") return exists @@ -314,7 +313,7 @@ class RedisCache: """ await self._validate_cache() items = self._dict_from_typestring( - await self._redis.hgetall(self._namespace) + await self.bot.redis_session.hgetall(self._namespace) ).items() log.trace(f"Retrieving all key/value pairs from cache, total of {len(items)} items.") @@ -323,7 +322,7 @@ class RedisCache: async def length(self) -> int: """Return the number of items in the Redis cache.""" await self._validate_cache() - number_of_items = await self._redis.hlen(self._namespace) + number_of_items = await self.bot.redis_session.hlen(self._namespace) log.trace(f"Returning length. Result is {number_of_items}.") return number_of_items @@ -335,7 +334,7 @@ class RedisCache: """Deletes the entire hash from the Redis cache.""" await self._validate_cache() log.trace("Clearing the cache of all key/value pairs.") - await self._redis.delete(self._namespace) + await self.bot.redis_session.delete(self._namespace) async def pop(self, key: RedisKeyType, default: Optional[RedisValueType] = None) -> RedisValueType: """Get the item, remove it from the cache, and provide a default if not found.""" @@ -364,7 +363,7 @@ class RedisCache: """ await self._validate_cache() log.trace(f"Updating the cache with the following items:\n{items}") - await self._redis.hmset_dict(self._namespace, self._dict_to_typestring(items)) + await self.bot.redis_session.hmset_dict(self._namespace, self._dict_to_typestring(items)) async def increment(self, key: RedisKeyType, amount: Optional[int, float] = 1) -> None: """ -- cgit v1.2.3 From 5a7ca92cf5d5ae7c7d4aa7ba086237586832af1a Mon Sep 17 00:00:00 2001 From: kwzrd Date: Wed, 5 Aug 2020 17:27:08 +0200 Subject: Revert "HelpChannels: use more reliable check for claimed channel" This reverts commit 59c62162 --- 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 975043df9..1be980472 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -694,7 +694,7 @@ class HelpChannels(commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - if await self.help_channel_claimants.contains(channel.id): + if not self.is_in_category(channel, constants.Categories.help_available): log.debug( f"Message {message.id} will not make #{channel} ({channel.id}) in-use " f"because another message in the channel already triggered that." -- cgit v1.2.3 From 9c76e33fbce15b4c42ca2e3966676bec27cfc2c0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 5 Aug 2020 15:34:58 -0700 Subject: HelpChannels: clear claimant cache when channel goes dormant The claimed channel check in `on_message` relies on the cache being cleared when a channel goes dormant. If it's not cleared, it will think the channel is still in use. --- bot/cogs/help_channels.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 975043df9..5f7bb748c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -215,9 +215,6 @@ class HelpChannels(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): - - # 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. @@ -551,6 +548,7 @@ class HelpChannels(commands.Cog): A caller argument is provided for metrics. """ + await self.help_channel_claimants.delete(channel.id) msg_id = await self.question_messages.pop(channel.id) try: -- cgit v1.2.3 From 3bfb3f09bae0f218a06db5f518496be397ed4b66 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Wed, 5 Aug 2020 22:00:59 -0400 Subject: Guild invite regex: Add support for dashes in the invite code --- bot/utils/regex.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/utils/regex.py b/bot/utils/regex.py index d194f93cb..0d2068f90 100644 --- a/bot/utils/regex.py +++ b/bot/utils/regex.py @@ -7,6 +7,6 @@ INVITE_RE = re.compile( 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"([a-zA-Z0-9\-]+)", # the invite code itself flags=re.IGNORECASE ) -- cgit v1.2.3 From 673daebe463995de9f53361b3294ad5e496be476 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 6 Aug 2020 11:29:08 -0700 Subject: Deps: update discord.py to 1.4.0 It was released on PyPI. No longer need to clone via git. --- Dockerfile | 5 --- Pipfile | 2 +- Pipfile.lock | 123 ++++++++++++++++++++++++----------------------------------- 3 files changed, 51 insertions(+), 79 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0b1674e7a..06a538b2a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,11 +6,6 @@ ENV PIP_NO_CACHE_DIR=false \ PIPENV_IGNORE_VIRTUALENVS=1 \ PIPENV_NOSPIN=1 -RUN apt-get -y update \ - && apt-get install -y \ - git \ - && rm -rf /var/lib/apt/lists/* - # Install pipenv RUN pip install -U pipenv diff --git a/Pipfile b/Pipfile index 4db8a238b..6fff2223e 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 = "0bc15fa130b8f01fe2d67446a2184d474b0d0ba7"} +discord.py = "~=1.4.0" fakeredis = "~=1.4" feedparser = "~=5.2" fuzzywuzzy = "~=0.17" diff --git a/Pipfile.lock b/Pipfile.lock index c8cd96d3d..50ddd478c 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "eab4852974d26bd2c10362540c3e01d34af62446cb4e1915ec9a0bf2bddf4d94" + "sha256": "1905fd7eb15074ddbf04f2177b6cdd65edc4c74cb5fcbf4e6ca08ef649ba8a3c" }, "pipfile-spec": 6, "requires": { @@ -60,11 +60,11 @@ }, "aiormq": { "hashes": [ - "sha256:41a9d4eb17db805f30ed172f3f609fe0c2b16657fb15b1b67df19d251dd93c0d", - "sha256:7c19477a9450824cb79f9949fd238f4148e2c0dca67756a2868863c387209f04" + "sha256:106695a836f19c1af6c46b58e8aac80e00f86c5b3287a3c6483a1ee369cc95c9", + "sha256:9f6dbf6155fe2b7a3d24bf68de97fb812db0fac0a54e96bc1af14ea95078ba7f" ], "markers": "python_version >= '3.6'", - "version": "==3.2.2" + "version": "==3.2.3" }, "alabaster": { "hashes": [ @@ -177,9 +177,22 @@ "index": "pypi", "version": "==4.3.2" }, - "discord-py": { - "git": "https://github.com/Rapptz/discord.py.git", - "ref": "0bc15fa130b8f01fe2d67446a2184d474b0d0ba7" + "discord": { + "hashes": [ + "sha256:9d4debb4a37845543bd4b92cb195bc53a302797333e768e70344222857ff1559", + "sha256:ff6653655e342e7721dfb3f10421345fd852c2a33f2cca912b1c39b3778a9429" + ], + "index": "pypi", + "py": "~=1.4.0", + "version": "==1.0.1" + }, + "discord.py": { + "hashes": [ + "sha256:2b1846bfa382b54f4eace8e437a9f59f185388c5b08749ac0e1bbd98e05bfde5", + "sha256:f3db9531fccc391f51de65cfa46133106a9ba12ff2927aca6c14bffd3b7f17b5" + ], + "markers": "python_full_version >= '3.5.3'", + "version": "==1.4.0" }, "docutils": { "hashes": [ @@ -191,11 +204,11 @@ }, "fakeredis": { "hashes": [ - "sha256:4d170886865a91dbc8b7f8cbd4e5d488f4c5f2f25dfae127f001617bbe9e8f97", - "sha256:647b2593d349d9d4e566c8dadb2e4c71ba35be5bdc4f1f7ac2d565a12a965053" + "sha256:790c85ad0f3b2967aba1f51767021bc59760fcb612159584be018ea7384f7fd2", + "sha256:fdfe06f277092d022c271fcaefdc1f0c8d9bfa8cb15374cae41d66a20bd96d2b" ], "index": "pypi", - "version": "==1.4.1" + "version": "==1.4.2" }, "feedparser": { "hashes": [ @@ -542,11 +555,11 @@ }, "sentry-sdk": { "hashes": [ - "sha256:2de15b13836fa3522815a933bd9c887c77f4868071043349f94f1b896c1bcfb8", - "sha256:38bb09d0277117f76507c8728d9a5156f09a47ac5175bb8072513859d19a593b" + "sha256:21b17d6aa064c0fb703a7c00f77cf6c9c497cf2f83345c28892980a5e742d116", + "sha256:4fc97114c77d005467b9b1a29f042e2bc01923cb683b0ef0bbda46e79fa12532" ], "index": "pypi", - "version": "==0.16.2" + "version": "==0.16.3" }, "six": { "hashes": [ @@ -642,14 +655,6 @@ "index": "pypi", "version": "==3.3.0" }, - "typing-extensions": { - "hashes": [ - "sha256:6e95524d8a547a91e08f404ae485bbb71962de46967e1b71a0cb89af24e761c5", - "sha256:79ee589a3caca649a9bfd2a8de4709837400dfa00b6cc81962a1e6a1815969ae", - "sha256:f8d2bd89d25bc39dabe7d23df520442fa1d8969b82544370e03d88b5a591c392" - ], - "version": "==3.7.4.2" - }, "urllib3": { "hashes": [ "sha256:91056c15fa70756691db97756772bb1eb9678fa585d9184f24534b100dc60f4a", @@ -658,56 +663,28 @@ "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.10" }, - "websockets": { - "hashes": [ - "sha256:0e4fb4de42701340bd2353bb2eee45314651caa6ccee80dbd5f5d5978888fed5", - "sha256:1d3f1bf059d04a4e0eb4985a887d49195e15ebabc42364f4eb564b1d065793f5", - "sha256:20891f0dddade307ffddf593c733a3fdb6b83e6f9eef85908113e628fa5a8308", - "sha256:295359a2cc78736737dd88c343cd0747546b2174b5e1adc223824bcaf3e164cb", - "sha256:2db62a9142e88535038a6bcfea70ef9447696ea77891aebb730a333a51ed559a", - "sha256:3762791ab8b38948f0c4d281c8b2ddfa99b7e510e46bd8dfa942a5fff621068c", - "sha256:3db87421956f1b0779a7564915875ba774295cc86e81bc671631379371af1170", - "sha256:3ef56fcc7b1ff90de46ccd5a687bbd13a3180132268c4254fc0fa44ecf4fc422", - "sha256:4f9f7d28ce1d8f1295717c2c25b732c2bc0645db3215cf757551c392177d7cb8", - "sha256:5c01fd846263a75bc8a2b9542606927cfad57e7282965d96b93c387622487485", - "sha256:5c65d2da8c6bce0fca2528f69f44b2f977e06954c8512a952222cea50dad430f", - "sha256:751a556205d8245ff94aeef23546a1113b1dd4f6e4d102ded66c39b99c2ce6c8", - "sha256:7ff46d441db78241f4c6c27b3868c9ae71473fe03341340d2dfdbe8d79310acc", - "sha256:965889d9f0e2a75edd81a07592d0ced54daa5b0785f57dc429c378edbcffe779", - "sha256:9b248ba3dd8a03b1a10b19efe7d4f7fa41d158fdaa95e2cf65af5a7b95a4f989", - "sha256:9bef37ee224e104a413f0780e29adb3e514a5b698aabe0d969a6ba426b8435d1", - "sha256:c1ec8db4fac31850286b7cd3b9c0e1b944204668b8eb721674916d4e28744092", - "sha256:c8a116feafdb1f84607cb3b14aa1418424ae71fee131642fc568d21423b51824", - "sha256:ce85b06a10fc65e6143518b96d3dca27b081a740bae261c2fb20375801a9d56d", - "sha256:d705f8aeecdf3262379644e4b55107a3b55860eb812b673b28d0fbc347a60c55", - "sha256:e898a0863421650f0bebac8ba40840fc02258ef4714cb7e1fd76b6a6354bda36", - "sha256:f8a7bff6e8664afc4e6c28b983845c5bc14965030e3fb98789734d416af77c4b" - ], - "markers": "python_full_version >= '3.6.1'", - "version": "==8.1" - }, "yarl": { "hashes": [ - "sha256:1707230e1ea48ea06a3e20acb4ce05a38d2465bd9566c21f48f6212a88e47536", - "sha256:1f269e8e6676193a94635399a77c9059e1826fb6265c9204c9e5a8ccd36006e1", - "sha256:2657716c1fc998f5f2675c0ee6ce91282e0da0ea9e4a94b584bb1917e11c1559", - "sha256:431faa6858f0ea323714d8b7b4a7da1db2eeb9403607f0eaa3800ab2c5a4b627", - "sha256:5bbcb195da7de57f4508b7508c33f7593e9516e27732d08b9aad8586c7b8c384", - "sha256:5c82f5b1499342339f22c83b97dbe2b8a09e47163fab86cd934a8dd46620e0fb", - "sha256:5d410f69b4f92c5e1e2a8ffb73337cd8a274388c6975091735795588a538e605", - "sha256:66b4f345e9573e004b1af184bc00431145cf5e089a4dcc1351505c1f5750192c", - "sha256:875b2a741ce0208f3b818008a859ab5d0f461e98a32bbdc6af82231a9e761c55", - "sha256:9a3266b047d15e78bba38c8455bf68b391c040231ca5965ef867f7cbbc60bde5", - "sha256:9a592c4aa642249e9bdaf76897d90feeb08118626b363a6be8788a9b300274b5", - "sha256:a1772068401d425e803999dada29a6babf041786e08be5e79ef63c9ecc4c9575", - "sha256:b065a5c3e050395ae563019253cc6c769a50fd82d7fa92d07476273521d56b7c", - "sha256:b325fefd574ebef50e391a1072d1712a60348ca29c183e1d546c9d87fec2cd32", - "sha256:cf5eb664910d759bbae0b76d060d6e21f8af5098242d66c448bbebaf2a7bfa70", - "sha256:f058b6541477022c7b54db37229f87dacf3b565de4f901ff5a0a78556a174fea", - "sha256:f5cfed0766837303f688196aa7002730d62c5cc802d98c6395ea1feb87252727" + "sha256:040b237f58ff7d800e6e0fd89c8439b841f777dd99b4a9cca04d6935564b9409", + "sha256:17668ec6722b1b7a3a05cc0167659f6c95b436d25a36c2d52db0eca7d3f72593", + "sha256:3a584b28086bc93c888a6c2aa5c92ed1ae20932f078c46509a66dce9ea5533f2", + "sha256:4439be27e4eee76c7632c2427ca5e73703151b22cae23e64adb243a9c2f565d8", + "sha256:48e918b05850fffb070a496d2b5f97fc31d15d94ca33d3d08a4f86e26d4e7c5d", + "sha256:9102b59e8337f9874638fcfc9ac3734a0cfadb100e47d55c20d0dc6087fb4692", + "sha256:9b930776c0ae0c691776f4d2891ebc5362af86f152dd0da463a6614074cb1b02", + "sha256:b3b9ad80f8b68519cc3372a6ca85ae02cc5a8807723ac366b53c0f089db19e4a", + "sha256:bc2f976c0e918659f723401c4f834deb8a8e7798a71be4382e024bcc3f7e23a8", + "sha256:c22c75b5f394f3d47105045ea551e08a3e804dc7e01b37800ca35b58f856c3d6", + "sha256:c52ce2883dc193824989a9b97a76ca86ecd1fa7955b14f87bf367a61b6232511", + "sha256:ce584af5de8830d8701b8979b18fcf450cef9a382b1a3c8ef189bedc408faf1e", + "sha256:da456eeec17fa8aa4594d9a9f27c0b1060b6a75f2419fe0c00609587b2695f4a", + "sha256:db6db0f45d2c63ddb1a9d18d1b9b22f308e52c83638c26b422d520a815c4b3fb", + "sha256:df89642981b94e7db5596818499c4b2219028f2a528c9c37cc1de45bf2fd3a3f", + "sha256:f18d68f2be6bf0e89f1521af2b1bb46e66ab0018faafa81d70f358153170a317", + "sha256:f379b7f83f23fe12823085cd6b906edc49df969eb99757f58ff382349a3303c6" ], "markers": "python_version >= '3.5'", - "version": "==1.5.0" + "version": "==1.5.1" } }, "develop": { @@ -728,11 +705,11 @@ }, "cfgv": { "hashes": [ - "sha256:1ccf53320421aeeb915275a196e23b3b8ae87dea8ac6698b1638001d4a486d53", - "sha256:c8e8f552ffcc6194f4e18dd4f68d9aef0c0d58ae7e7be8c82bee3c5e9edfa513" + "sha256:32e43d604bbe7896fe7c248a9c2276447dbef840feb28fe20494f62af110211d", + "sha256:cf22deb93d4bcf92f345a5c3cd39d3d41d6340adc60c78bbbd6588c384fda6a1" ], "markers": "python_full_version >= '3.6.1'", - "version": "==3.1.0" + "version": "==3.2.0" }, "coverage": { "hashes": [ @@ -968,11 +945,11 @@ }, "virtualenv": { "hashes": [ - "sha256:688a61d7976d82b92f7906c367e83bb4b3f0af96f8f75bfcd3da95608fe8ac6c", - "sha256:8f582a030156282a9ee9d319984b759a232b07f86048c1d6a9e394afa44e78c8" + "sha256:7b54fd606a1b85f83de49ad8d80dbec08e983a2d2f96685045b262ebc7481ee5", + "sha256:8cd7b2a4850b003a11be2fc213e206419efab41115cc14bca20e69654f2ac08e" ], "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", - "version": "==20.0.28" + "version": "==20.0.30" } } } -- cgit v1.2.3 From 806825ec56e13391fecd45ba0e0da6ab365e11ec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 7 Aug 2020 11:08:12 -0700 Subject: HelpChannels: simplify control flow in is_empty --- bot/cogs/help_channels.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index a13207d20..bdfbf3392 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -739,7 +739,6 @@ class HelpChannels(commands.Cog): async def is_empty(self, channel: discord.TextChannel) -> bool: """Return True if there's an AVAILABLE_MSG and the messages leading up are bot messages.""" log.trace(f"Checking if #{channel} ({channel.id}) is empty.") - found = False # A limit of 100 results in a single API call. # If AVAILABLE_MSG isn't found within 100 messages, then assume the channel is not empty. @@ -751,10 +750,9 @@ class HelpChannels(commands.Cog): if self.match_bot_embed(msg, AVAILABLE_MSG): log.trace(f"#{channel} ({channel.id}) has the available message embed.") - found = True - break + return True - return found + return False async def check_cooldowns(self) -> None: """Remove expired cooldowns and re-schedule active ones.""" -- cgit v1.2.3 From 3cd4c92b1e24c8cfdae8c5c68c19607c62cc01ed Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sat, 8 Aug 2020 18:32:47 +0100 Subject: Remove unnecessary edits during pagination --- bot/pagination.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index 94c2d7c0c..bab98cacf 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -313,8 +313,6 @@ class LinePaginator(Paginator): log.debug(f"Got first page reaction - changing to page 1/{len(paginator.pages)}") - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -328,8 +326,6 @@ class LinePaginator(Paginator): log.debug(f"Got last page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] if footer_text: embed.set_footer(text=f"{footer_text} (Page {current_page + 1}/{len(paginator.pages)})") @@ -347,8 +343,6 @@ class LinePaginator(Paginator): current_page -= 1 log.debug(f"Got previous page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] if footer_text: @@ -368,8 +362,6 @@ class LinePaginator(Paginator): current_page += 1 log.debug(f"Got next page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] if footer_text: @@ -532,8 +524,6 @@ class ImagePaginator(Paginator): reaction_type = "next" # Magic happens here, after page and reaction_type is set - embed.description = "" - await message.edit(embed=embed) embed.description = paginator.pages[current_page] image = paginator.images[current_page] -- cgit v1.2.3 From a36e04b70c3090b128ac80221582f140c196b20f Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 10 Aug 2020 01:54:18 +0200 Subject: Remove unused api endpoint config constants. The constants aren't used anywhere in the bot, and are incompatible with the APIClient. --- bot/constants.py | 14 -------------- config-default.yml | 17 ----------------- 2 files changed, 31 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 9d00eac36..6baa04ec5 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -488,22 +488,8 @@ class URLs(metaclass=YAMLGetter): # Site endpoints site: str site_api: str - site_superstarify_api: str - site_logs_api: str site_logs_view: str - site_reminders_api: str - site_reminders_user_api: str site_schema: str - site_settings_api: str - site_tags_api: str - site_user_api: str - site_user_complete_api: str - site_infractions: str - site_infractions_user: str - site_infractions_type: str - site_infractions_by_id: str - site_infractions_user_type_current: str - site_infractions_user_type: str paste_service: str diff --git a/config-default.yml b/config-default.yml index 4bd90511c..e3ba9fb05 100644 --- a/config-default.yml +++ b/config-default.yml @@ -309,24 +309,7 @@ urls: site_staff: &STAFF !JOIN ["staff.", *DOMAIN] site_schema: &SCHEMA "https://" - site_bigbrother_api: !JOIN [*SCHEMA, *API, "/bot/bigbrother"] - site_docs_api: !JOIN [*SCHEMA, *API, "/bot/docs"] - site_superstarify_api: !JOIN [*SCHEMA, *API, "/bot/superstarify"] - site_infractions: !JOIN [*SCHEMA, *API, "/bot/infractions"] - site_infractions_user: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}"] - site_infractions_type: !JOIN [*SCHEMA, *API, "/bot/infractions/type/{infraction_type}"] - site_infractions_by_id: !JOIN [*SCHEMA, *API, "/bot/infractions/id/{infraction_id}"] - site_infractions_user_type_current: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}/current"] - site_infractions_user_type: !JOIN [*SCHEMA, *API, "/bot/infractions/user/{user_id}/{infraction_type}"] - site_logs_api: !JOIN [*SCHEMA, *API, "/bot/logs"] site_logs_view: !JOIN [*SCHEMA, *STAFF, "/bot/logs"] - site_off_topic_names_api: !JOIN [*SCHEMA, *API, "/bot/off-topic-names"] - site_reminders_api: !JOIN [*SCHEMA, *API, "/bot/reminders"] - site_reminders_user_api: !JOIN [*SCHEMA, *API, "/bot/reminders/user"] - site_settings_api: !JOIN [*SCHEMA, *API, "/bot/settings"] - site_tags_api: !JOIN [*SCHEMA, *API, "/bot/tags"] - site_user_api: !JOIN [*SCHEMA, *API, "/bot/users"] - site_user_complete_api: !JOIN [*SCHEMA, *API, "/bot/users/complete"] paste_service: !JOIN [*SCHEMA, *PASTE, "/{key}"] # Snekbox -- cgit v1.2.3 From 573154451ed4d330443e4c340fc46ab24e52f852 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Mon, 10 Aug 2020 01:58:44 +0200 Subject: Reorder site URL constants. --- bot/constants.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 6baa04ec5..d01dcb0fc 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -485,11 +485,13 @@ class URLs(metaclass=YAMLGetter): bot_avatar: str github_bot_repo: str - # Site endpoints + # Base site vars site: str site_api: str - site_logs_view: str site_schema: str + + # Site endpoints + site_logs_view: str paste_service: str -- cgit v1.2.3 From 5c6d19d335fe39af58d9787434b3a1bd64e22839 Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Sun, 9 Aug 2020 20:20:35 -0400 Subject: Create kindling-projects tag --- bot/resources/tags/kindling-projects.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 bot/resources/tags/kindling-projects.md diff --git a/bot/resources/tags/kindling-projects.md b/bot/resources/tags/kindling-projects.md new file mode 100644 index 000000000..54ed8c961 --- /dev/null +++ b/bot/resources/tags/kindling-projects.md @@ -0,0 +1,3 @@ +**Kindling Projects** + +The [Kindling projects page](https://nedbatchelder.com/text/kindling.html) on Ned Batchelder's website contains a list of projects and ideas programmers can tackle to build their skills and knowledge. -- cgit v1.2.3 From 257048446a1e37c1bbdad424f8a8465f0491ca83 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 12 Aug 2020 12:11:47 -0700 Subject: Filtering: ignore errors for duplicate offensive messages The error happens when a filter is triggered by a message edit. Fixes #1099 Fixes BOT-6B --- bot/cogs/filtering.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/bot/cogs/filtering.py b/bot/cogs/filtering.py index 93cc1c655..99b659bff 100644 --- a/bot/cogs/filtering.py +++ b/bot/cogs/filtering.py @@ -11,6 +11,7 @@ from discord import Colour, HTTPException, Member, Message, NotFound, TextChanne from discord.ext.commands import Cog from discord.utils import escape_markdown +from bot.api import ResponseCodeError from bot.bot import Bot from bot.cogs.moderation import ModLog from bot.constants import ( @@ -301,9 +302,16 @@ class Filtering(Cog): 'delete_date': delete_date } - await self.bot.api_client.post('bot/offensive-messages', json=data) - self.schedule_msg_delete(data) - log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") + try: + await self.bot.api_client.post('bot/offensive-messages', json=data) + except ResponseCodeError as e: + if e.status == 400 and "already exists" in e.response_json.get("id", [""])[0]: + log.debug(f"Offensive message {msg.id} already exists.") + else: + log.error(f"Offensive message {msg.id} failed to post: {e}") + else: + self.schedule_msg_delete(data) + log.trace(f"Offensive message {msg.id} will be deleted on {delete_date}") if is_private: channel_str = "via DM" -- cgit v1.2.3 From 601e6824e004ac4886eb6dde5e8d0b933dc389ed Mon Sep 17 00:00:00 2001 From: AtieP <62116490+AtieP@users.noreply.github.com> Date: Thu, 13 Aug 2020 16:22:07 +0200 Subject: Fix typo on the traceback tag See issue #1101 --- bot/resources/tags/traceback.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/resources/tags/traceback.md b/bot/resources/tags/traceback.md index 46ef40aa1..e770fa86d 100644 --- a/bot/resources/tags/traceback.md +++ b/bot/resources/tags/traceback.md @@ -11,7 +11,7 @@ ZeroDivisionError: integer division or modulo by zero ``` The best way to read your traceback is bottom to top. -• Identify the exception raised (e.g. ZeroDivisonError) +• Identify the exception raised (e.g. ZeroDivisionError) • Make note of the line number, and navigate there in your program. • Try to understand why the error occurred. -- cgit v1.2.3 From e374c00e2a846cfd9f8c5468b5c2dab599c1f1e2 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:09:51 +0100 Subject: Add constants for badges --- bot/constants.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index d01dcb0fc..f3db80279 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -268,6 +268,17 @@ class Emojis(metaclass=YAMLGetter): status_idle: str status_dnd: str + badge_staff: str + badge_partner: str + badge_hypesquad: str + badge_bug_hunter: str + badge_hypesquad_bravery: str + badge_hypesquad_brilliance: str + badge_hypesquad_balance: str + badge_early_supporter: str + badge_bug_hunter_level_2: str + badge_verified_bot_developer: str + incident_actioned: str incident_unactioned: str incident_investigating: str -- cgit v1.2.3 From f87db13ee549c64723086948b101292da93934d8 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:13:05 +0100 Subject: Add YAML values for badges --- config-default.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/config-default.yml b/config-default.yml index e3ba9fb05..8c0092e76 100644 --- a/config-default.yml +++ b/config-default.yml @@ -38,6 +38,17 @@ style: status_dnd: "<:status_dnd:470326272082313216>" status_offline: "<:status_offline:470326266537705472>" + badge_staff: "<:discord_staff:743882896498098226>" + badge_partner: "<:partner:743882897131569323>" + badge_hypesquad: "<:hypesquad_events:743882896892362873>" + badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" + badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" + badge_hypesquad_brilliance: "<:hypesquad_brilliance:743882896938631248>" + badge_hypesquad_balance: "<:hypesquad_balance:743882896460480625>" + badge_early_supporter: "<:early_supporter:743882896909140058>" + badge_bug_hunter_level_2: "<:bug_hunter_lvl2:743882896611344505>" + badge_verified_bot_developer: "<:verified_bot_dev:743882897299210310>" + incident_actioned: "<:incident_actioned:719645530128646266>" incident_unactioned: "<:incident_unactioned:719645583245180960>" incident_investigating: "<:incident_investigating:719645658671480924>" -- cgit v1.2.3 From ab133d914e21a298ddd743db578e7d4a2e33120c Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:14:20 +0100 Subject: Add badges & status to user command --- bot/cogs/information.py | 95 ++++++++++++++++++++++++++++++++++--------------- 1 file changed, 67 insertions(+), 28 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8982196d1..34a85a86b 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -4,7 +4,7 @@ import pprint import textwrap from collections import Counter, defaultdict from string import Template -from typing import Any, Mapping, Optional, Union +from typing import Any, Mapping, Optional, Tuple, Union from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel @@ -184,6 +184,18 @@ class Information(Cog): await ctx.send(embed=embed) + @staticmethod + def status_to_emoji(status: Status) -> str: + """Convert a Discord status into the relevant emoji.""" + if status is Status.offline: + return constants.Emojis.status_offline + elif status is Status.dnd: + return constants.Emojis.status_dnd + elif status is Status.idle: + return constants.Emojis.status_idle + else: + return constants.Emojis.status_online + @command(name="user", aliases=["user_info", "member", "member_info"]) async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" @@ -223,41 +235,68 @@ class Information(Cog): if user.nick: name = f"{user.nick} ({name})" + badges = "" + + for badge, is_set in user.public_flags: + if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}")): + badges += emoji + " " + joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - description = [ - textwrap.dedent(f""" - **User Information** - Created: {created} - Profile: {user.mention} - ID: {user.id} - {custom_status} - **Member Information** - Joined: {joined} - Roles: {roles or None} - """).strip() + desktop_status = self.status_to_emoji(user.desktop_status) + web_status = self.status_to_emoji(user.web_status) + mobile_status = self.status_to_emoji(user.mobile_status) + + fields = [ + ( + "User information", + textwrap.dedent(f""" + Created: {created} + Profile: {user.mention} + ID: {user.id} + {custom_status} + """).strip() + ), + ( + "Member information", + textwrap.dedent(f""" + Joined: {joined} + Roles: {roles or None} + """).strip() + ), + ( + "Status", + textwrap.dedent(f""" + Desktop: {desktop_status} + Web: {web_status} + Mobile: {mobile_status} + """).strip() + ) ] # Show more verbose output in moderation channels for infractions and nominations if ctx.channel.id in constants.MODERATION_CHANNELS: - description.append(await self.expanded_user_infraction_counts(user)) - description.append(await self.user_nomination_counts(user)) + fields.append(await self.expanded_user_infraction_counts(user)) + fields.append(await self.user_nomination_counts(user)) else: - description.append(await self.basic_user_infraction_counts(user)) + fields.append(await self.basic_user_infraction_counts(user)) # Let's build the embed now embed = Embed( title=name, - description="\n\n".join(description) + description=badges ) + for field_name, field_content in fields: + embed.add_field(name=field_name, value=field_content, inline=False) + embed.set_thumbnail(url=user.avatar_url_as(static_format="png")) embed.colour = user.top_role.colour if roles else Colour.blurple() return embed - async def basic_user_infraction_counts(self, member: Member) -> str: + async def basic_user_infraction_counts(self, member: Member) -> Tuple[str, str]: """Gets the total and active infraction counts for the given `member`.""" infractions = await self.bot.api_client.get( 'bot/infractions', @@ -270,11 +309,11 @@ class Information(Cog): total_infractions = len(infractions) active_infractions = sum(infraction['active'] for infraction in infractions) - infraction_output = f"**Infractions**\nTotal: {total_infractions}\nActive: {active_infractions}" + infraction_output = f"Total: {total_infractions}\nActive: {active_infractions}" - return infraction_output + return "Infractions", infraction_output - async def expanded_user_infraction_counts(self, member: Member) -> str: + async def expanded_user_infraction_counts(self, member: Member) -> Tuple[str, str]: """ Gets expanded infraction counts for the given `member`. @@ -288,9 +327,9 @@ class Information(Cog): } ) - infraction_output = ["**Infractions**"] + infraction_output = [] if not infractions: - infraction_output.append("This user has never received an infraction.") + infraction_output.append("No infractions") else: # Count infractions split by `type` and `active` status for this user infraction_types = set() @@ -313,9 +352,9 @@ class Information(Cog): infraction_output.append(line) - return "\n".join(infraction_output) + return "Infractions", "\n".join(infraction_output) - async def user_nomination_counts(self, member: Member) -> str: + async def user_nomination_counts(self, member: Member) -> Tuple[str, str]: """Gets the active and historical nomination counts for the given `member`.""" nominations = await self.bot.api_client.get( 'bot/nominations', @@ -324,21 +363,21 @@ class Information(Cog): } ) - output = ["**Nominations**"] + output = [] if not nominations: - output.append("This user has never been nominated.") + output.append("No nominations") else: count = len(nominations) is_currently_nominated = any(nomination["active"] for nomination in nominations) nomination_noun = "nomination" if count == 1 else "nominations" if is_currently_nominated: - output.append(f"This user is **currently** nominated ({count} {nomination_noun} in total).") + output.append(f"This user is **currently** nominated\n({count} {nomination_noun} in total)") else: output.append(f"This user has {count} historical {nomination_noun}, but is currently not nominated.") - return "\n".join(output) + return "Nominations", "\n".join(output) def format_fields(self, mapping: Mapping[str, Any], field_width: Optional[int] = None) -> str: """Format a mapping to be readable to a human.""" -- cgit v1.2.3 From ed4ebbf5f7ee751f87554831e277d270cf36ac40 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:19:04 +0100 Subject: Update tests for user commands --- tests/bot/cogs/test_information.py | 87 ++++++++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 32 deletions(-) diff --git a/tests/bot/cogs/test_information.py b/tests/bot/cogs/test_information.py index 79c0e0ad3..77b0ddf17 100644 --- a/tests/bot/cogs/test_information.py +++ b/tests/bot/cogs/test_information.py @@ -215,10 +215,10 @@ class UserInfractionHelperMethodTests(unittest.TestCase): with self.subTest(method=method, api_response=api_response, expected_lines=expected_lines): self.bot.api_client.get.return_value = api_response - expected_output = "\n".join(default_header + expected_lines) + expected_output = "\n".join(expected_lines) actual_output = asyncio.run(method(self.member)) - self.assertEqual(expected_output, actual_output) + self.assertEqual((default_header, expected_output), actual_output) def test_basic_user_infraction_counts_returns_correct_strings(self): """The method should correctly list both the total and active number of non-hidden infractions.""" @@ -249,7 +249,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): }, ) - header = ["**Infractions**"] + header = "Infractions" self._method_subtests(self.cog.basic_user_infraction_counts, test_values, header) @@ -258,7 +258,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): test_values = ( { "api response": [], - "expected_lines": ["This user has never received an infraction."], + "expected_lines": ["No infractions"], }, # Shows non-hidden inactive infraction as expected { @@ -304,7 +304,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): }, ) - header = ["**Infractions**"] + header = "Infractions" self._method_subtests(self.cog.expanded_user_infraction_counts, test_values, header) @@ -313,15 +313,15 @@ class UserInfractionHelperMethodTests(unittest.TestCase): test_values = ( { "api response": [], - "expected_lines": ["This user has never been nominated."], + "expected_lines": ["No nominations"], }, { "api response": [{'active': True}], - "expected_lines": ["This user is **currently** nominated (1 nomination in total)."], + "expected_lines": ["This user is **currently** nominated", "(1 nomination in total)"], }, { "api response": [{'active': True}, {'active': False}], - "expected_lines": ["This user is **currently** nominated (2 nominations in total)."], + "expected_lines": ["This user is **currently** nominated", "(2 nominations in total)"], }, { "api response": [{'active': False}], @@ -334,7 +334,7 @@ class UserInfractionHelperMethodTests(unittest.TestCase): ) - header = ["**Nominations**"] + header = "Nominations" self._method_subtests(self.cog.user_nomination_counts, test_values, header) @@ -350,7 +350,10 @@ class UserEmbedTests(unittest.TestCase): self.bot.api_client.get = unittest.mock.AsyncMock() self.cog = information.Information(self.bot) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_string_representation_of_user_in_title_if_nick_is_not_available(self): """The embed should use the string representation of the user if they don't have a nick.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -362,7 +365,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Mr. Hemlock") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_nick_in_title_if_available(self): """The embed should use the nick if it's available.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -374,7 +380,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.title, "Cat lover (Mr. Hemlock)") - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_ignores_everyone_role(self): """Created `!user` embeds should not contain mention of the @everyone-role.""" ctx = helpers.MockContext(channel=helpers.MockTextChannel(id=1)) @@ -386,8 +395,8 @@ class UserEmbedTests(unittest.TestCase): embed = asyncio.run(self.cog.create_user_embed(ctx, user)) - self.assertIn("&Admins", embed.description) - self.assertNotIn("&Everyone", embed.description) + self.assertIn("&Admins", embed.fields[1].value) + self.assertNotIn("&Everyone", embed.fields[1].value) @unittest.mock.patch(f"{COG_PATH}.expanded_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @unittest.mock.patch(f"{COG_PATH}.user_nomination_counts", new_callable=unittest.mock.AsyncMock) @@ -398,8 +407,8 @@ class UserEmbedTests(unittest.TestCase): moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - infraction_counts.return_value = "expanded infractions info" - nomination_counts.return_value = "nomination info" + infraction_counts.return_value = ("Infractions", "expanded infractions info") + nomination_counts.return_value = ("Nominations", "nomination info") user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -409,20 +418,19 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual( textwrap.dedent(f""" - **User Information** Created: {"1 year ago"} Profile: {user.mention} ID: {user.id} + """).strip(), + embed.fields[0].value + ) - **Member Information** + self.assertEqual( + textwrap.dedent(f""" Joined: {"1 year ago"} Roles: &Moderators - - expanded infractions info - - nomination info """).strip(), - embed.description + embed.fields[1].value ) @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new_callable=unittest.mock.AsyncMock) @@ -433,7 +441,7 @@ class UserEmbedTests(unittest.TestCase): moderators_role = helpers.MockRole(name='Moderators') moderators_role.colour = 100 - infraction_counts.return_value = "basic infractions info" + infraction_counts.return_value = ("Infractions", "basic infractions info") user = helpers.MockMember(id=314, roles=[moderators_role], top_role=moderators_role) embed = asyncio.run(self.cog.create_user_embed(ctx, user)) @@ -442,21 +450,30 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual( textwrap.dedent(f""" - **User Information** Created: {"1 year ago"} Profile: {user.mention} ID: {user.id} + """).strip(), + embed.fields[0].value + ) - **Member Information** + self.assertEqual( + textwrap.dedent(f""" Joined: {"1 year ago"} Roles: &Moderators - - basic infractions info """).strip(), - embed.description + embed.fields[1].value + ) + + self.assertEqual( + "basic infractions info", + embed.fields[3].value ) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_top_role_colour_when_user_has_roles(self): """The embed should be created with the colour of the top role, if a top role is available.""" ctx = helpers.MockContext() @@ -469,7 +486,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour(moderators_role.colour)) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_blurple_colour_when_user_has_no_roles(self): """The embed should be created with a blurple colour if the user has no assigned roles.""" ctx = helpers.MockContext() @@ -479,7 +499,10 @@ class UserEmbedTests(unittest.TestCase): self.assertEqual(embed.colour, discord.Colour.blurple()) - @unittest.mock.patch(f"{COG_PATH}.basic_user_infraction_counts", new=unittest.mock.AsyncMock(return_value="")) + @unittest.mock.patch( + f"{COG_PATH}.basic_user_infraction_counts", + new=unittest.mock.AsyncMock(return_value=("Infractions", "basic infractions")) + ) def test_create_user_embed_uses_png_format_of_user_avatar_as_thumbnail(self): """The embed thumbnail should be set to the user's avatar in `png` format.""" ctx = helpers.MockContext() -- cgit v1.2.3 From fd403522896eeb5ffdf10eb5fa1dd0616df32486 Mon Sep 17 00:00:00 2001 From: Joseph Banks Date: Fri, 14 Aug 2020 21:52:57 +0100 Subject: Add status information to user command --- bot/cogs/information.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 34a85a86b..8c5806898 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -6,7 +6,7 @@ from collections import Counter, defaultdict from string import Template from typing import Any, Mapping, Optional, Tuple, Union -from discord import ChannelType, Colour, Embed, Guild, Member, Message, Role, Status, utils +from discord import ChannelType, Colour, CustomActivity, Embed, Guild, Member, Message, Role, Status, utils from discord.abc import GuildChannel from discord.ext.commands import BucketType, Cog, Context, Paginator, command, group from discord.utils import escape_markdown @@ -223,13 +223,18 @@ class Information(Cog): # Custom status custom_status = '' for activity in user.activities: - # Check activity.state for None value if user has a custom status set - # This guards against a custom status with an emoji but no text, which will cause - # escape_markdown to raise an exception - # This can be reworked after a move to d.py 1.3.0+, which adds a CustomActivity class - if activity.name == 'Custom Status' and activity.state: - state = escape_markdown(activity.state) - custom_status = f'Status: {state}\n' + if isinstance(activity, CustomActivity): + state = "" + + if activity.name: + state = escape_markdown(activity.name) + + emoji = "" + if activity.emoji: + if not activity.emoji.id: + emoji += activity.emoji.name + " " + + custom_status = f'Status: {emoji}{state}\n' name = str(user) if user.nick: -- cgit v1.2.3 From b7e40706aa152228154ce96f5aa346a9f5fc43db Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 15 Aug 2020 10:22:21 +0200 Subject: Add doc cleanup --- bot/cogs/doc.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 204cffb37..63dcc2c15 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,7 +19,7 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput +from bot.constants import MODERATION_ROLES, RedirectOutput, Emojis from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -28,6 +28,8 @@ from bot.pagination import LinePaginator log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) +DELETE_EMOJI = Emojis.trashcan + # Since Intersphinx is intended to be used with Sphinx, # we need to mock its configuration. SPHINX_MOCK_APP = SimpleNamespace( @@ -66,6 +68,27 @@ FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay +async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message) -> None: + """ + Runs the cleanup for the documentation command. + + Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. + After a 300 second timeout, the reaction will be removed.""" + + await message.add_reaction(DELETE_EMOJI) + + def check(reaction: discord.Reaction, member: discord.Member) -> bool: + """Check the reaction is :trashcan:, the author is original author and messages are the same.""" + return str(reaction) == DELETE_EMOJI and member.id == author.id and reaction.message.id == message.id + + with suppress(NotFound): + try: + await bot.wait_for("reaction_add", check=check, timeout=300) + await message.delete() + except asyncio.TimeoutError: + await message.remove_reaction(DELETE_EMOJI, bot.user) + + def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. @@ -391,7 +414,8 @@ class Doc(commands.Cog): await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: - await ctx.send(embed=doc_embed) + doc_embed = await ctx.send(embed=doc_embed) + await doc_cleanup(self.bot, ctx.author, doc_embed) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 9745f6bdc5d9928cf1cc5d19e3b25da4574d52ec Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 15 Aug 2020 10:55:02 +0200 Subject: Satisfy some of the Azure pipelines' code requirements --- bot/cogs/doc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 63dcc2c15..12ed89004 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,7 +19,7 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import MODERATION_ROLES, RedirectOutput, Emojis +from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator @@ -73,8 +73,8 @@ async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message Runs the cleanup for the documentation command. Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. - After a 300 second timeout, the reaction will be removed.""" - + After a 300 second timeout, the reaction will be removed. + """ await message.add_reaction(DELETE_EMOJI) def check(reaction: discord.Reaction, member: discord.Member) -> bool: -- cgit v1.2.3 From 7cd29c72c0c074680d63740b79b388da95a50de5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 09:55:43 -0700 Subject: Don't patch ctx.message.author in antispam The modification propagated across all code that is using the same `Message` object, including all other `on_message` listeners. This caused weird bugs e.g. the filtering cog thinking the bot authored a message that triggered a filter. Patching only `ctx.author` means the implementation is more fragile. Infraction code must ensure it only retrieves the author via `ctx.author` and not through `ctx.message`. Fixes #1005 Fixes BOT-7D --- bot/cogs/antispam.py | 1 - bot/cogs/moderation/scheduler.py | 6 ++++-- bot/cogs/moderation/utils.py | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index 0bcca578d..bc31cbd95 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -219,7 +219,6 @@ class AntiSpam(Cog): # Get context and make sure the bot becomes the actor of infraction by patching the `author` attributes context = await self.bot.get_context(msg) context.author = self.bot.user - context.message.author = self.bot.user # Since we're going to invoke the tempmute command directly, we need to manually call the converter. dt_remove_role_after = await self.expiration_date_converter.convert(context, f"{remove_role_after}S") diff --git a/bot/cogs/moderation/scheduler.py b/bot/cogs/moderation/scheduler.py index 75028d851..051f6c52c 100644 --- a/bot/cogs/moderation/scheduler.py +++ b/bot/cogs/moderation/scheduler.py @@ -161,6 +161,7 @@ class InfractionScheduler: self.schedule_expiration(infraction) except discord.HTTPException as e: # Accordingly display that applying the infraction failed. + # Don't use ctx.message.author; antispam only patches ctx.author. confirm_msg = ":x: failed to apply" expiry_msg = "" log_content = ctx.author.mention @@ -190,6 +191,7 @@ class InfractionScheduler: await ctx.send(f"{dm_result}{confirm_msg}{infr_message}.") # Send a log message to the mod log. + # Don't use ctx.message.author for the actor; antispam only patches ctx.author. log.trace(f"Sending apply mod log for infraction #{id_}.") await self.mod_log.send_log_message( icon_url=icon, @@ -198,7 +200,7 @@ class InfractionScheduler: thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {user.mention} (`{user.id}`) - Actor: {ctx.message.author}{dm_log_text}{expiry_log_text} + Actor: {ctx.author}{dm_log_text}{expiry_log_text} Reason: {reason} """), content=log_content, @@ -242,7 +244,7 @@ class InfractionScheduler: log_text = await self.deactivate_infraction(response[0], send_log=False) log_text["Member"] = f"{user.mention}(`{user.id}`)" - log_text["Actor"] = str(ctx.message.author) + log_text["Actor"] = str(ctx.author) log_content = None id_ = response[0]['id'] footer = f"ID: {id_}" diff --git a/bot/cogs/moderation/utils.py b/bot/cogs/moderation/utils.py index fb55287b6..f21272102 100644 --- a/bot/cogs/moderation/utils.py +++ b/bot/cogs/moderation/utils.py @@ -70,7 +70,7 @@ async def post_infraction( log.trace(f"Posting {infr_type} infraction for {user} to the API.") payload = { - "actor": ctx.message.author.id, + "actor": ctx.author.id, # Don't use ctx.message.author; antispam only patches ctx.author. "hidden": hidden, "reason": reason, "type": infr_type, -- cgit v1.2.3 From f26deafbebf1d3f6790a165d403e0fb664117939 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 12:23:26 -0700 Subject: Truncate mod log content Discord has a limit of 2000 characters for messages. --- bot/cogs/moderation/modlog.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/moderation/modlog.py b/bot/cogs/moderation/modlog.py index 0a63f57b8..5f30d3744 100644 --- a/bot/cogs/moderation/modlog.py +++ b/bot/cogs/moderation/modlog.py @@ -120,6 +120,10 @@ class ModLog(Cog, name="ModLog"): else: content = "@everyone" + # Truncate content to 2000 characters and append an ellipsis. + if content and len(content) > 2000: + content = content[:2000 - 3] + "..." + channel = self.bot.get_channel(channel_id) log_message = await channel.send( content=content, -- cgit v1.2.3 From 056936eafc927e8770acdc6f70bf2971cca4f4d2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 12:49:26 -0700 Subject: Escape Markdown in reddit post titles Use a Unicode look-alike character to replace square brackets, since they'd otherwise interfere with the Markdown. Fixes #1030 --- bot/cogs/reddit.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index d853ab2ea..5d9e2c20b 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -10,6 +10,7 @@ from aiohttp import BasicAuth, ClientError from discord import Colour, Embed, TextChannel from discord.ext.commands import Cog, Context, group from discord.ext.tasks import loop +from discord.utils import escape_markdown from bot.bot import Bot from bot.constants import Channels, ERROR_REPLIES, Emojis, Reddit as RedditConfig, STAFF_ROLES, Webhooks @@ -187,6 +188,8 @@ class Reddit(Cog): author = data["author"] title = textwrap.shorten(data["title"], width=64, placeholder="...") + # Normal brackets interfere with Markdown. + title = escape_markdown(title).replace("[", "⦋").replace("]", "⦌") link = self.URL + data["permalink"] embed.description += ( -- cgit v1.2.3 From 063ae2baa0be2d698705dbf896a4e14511416788 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 15 Aug 2020 13:21:02 -0700 Subject: Unnominate banned users from the talent pool Fixes #1065 --- bot/cogs/watchchannels/talentpool.py | 54 +++++++++++++++++++++++------------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 89256e92e..002f01399 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -1,8 +1,9 @@ import logging import textwrap from collections import ChainMap +from typing import Union -from discord import Color, Embed, Member +from discord import Color, Embed, Member, User from discord.ext.commands import Cog, Context, group from bot.api import ResponseCodeError @@ -164,25 +165,10 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): Providing a `reason` is required. """ - active_nomination = await self.bot.api_client.get( - self.api_endpoint, - params=ChainMap( - self.api_default_params, - {"user__id": str(user.id)} - ) - ) - - if not active_nomination: + if await self.unwatch(user.id, reason): + await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") + else: await ctx.send(":x: The specified user does not have an active nomination") - return - - [nomination] = active_nomination - await self.bot.api_client.patch( - f"{self.api_endpoint}/{nomination['id']}", - json={'end_reason': reason, 'active': False} - ) - await ctx.send(f":white_check_mark: Messages sent by {user} will no longer be relayed") - self._remove_user(user.id) @nomination_group.group(name='edit', aliases=('e',), invoke_without_command=True) @with_role(*MODERATION_ROLES) @@ -220,6 +206,36 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): await ctx.send(f":white_check_mark: Updated the {field} of the nomination!") + @Cog.listener() + async def on_member_ban(self, guild: Guild, user: Union[User, Member]) -> None: + """Remove `user` from the talent pool after they are banned.""" + await self.unwatch(user.id, "User was banned.") + + async def unwatch(self, user_id: int, reason: str) -> bool: + """End the active nomination of a user with the given reason and return True on success.""" + active_nomination = await self.bot.api_client.get( + self.api_endpoint, + params=ChainMap( + self.api_default_params, + {"user__id": str(user_id)} + ) + ) + + if not active_nomination: + log.debug(f"No active nominate exists for {user_id=}") + return False + + log.info(f"Ending nomination: {user_id=} {reason=}") + + [nomination] = active_nomination + await self.bot.api_client.patch( + f"{self.api_endpoint}/{nomination['id']}", + json={'end_reason': reason, 'active': False} + ) + self._remove_user(user_id) + + return True + def _nomination_to_string(self, nomination_object: dict) -> str: """Creates a string representation of a nomination.""" guild = self.bot.get_guild(Guild.id) -- cgit v1.2.3 From 4df3089d8d03f54cdbd14d7683149ae7931036c1 Mon Sep 17 00:00:00 2001 From: Leon Sandøy Date: Sun, 16 Aug 2020 15:28:23 +0200 Subject: Remove the !ask tag --- bot/resources/tags/ask.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 bot/resources/tags/ask.md diff --git a/bot/resources/tags/ask.md b/bot/resources/tags/ask.md deleted file mode 100644 index e2c2a88f6..000000000 --- a/bot/resources/tags/ask.md +++ /dev/null @@ -1,9 +0,0 @@ -Asking good questions will yield a much higher chance of a quick response: - -• Don't ask to ask your question, just go ahead and tell us your problem. -• Don't ask if anyone is knowledgeable in some area, filtering serves no purpose. -• Try to solve the problem on your own first, we're not going to write code for you. -• Show us the code you've tried and any errors or unexpected results it's giving. -• Be patient while we're helping you. - -You can find a much more detailed explanation [on our website](https://pythondiscord.com/pages/asking-good-questions/). -- cgit v1.2.3 From 0a865004d7e33a6d379f04b121cf3201411c75a3 Mon Sep 17 00:00:00 2001 From: AtieP Date: Sun, 16 Aug 2020 17:43:34 +0200 Subject: Use wait_for_deletion from /bot/utils/messages.py instead of doc_cleanup --- bot/cogs/doc.py | 28 +++------------------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index 12ed89004..a3b1d26a1 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -19,17 +19,16 @@ from sphinx.ext import intersphinx from urllib3.exceptions import ProtocolError from bot.bot import Bot -from bot.constants import Emojis, MODERATION_ROLES, RedirectOutput +from bot.constants import MODERATION_ROLES, RedirectOutput from bot.converters import ValidPythonIdentifier, ValidURL from bot.decorators import with_role from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) logging.getLogger('urllib3').setLevel(logging.WARNING) -DELETE_EMOJI = Emojis.trashcan - # Since Intersphinx is intended to be used with Sphinx, # we need to mock its configuration. SPHINX_MOCK_APP = SimpleNamespace( @@ -68,27 +67,6 @@ FAILED_REQUEST_RETRY_AMOUNT = 3 NOT_FOUND_DELETE_DELAY = RedirectOutput.delete_delay -async def doc_cleanup(bot: Bot, author: discord.Member, message: discord.Message) -> None: - """ - Runs the cleanup for the documentation command. - - Adds a :trashcan: reaction what, when clicked, will delete the documentation embed. - After a 300 second timeout, the reaction will be removed. - """ - await message.add_reaction(DELETE_EMOJI) - - def check(reaction: discord.Reaction, member: discord.Member) -> bool: - """Check the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and member.id == author.id and reaction.message.id == message.id - - with suppress(NotFound): - try: - await bot.wait_for("reaction_add", check=check, timeout=300) - await message.delete() - except asyncio.TimeoutError: - await message.remove_reaction(DELETE_EMOJI, bot.user) - - def async_cache(max_size: int = 128, arg_offset: int = 0) -> Callable: """ LRU cache implementation for coroutines. @@ -415,7 +393,7 @@ class Doc(commands.Cog): await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: doc_embed = await ctx.send(embed=doc_embed) - await doc_cleanup(self.bot, ctx.author, doc_embed) + await wait_for_deletion(doc_embed, (ctx.author.id,), client=self.bot) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From 92094a5f9d4b8cb9693f5e7bd77e69384f25946e Mon Sep 17 00:00:00 2001 From: AtieP Date: Sun, 16 Aug 2020 19:24:27 +0200 Subject: msg rather than doc_embed --- bot/cogs/doc.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/doc.py b/bot/cogs/doc.py index a3b1d26a1..30c793c75 100644 --- a/bot/cogs/doc.py +++ b/bot/cogs/doc.py @@ -392,8 +392,8 @@ class Doc(commands.Cog): await error_message.delete(delay=NOT_FOUND_DELETE_DELAY) await ctx.message.delete(delay=NOT_FOUND_DELETE_DELAY) else: - doc_embed = await ctx.send(embed=doc_embed) - await wait_for_deletion(doc_embed, (ctx.author.id,), client=self.bot) + msg = await ctx.send(embed=doc_embed) + await wait_for_deletion(msg, (ctx.author.id,), client=self.bot) @docs_group.command(name='set', aliases=('s',)) @with_role(*MODERATION_ROLES) -- cgit v1.2.3 From e8bd4f6d2316f351ad2c11b0b4db160939ab6ed5 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 16 Aug 2020 20:59:23 +0100 Subject: Re-align status icons --- bot/cogs/information.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8c5806898..776a0d474 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -273,9 +273,9 @@ class Information(Cog): ( "Status", textwrap.dedent(f""" - Desktop: {desktop_status} - Web: {web_status} - Mobile: {mobile_status} + {desktop_status} Desktop + {web_status} Web + {mobile_status} Mobile """).strip() ) ] -- cgit v1.2.3 From 743a6b434000813425bf6480b9f7788043f6115d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 13:46:17 -0700 Subject: Swap argument order in ChainMaps The defaults should be last to ensure they don't take precedence over explicitly set values. --- bot/cogs/watchchannels/bigbrother.py | 2 +- bot/cogs/watchchannels/talentpool.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 4d27a6333..7aa9cec58 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -131,8 +131,8 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): active_watches = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( + {"user__id": str(user.id)}, self.api_default_params, - {"user__id": str(user.id)} ) ) if active_watches: diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index 002f01399..c5621ae18 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -216,8 +216,8 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): active_nomination = await self.bot.api_client.get( self.api_endpoint, params=ChainMap( + {"user__id": str(user_id)}, self.api_default_params, - {"user__id": str(user_id)} ) ) -- cgit v1.2.3 From bca71687eec90b88e60155679d369b57344a0ddc Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 19 Aug 2020 13:50:21 -0700 Subject: Replace stinky single-item unpacking syntax --- bot/cogs/watchchannels/talentpool.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index c5621ae18..a6df84c23 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -227,7 +227,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): log.info(f"Ending nomination: {user_id=} {reason=}") - [nomination] = active_nomination + nomination = active_nomination[0] await self.bot.api_client.patch( f"{self.api_endpoint}/{nomination['id']}", json={'end_reason': reason, 'active': False} -- cgit v1.2.3 From 574bcac2b3fb43fc74a6c840667cfed408bc4077 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Thu, 20 Aug 2020 13:53:54 +0200 Subject: Restrict reminder methods to authors and admins. Before, any user could modify the reminders of others by the id. This restricts the behaviour to only admins and users can only modify the reminders they authored. --- bot/cogs/reminders.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/bot/cogs/reminders.py b/bot/cogs/reminders.py index 670493bcf..08bce2153 100644 --- a/bot/cogs/reminders.py +++ b/bot/cogs/reminders.py @@ -12,10 +12,10 @@ 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, POSITIVE_REPLIES, STAFF_ROLES +from bot.constants import Guild, Icons, MODERATION_ROLES, POSITIVE_REPLIES, Roles, STAFF_ROLES from bot.converters import Duration from bot.pagination import LinePaginator -from bot.utils.checks import without_role_check +from bot.utils.checks import with_role_check, without_role_check from bot.utils.messages import send_denial from bot.utils.scheduling import Scheduler from bot.utils.time import humanize_delta @@ -396,6 +396,8 @@ class Reminders(Cog): async def edit_reminder(self, ctx: Context, id_: int, payload: dict) -> None: """Edits a reminder with the given payload, then sends a confirmation message.""" + if not await self._can_modify(ctx, id_): + return reminder = await self._edit_reminder(id_, payload) # Parse the reminder expiration back into a datetime @@ -413,6 +415,8 @@ class Reminders(Cog): @remind_group.command("delete", aliases=("remove", "cancel")) async def delete_reminder(self, ctx: Context, id_: int) -> None: """Delete one of your active reminders.""" + if not await self._can_modify(ctx, id_): + return await self._delete_reminder(id_) await self._send_confirmation( ctx, @@ -421,6 +425,24 @@ class Reminders(Cog): delivery_dt=None, ) + async def _can_modify(self, ctx: Context, reminder_id: t.Union[str, int]) -> bool: + """ + Check whether the reminder can be modified by the ctx author. + + The check passes when the user is an admin, or if they created the reminder. + """ + if with_role_check(ctx, Roles.admins): + return True + + api_response = await self.bot.api_client.get(f"bot/reminders/{reminder_id}") + if not api_response["author"] == ctx.author.id: + log.debug(f"{ctx.author} is not the reminder author and does not pass the check.") + await send_denial(ctx, "You can't modify reminders of other users!") + return False + + log.debug(f"{ctx.author} is the reminder author and passes the check.") + return True + def setup(bot: Bot) -> None: """Load the Reminders cog.""" -- cgit v1.2.3 From 47521608d573c97597df7b97bf42b0142f79e98c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:02:40 -0700 Subject: Make client parameter mandatory for wait_for_deletion A client instance is necessary for the core feature of this function. There is no way to obtain it from the other arguments. The previous code was wrong to think `discord.Guild.me` is an equivalent. Fixes #1112 --- bot/cogs/bot.py | 2 +- bot/cogs/snekbox.py | 4 +--- bot/cogs/tags.py | 4 ++-- bot/utils/messages.py | 13 ++++--------- 4 files changed, 8 insertions(+), 15 deletions(-) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 79510739c..70ef407d7 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -337,7 +337,7 @@ class BotCog(Cog, name="Bot"): self.codeblock_message_ids[msg.id] = bot_message.id self.bot.loop.create_task( - wait_for_deletion(bot_message, user_ids=(msg.author.id,), client=self.bot) + wait_for_deletion(bot_message, (msg.author.id,), self.bot) ) else: return diff --git a/bot/cogs/snekbox.py b/bot/cogs/snekbox.py index 52c8b6f88..63e6d7f31 100644 --- a/bot/cogs/snekbox.py +++ b/bot/cogs/snekbox.py @@ -220,9 +220,7 @@ class Snekbox(Cog): 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) - ) + self.bot.loop.create_task(wait_for_deletion(response, (ctx.author.id,), ctx.bot)) log.info(f"{ctx.author}'s job had a return code of {results['returncode']}") return response diff --git a/bot/cogs/tags.py b/bot/cogs/tags.py index 3d76c5c08..d01647312 100644 --- a/bot/cogs/tags.py +++ b/bot/cogs/tags.py @@ -236,7 +236,7 @@ class Tags(Cog): await wait_for_deletion( await ctx.send(embed=Embed.from_dict(tag['embed'])), [ctx.author.id], - client=self.bot + self.bot ) elif founds and len(tag_name) >= 3: await wait_for_deletion( @@ -247,7 +247,7 @@ class Tags(Cog): ) ), [ctx.author.id], - client=self.bot + self.bot ) else: diff --git a/bot/utils/messages.py b/bot/utils/messages.py index 670289941..aa8f17f75 100644 --- a/bot/utils/messages.py +++ b/bot/utils/messages.py @@ -19,25 +19,20 @@ log = logging.getLogger(__name__) async def wait_for_deletion( message: Message, user_ids: Sequence[Snowflake], + client: Client, deletion_emojis: Sequence[str] = (Emojis.trashcan,), timeout: float = 60 * 5, attach_emojis: bool = True, - client: Optional[Client] = None ) -> None: """ Wait for up to `timeout` seconds for a reaction by any of the specified `user_ids` to delete the message. An `attach_emojis` bool may be specified to determine whether to attach the given - `deletion_emojis` to the message in the given `context` - - A `client` instance may be optionally specified, otherwise client will be taken from the - guild of the message. + `deletion_emojis` to the message in the given `context`. """ - if message.guild is None and client is None: + if message.guild is None: raise ValueError("Message must be sent on a guild") - bot = client or message.guild.me - if attach_emojis: for emoji in deletion_emojis: await message.add_reaction(emoji) @@ -51,7 +46,7 @@ async def wait_for_deletion( ) with contextlib.suppress(asyncio.TimeoutError): - await bot.wait_for('reaction_add', check=check, timeout=timeout) + await client.wait_for('reaction_add', check=check, timeout=timeout) await message.delete() -- cgit v1.2.3 From e0438b2f78ffbc22a9d4d391db524563ec9baa18 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:16:18 -0700 Subject: Watchchannels: censor message content if it has a leaked token Fixes #1094 --- bot/cogs/watchchannels/watchchannel.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/bot/cogs/watchchannels/watchchannel.py b/bot/cogs/watchchannels/watchchannel.py index 044077350..a58b604c0 100644 --- a/bot/cogs/watchchannels/watchchannel.py +++ b/bot/cogs/watchchannels/watchchannel.py @@ -15,6 +15,8 @@ from discord.ext.commands import Cog, Context from bot.api import ResponseCodeError from bot.bot import Bot from bot.cogs.moderation import ModLog +from bot.cogs.token_remover import TokenRemover +from bot.cogs.webhook_remover import WEBHOOK_URL_RE from bot.constants import BigBrother as BigBrotherConfig, Guild as GuildConfig, Icons from bot.pagination import LinePaginator from bot.utils import CogABCMeta, messages @@ -226,14 +228,16 @@ class WatchChannel(metaclass=CogABCMeta): await self.send_header(msg) - cleaned_content = msg.clean_content - - if cleaned_content: + if TokenRemover.find_token_in_message(msg) or WEBHOOK_URL_RE.search(msg.content): + cleaned_content = "Content is censored because it contains a bot or webhook token." + elif cleaned_content := msg.clean_content: # Put all non-media URLs in a code block to prevent embeds media_urls = {embed.url for embed in msg.embeds if embed.type in ("image", "video")} for url in URL_RE.findall(cleaned_content): if url not in media_urls: cleaned_content = cleaned_content.replace(url, f"`{url}`") + + if cleaned_content: await self.webhook_send( cleaned_content, username=msg.author.display_name, -- cgit v1.2.3 From c0afea19897ec0b47642bb62e4a426f4ca0c3cc8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 20 Aug 2020 11:18:02 -0700 Subject: Don't send code block help if message has a webhook token --- bot/cogs/bot.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index 79510739c..93f2eae7c 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -9,6 +9,7 @@ from discord.ext.commands import Cog, Context, command, group from bot.bot import Bot from bot.cogs.token_remover import TokenRemover +from bot.cogs.webhook_remover import WEBHOOK_URL_RE from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role from bot.utils.messages import wait_for_deletion @@ -240,6 +241,7 @@ class BotCog(Cog, name="Bot"): and not msg.author.bot and len(msg.content.splitlines()) > 3 and not TokenRemover.find_token_in_message(msg) + and not WEBHOOK_URL_RE.search(msg.content) ) if parse_codeblock: # no token in the msg -- cgit v1.2.3 From 36ccac8272de9e60c1c04db7ab3640fd76af8585 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 20 Aug 2020 22:35:12 +0100 Subject: Disable raw commands --- bot/cogs/information.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 8982196d1..2d87866fb 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -376,7 +376,7 @@ class Information(Cog): return out.rstrip() @cooldown_with_role_bypass(2, 60 * 3, BucketType.member, bypass_roles=constants.STAFF_ROLES) - @group(invoke_without_command=True) + @group(invoke_without_command=True, enabled=False) @in_whitelist(channels=(constants.Channels.bot_commands,), roles=constants.STAFF_ROLES) async def raw(self, ctx: Context, *, message: Message, json: bool = False) -> None: """Shows information about the raw API response.""" @@ -411,7 +411,7 @@ class Information(Cog): for page in paginator.pages: await ctx.send(page) - @raw.command() + @raw.command(enabled=False) async def json(self, ctx: Context, message: Message) -> None: """Shows information about the raw API response in a copy-pasteable Python format.""" await ctx.invoke(self.raw, message=message, json=True) -- cgit v1.2.3 From 59a58db3ca6ba14539b028f3e02ccc4d89ec16a0 Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 22 Aug 2020 18:38:05 +0200 Subject: Use wait_for_deletion from bot/utils/messages.py rather than help_cleanup --- bot/cogs/help.py | 31 +++++-------------------------- 1 file changed, 5 insertions(+), 26 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 3d1d6fd10..76aaf655c 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -1,11 +1,10 @@ import itertools import logging -from asyncio import TimeoutError from collections import namedtuple from contextlib import suppress from typing import List, Union -from discord import Colour, Embed, Member, Message, NotFound, Reaction, User +from discord import Colour, Embed from discord.ext.commands import Bot, Cog, Command, Context, Group, HelpCommand from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process @@ -14,6 +13,7 @@ from bot import constants from bot.constants import Channels, Emojis, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import LinePaginator +from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) @@ -24,27 +24,6 @@ PREFIX = constants.Bot.prefix Category = namedtuple("Category", ["name", "description", "cogs"]) -async def help_cleanup(bot: Bot, author: Member, message: Message) -> None: - """ - Runs the cleanup for the help command. - - Adds the :trashcan: reaction that, when clicked, will delete the help message. - After a 300 second timeout, the reaction will be removed. - """ - def check(reaction: Reaction, user: User) -> bool: - """Checks the reaction is :trashcan:, the author is original author and messages are the same.""" - return str(reaction) == DELETE_EMOJI and user.id == author.id and reaction.message.id == message.id - - await message.add_reaction(DELETE_EMOJI) - - 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): """ Raised when a HelpSession Query doesn't match a command or cog. @@ -206,7 +185,7 @@ class CustomHelpCommand(HelpCommand): """Send help for a single command.""" embed = await self.command_formatting(command) message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) @staticmethod def get_commands_brief_details(commands_: List[Command], return_as_list: bool = False) -> Union[List[str], str]: @@ -245,7 +224,7 @@ class CustomHelpCommand(HelpCommand): embed.description += f"\n**Subcommands:**\n{command_details}" message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) async def send_cog_help(self, cog: Cog) -> None: """Send help for a cog.""" @@ -261,7 +240,7 @@ class CustomHelpCommand(HelpCommand): embed.description += f"\n\n**Commands:**\n{command_details}" message = await self.context.send(embed=embed) - await help_cleanup(self.context.bot, self.context.author, message) + await wait_for_deletion(message, (self.context.author.id,), self.context.bot) @staticmethod def _category_key(command: Command) -> str: -- cgit v1.2.3 From ee4efbb91300890424d1f8ecb1273166e9f0f53a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 13:05:49 -0700 Subject: Define a Command subclass with root alias support A subclass is used because cogs make copies of Command objects. They do this to allow multiple instances of a cog to be used. If the Command class doesn't inherently support the `root_aliases` kwarg, it won't end up being copied when a command gets copied. `Command.__original_kwargs__` could be updated to include the new kwarg. However, updating it and adding the attribute to the command wouldn't be as elegant as passing a `Command` subclass as a `cls` attribute to the `commands.command` decorator. This is because the former requires copying the entire code of the decorator to add the two lines into the nested function (it's a decorator with args, hence the nested function). --- bot/command.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/command.py diff --git a/bot/command.py b/bot/command.py new file mode 100644 index 000000000..92e61d97e --- /dev/null +++ b/bot/command.py @@ -0,0 +1,15 @@ +from discord.ext import commands + + +class Command(commands.Command): + """ + A `discord.ext.commands.Command` subclass which supports root aliases. + + A `root_aliases` keyword argument is added, which is a sequence of alias names that will act as + top-level commands rather than being aliases of the command's group. It's stored as an attribute + also named `root_aliases`. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.root_aliases = kwargs.get("root_aliases", []) -- cgit v1.2.3 From f455a7908a9b07747db6ab89f9c5c53bd5ea2450 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:13:21 -0700 Subject: Bot: add root alias support Override `Bot.add_command` and `Bot.remove_command` to add/remove root aliases for a command (and recursively for any subcommands). This has to happen in `Bot` because there's no reliable way to get the `Bot` instance otherwise. Therefore, overriding the methods in `GroupMixin` unfortunately doesn't work. Otherwise, it'd be possible to avoid recursion by processing each subcommand as it got added. --- bot/bot.py | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/bot/bot.py b/bot/bot.py index 756449293..34254d8e8 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -130,6 +130,26 @@ class Bot(commands.Bot): super().add_cog(cog) log.info(f"Cog loaded: {cog.qualified_name}") + def add_command(self, command: commands.Command) -> None: + """Add `command` as normal and then add its root aliases to the bot.""" + super().add_command(command) + self._add_root_aliases(command) + + def remove_command(self, name: str) -> Optional[commands.Command]: + """ + Remove a command/alias as normal and then remove its root aliases from the bot. + + Individual root aliases cannot be removed by this function. + To remove them, either remove the entire command or manually edit `bot.all_commands`. + """ + command = super().remove_command(name) + if command is None: + # Even if it's a root alias, there's no way to get the Bot instance to remove the alias. + return + + self._remove_root_aliases(command) + return command + def clear(self) -> None: """ Clears the internal state of the bot and recreates the connector and sessions. @@ -235,3 +255,24 @@ class Bot(commands.Bot): scope.set_extra("kwargs", kwargs) log.exception(f"Unhandled exception in {event}.") + + def _add_root_aliases(self, command: commands.Command) -> None: + """Recursively add root aliases for `command` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._add_root_aliases(subcommand) + + for alias in command.root_aliases: + if alias in self.all_commands: + raise commands.CommandRegistrationError(alias, alias_conflict=True) + + self.all_commands[alias] = command + + def _remove_root_aliases(self, command: commands.Command) -> None: + """Recursively remove root aliases for `command` and any of its subcommands.""" + if isinstance(command, commands.Group): + for subcommand in command.commands: + self._remove_root_aliases(subcommand) + + for alias in command.root_aliases: + self.all_commands.pop(alias, None) -- cgit v1.2.3 From 36ec4b31730ac7243fc76fe5140a0ed2e922940f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:20:54 -0700 Subject: Patch d.py decorators to support root aliases To avoid explicitly specifying `cls` everywhere, patch the decorators to set the default value of `cls` to the `Command` subclass which supports root aliases. --- bot/__init__.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/__init__.py b/bot/__init__.py index d63086fe2..3ee70c4e9 100644 --- a/bot/__init__.py +++ b/bot/__init__.py @@ -2,10 +2,14 @@ import asyncio import logging import os import sys +from functools import partial, partialmethod from logging import Logger, handlers from pathlib import Path import coloredlogs +from discord.ext import commands + +from bot.command import Command TRACE_LEVEL = logging.TRACE = 5 logging.addLevelName(TRACE_LEVEL, "TRACE") @@ -66,3 +70,9 @@ logging.getLogger(__name__) # On Windows, the selector event loop is required for aiodns. if os.name == "nt": asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +# Monkey-patch discord.py decorators to use the Command subclass which supports root aliases. +# Must be patched before any cogs are added. +commands.command = partial(commands.command, cls=Command) +commands.GroupMixin.command = partialmethod(commands.GroupMixin.command, cls=Command) -- cgit v1.2.3 From 027ce8c5525187296a9f7bd26b89af9c66200835 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 19:43:15 -0700 Subject: Bot: fix AttributeError for commands which lack root_aliases Even if the `command` decorators are patched, there are still some other internal things that need to be patched. For example, the default help command subclasses the original `Command` type. It's more maintainable to exclude root alias support for these objects than to try to patch everything. --- bot/bot.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/bot.py b/bot/bot.py index 34254d8e8..d25074fd9 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -262,7 +262,7 @@ class Bot(commands.Bot): for subcommand in command.commands: self._add_root_aliases(subcommand) - for alias in command.root_aliases: + for alias in getattr(command, "root_aliases", ()): if alias in self.all_commands: raise commands.CommandRegistrationError(alias, alias_conflict=True) @@ -274,5 +274,5 @@ class Bot(commands.Bot): for subcommand in command.commands: self._remove_root_aliases(subcommand) - for alias in command.root_aliases: + for alias in getattr(command, "root_aliases", ()): self.all_commands.pop(alias, None) -- cgit v1.2.3 From c6a20ef3b7b7afe3013a17042f8f2ca84566d998 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:36:27 -0700 Subject: Replace alias command definitions with root_aliases The fruits of my labour. --- bot/cogs/alias.py | 70 ++---------------------------------- bot/cogs/defcon.py | 4 +-- bot/cogs/extensions.py | 2 +- bot/cogs/site.py | 10 +++--- bot/cogs/watchchannels/bigbrother.py | 4 +-- bot/cogs/watchchannels/talentpool.py | 6 ++-- 6 files changed, 15 insertions(+), 81 deletions(-) diff --git a/bot/cogs/alias.py b/bot/cogs/alias.py index 55c7efe65..c6ba8d6f3 100644 --- a/bot/cogs/alias.py +++ b/bot/cogs/alias.py @@ -3,13 +3,12 @@ import logging from discord import Colour, Embed from discord.ext.commands import ( - Cog, Command, Context, Greedy, + Cog, Command, Context, clean_content, command, group, ) from bot.bot import Bot -from bot.cogs.extensions import Extension -from bot.converters import FetchedMember, TagNameConverter +from bot.converters import TagNameConverter from bot.pagination import LinePaginator log = logging.getLogger(__name__) @@ -51,56 +50,6 @@ class Alias (Cog): ctx, embed, empty=False, max_lines=20 ) - @command(name="resources", aliases=("resource",), hidden=True) - async def site_resources_alias(self, ctx: Context) -> None: - """Alias for invoking site resources.""" - await self.invoke(ctx, "site resources") - - @command(name="tools", hidden=True) - async def site_tools_alias(self, ctx: Context) -> None: - """Alias for invoking site tools.""" - await self.invoke(ctx, "site tools") - - @command(name="watch", hidden=True) - async def bigbrother_watch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother watch [user] [reason].""" - await self.invoke(ctx, "bigbrother watch", user, reason=reason) - - @command(name="unwatch", hidden=True) - async def bigbrother_unwatch_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking bigbrother unwatch [user] [reason].""" - await self.invoke(ctx, "bigbrother unwatch", user, reason=reason) - - @command(name="home", hidden=True) - async def site_home_alias(self, ctx: Context) -> None: - """Alias for invoking site home.""" - await self.invoke(ctx, "site home") - - @command(name="faq", hidden=True) - async def site_faq_alias(self, ctx: Context) -> None: - """Alias for invoking site faq.""" - await self.invoke(ctx, "site faq") - - @command(name="rules", aliases=("rule",), hidden=True) - async def site_rules_alias(self, ctx: Context, rules: Greedy[int], *_: str) -> None: - """Alias for invoking site rules.""" - await self.invoke(ctx, "site rules", *rules) - - @command(name="reload", hidden=True) - async def extensions_reload_alias(self, ctx: Context, *extensions: Extension) -> None: - """Alias for invoking extensions reload [extensions...].""" - await self.invoke(ctx, "extensions reload", *extensions) - - @command(name="defon", hidden=True) - async def defcon_enable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon enable.""" - await self.invoke(ctx, "defcon enable") - - @command(name="defoff", hidden=True) - async def defcon_disable_alias(self, ctx: Context) -> None: - """Alias for invoking defcon disable.""" - await self.invoke(ctx, "defcon disable") - @command(name="exception", hidden=True) async def tags_get_traceback_alias(self, ctx: Context) -> None: """Alias for invoking tags get traceback.""" @@ -132,21 +81,6 @@ class Alias (Cog): """Alias for invoking docs get [symbol].""" await self.invoke(ctx, "docs get", symbol) - @command(name="nominate", hidden=True) - async def nomination_add_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking talentpool add [user] [reason].""" - await self.invoke(ctx, "talentpool add", user, reason=reason) - - @command(name="unnominate", hidden=True) - async def nomination_end_alias(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: - """Alias for invoking nomination end [user] [reason].""" - await self.invoke(ctx, "nomination end", user, reason=reason) - - @command(name="nominees", hidden=True) - async def nominees_alias(self, ctx: Context) -> None: - """Alias for invoking tp watched.""" - await self.invoke(ctx, "talentpool watched") - def setup(bot: Bot) -> None: """Load the Alias cog.""" diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index 4c0ad5914..de0f4545e 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -162,7 +162,7 @@ class Defcon(Cog): self.bot.stats.gauge("defcon.threshold", days) - @defcon_group.command(name='enable', aliases=('on', 'e')) + @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) @with_role(Roles.admins, Roles.owners) async def enable_command(self, ctx: Context) -> None: """ @@ -175,7 +175,7 @@ class Defcon(Cog): await self._defcon_action(ctx, days=0, action=Action.ENABLED) await self.update_channel_topic() - @defcon_group.command(name='disable', aliases=('off', 'd')) + @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) @with_role(Roles.admins, Roles.owners) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" diff --git a/bot/cogs/extensions.py b/bot/cogs/extensions.py index 365f198ff..396e406b0 100644 --- a/bot/cogs/extensions.py +++ b/bot/cogs/extensions.py @@ -107,7 +107,7 @@ class Extensions(commands.Cog): await ctx.send(msg) - @extensions_group.command(name="reload", aliases=("r",)) + @extensions_group.command(name="reload", aliases=("r",), root_aliases=("reload",)) async def reload_command(self, ctx: Context, *extensions: Extension) -> None: r""" Reload extensions given their fully qualified or unqualified names. diff --git a/bot/cogs/site.py b/bot/cogs/site.py index ac29daa1d..2d3a3d9f3 100644 --- a/bot/cogs/site.py +++ b/bot/cogs/site.py @@ -23,7 +23,7 @@ class Site(Cog): """Commands for getting info about our website.""" await ctx.send_help(ctx.command) - @site_group.command(name="home", aliases=("about",)) + @site_group.command(name="home", aliases=("about",), root_aliases=("home",)) async def site_main(self, ctx: Context) -> None: """Info about the website itself.""" url = f"{URLs.site_schema}{URLs.site}/" @@ -40,7 +40,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="resources") + @site_group.command(name="resources", root_aliases=("resources", "resource")) async def site_resources(self, ctx: Context) -> None: """Info about the site's Resources page.""" learning_url = f"{PAGES_URL}/resources" @@ -56,7 +56,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="tools") + @site_group.command(name="tools", root_aliases=("tools",)) async def site_tools(self, ctx: Context) -> None: """Info about the site's Tools page.""" tools_url = f"{PAGES_URL}/resources/tools" @@ -87,7 +87,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(name="faq") + @site_group.command(name="faq", root_aliases=("faq",)) async def site_faq(self, ctx: Context) -> None: """Info about the site's FAQ page.""" url = f"{PAGES_URL}/frequently-asked-questions" @@ -104,7 +104,7 @@ class Site(Cog): await ctx.send(embed=embed) - @site_group.command(aliases=['r', 'rule'], name='rules') + @site_group.command(name="rules", aliases=("r", "rule"), root_aliases=("rules", "rule")) async def site_rules(self, ctx: Context, *rules: int) -> None: """Provides a link to all rules or, if specified, displays specific rule(s).""" rules_embed = Embed(title='Rules', color=Colour.blurple()) diff --git a/bot/cogs/watchchannels/bigbrother.py b/bot/cogs/watchchannels/bigbrother.py index 7aa9cec58..11ab8917a 100644 --- a/bot/cogs/watchchannels/bigbrother.py +++ b/bot/cogs/watchchannels/bigbrother.py @@ -59,7 +59,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): """ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - @bigbrother_group.command(name='watch', aliases=('w',)) + @bigbrother_group.command(name='watch', aliases=('w',), root_aliases=('watch',)) @with_role(*MODERATION_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ @@ -70,7 +70,7 @@ class BigBrother(WatchChannel, Cog, name="Big Brother"): """ await self.apply_watch(ctx, user, reason) - @bigbrother_group.command(name='unwatch', aliases=('uw',)) + @bigbrother_group.command(name='unwatch', aliases=('uw',), root_aliases=('unwatch',)) @with_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """Stop relaying messages by the given `user`.""" diff --git a/bot/cogs/watchchannels/talentpool.py b/bot/cogs/watchchannels/talentpool.py index a6df84c23..76d6fe9bd 100644 --- a/bot/cogs/watchchannels/talentpool.py +++ b/bot/cogs/watchchannels/talentpool.py @@ -37,7 +37,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """Highlights the activity of helper nominees by relaying their messages to the talent pool channel.""" await ctx.send_help(ctx.command) - @nomination_group.command(name='watched', aliases=('all', 'list')) + @nomination_group.command(name='watched', aliases=('all', 'list'), root_aliases=("nominees",)) @with_role(*MODERATION_ROLES) async def watched_command( self, ctx: Context, oldest_first: bool = False, update_cache: bool = True @@ -63,7 +63,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): """ await ctx.invoke(self.watched_command, oldest_first=True, update_cache=update_cache) - @nomination_group.command(name='watch', aliases=('w', 'add', 'a')) + @nomination_group.command(name='watch', aliases=('w', 'add', 'a'), root_aliases=("nominate",)) @with_role(*STAFF_ROLES) async def watch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ @@ -157,7 +157,7 @@ class TalentPool(WatchChannel, Cog, name="Talentpool"): max_size=1000 ) - @nomination_group.command(name='unwatch', aliases=('end', )) + @nomination_group.command(name='unwatch', aliases=('end', ), root_aliases=("unnominate",)) @with_role(*MODERATION_ROLES) async def unwatch_command(self, ctx: Context, user: FetchedMember, *, reason: str) -> None: """ -- cgit v1.2.3 From 520ac0f9871bf6775d76eea753ed2a940704e92d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:44:48 -0700 Subject: Include root aliases in the command name conflict test --- tests/bot/cogs/test_cogs.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/bot/cogs/test_cogs.py b/tests/bot/cogs/test_cogs.py index fdda59a8f..30a04422a 100644 --- a/tests/bot/cogs/test_cogs.py +++ b/tests/bot/cogs/test_cogs.py @@ -53,6 +53,7 @@ class CommandNameTests(unittest.TestCase): """Return a list of all qualified names, including aliases, for the `command`.""" names = [f"{command.full_parent_name} {alias}".strip() for alias in command.aliases] names.append(command.qualified_name) + names += getattr(command, "root_aliases", []) return names -- cgit v1.2.3 From fa92df15a4644d01256edeb440242ae92dc8adf0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 22 Aug 2020 20:52:21 -0700 Subject: Help: include root aliases in output --- bot/cogs/help.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 3d1d6fd10..25ce4ae0f 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -189,7 +189,9 @@ class CustomHelpCommand(HelpCommand): command_details = f"**```{PREFIX}{name} {command.signature}```**\n" # show command aliases - aliases = ", ".join(f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases) + aliases = [f"`{alias}`" if not parent else f"`{parent} {alias}`" for alias in command.aliases] + aliases += [f"`{alias}`" for alias in getattr(command, "root_aliases", ())] + aliases = ", ".join(sorted(aliases)) if aliases: command_details += f"**Can also use:** {aliases}\n\n" -- cgit v1.2.3 From 075110f6300da0525dec0aadb6530409549a02f5 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 23 Aug 2020 14:36:06 +0100 Subject: Address review comments from @kwzrd --- bot/cogs/information.py | 33 ++++++++++++++------------------- 1 file changed, 14 insertions(+), 19 deletions(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index 776a0d474..c9412948a 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -20,6 +20,12 @@ from bot.utils.time import time_since log = logging.getLogger(__name__) +STATUS_EMOTES = { + Status.offline: constants.Emojis.status_offline, + Status.dnd: constants.Emojis.status_dnd, + Status.idle: constants.Emojis.status_idle +} + class Information(Cog): """A cog with commands for generating embeds with server info, such as server stats and user info.""" @@ -184,18 +190,6 @@ class Information(Cog): await ctx.send(embed=embed) - @staticmethod - def status_to_emoji(status: Status) -> str: - """Convert a Discord status into the relevant emoji.""" - if status is Status.offline: - return constants.Emojis.status_offline - elif status is Status.dnd: - return constants.Emojis.status_dnd - elif status is Status.idle: - return constants.Emojis.status_idle - else: - return constants.Emojis.status_online - @command(name="user", aliases=["user_info", "member", "member_info"]) async def user_info(self, ctx: Context, user: Member = None) -> None: """Returns info about a user.""" @@ -231,6 +225,7 @@ class Information(Cog): emoji = "" if activity.emoji: + # Confirm that the emoji is not a custom emoji since we cannot use them. if not activity.emoji.id: emoji += activity.emoji.name + " " @@ -240,18 +235,18 @@ class Information(Cog): if user.nick: name = f"{user.nick} ({name})" - badges = "" + badges = [] for badge, is_set in user.public_flags: - if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}")): - badges += emoji + " " + if is_set and (emoji := getattr(constants.Emojis, f"badge_{badge}", None)): + badges.append(emoji) joined = time_since(user.joined_at, max_units=3) roles = ", ".join(role.mention for role in user.roles[1:]) - desktop_status = self.status_to_emoji(user.desktop_status) - web_status = self.status_to_emoji(user.web_status) - mobile_status = self.status_to_emoji(user.mobile_status) + desktop_status = STATUS_EMOTES.get(user.desktop_status, constants.Emojis.status_online) + web_status = STATUS_EMOTES.get(user.web_status, constants.Emojis.status_online) + mobile_status = STATUS_EMOTES.get(user.mobile_status, constants.Emojis.status_online) fields = [ ( @@ -290,7 +285,7 @@ class Information(Cog): # Let's build the embed now embed = Embed( title=name, - description=badges + description=" ".join(badges) ) for field_name, field_content in fields: -- cgit v1.2.3 From 2448c0530e24cb0aacb733f17dea7a6830fdd98b Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Sun, 23 Aug 2020 15:11:41 +0100 Subject: Don't just exclude custom emoji, include the name of the emote --- bot/cogs/information.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/information.py b/bot/cogs/information.py index c9412948a..3ec6c33af 100644 --- a/bot/cogs/information.py +++ b/bot/cogs/information.py @@ -225,9 +225,11 @@ class Information(Cog): emoji = "" if activity.emoji: - # Confirm that the emoji is not a custom emoji since we cannot use them. + # If an emoji is unicode use the emoji, else write the emote like :abc: if not activity.emoji.id: emoji += activity.emoji.name + " " + else: + emoji += f"`:{activity.emoji.name}:` " custom_status = f'Status: {emoji}{state}\n' -- cgit v1.2.3 From fa28bf6de4dd5c5412b68d4ad448e0b4cb15cfac Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 24 Aug 2020 10:10:38 -0700 Subject: Type check root aliases Just like normal aliases, they should only be tuples or lists. This is likely done by discord.py to prevent accidentally passing a string when only a single alias is desired. --- bot/command.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/bot/command.py b/bot/command.py index 92e61d97e..0fb900f7b 100644 --- a/bot/command.py +++ b/bot/command.py @@ -13,3 +13,6 @@ class Command(commands.Command): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.root_aliases = kwargs.get("root_aliases", []) + + if not isinstance(self.root_aliases, (list, tuple)): + raise TypeError("Root aliases of a command must be a list or a tuple of strings.") -- cgit v1.2.3 From 474d78704d852eec106df8d6f64783d0216f4b7f Mon Sep 17 00:00:00 2001 From: Boris Muratov Date: Wed, 26 Aug 2020 02:42:20 +0300 Subject: Bold link to asking guide in embeds --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 57094751e..541c6f336 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -36,7 +36,7 @@ the **Help: Dormant** category. 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}). +check out our guide on [**asking good questions**]({ASKING_GUIDE_URL}). """ DORMANT_MSG = f""" @@ -47,7 +47,7 @@ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ **Help: Available** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for [asking a good question]({ASKING_GUIDE_URL}). +through our guide for [**asking a good question**]({ASKING_GUIDE_URL}). """ CoroutineFunc = t.Callable[..., t.Coroutine] -- cgit v1.2.3 From 30cbde7a7c48e59a19b5a7f1934d0e7674473d62 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 27 Aug 2020 12:26:27 -0700 Subject: AntiSpam: ignore custom emojis in code blocks In code blocks, custom emojis render as text rather than as images. Therefore, they probably aren't being spammed and should be ignored. Fix #1130 --- bot/rules/discord_emojis.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/rules/discord_emojis.py b/bot/rules/discord_emojis.py index 5bab514f2..6e47f0197 100644 --- a/bot/rules/discord_emojis.py +++ b/bot/rules/discord_emojis.py @@ -5,6 +5,7 @@ from discord import Member, Message DISCORD_EMOJI_RE = re.compile(r"<:\w+:\d+>") +CODE_BLOCK_RE = re.compile(r"```.*?```", flags=re.DOTALL) async def apply( @@ -17,8 +18,9 @@ async def apply( if msg.author == last_message.author ) + # Get rid of code blocks in the message before searching for emojis. total_emojis = sum( - len(DISCORD_EMOJI_RE.findall(msg.content)) + len(DISCORD_EMOJI_RE.findall(CODE_BLOCK_RE.sub("", msg.content))) for msg in relevant_messages ) -- cgit v1.2.3 From 7016124192f3228145195765b1c94535700e54aa Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Thu, 27 Aug 2020 23:14:38 +0100 Subject: Update Discord Partner badge --- config-default.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config-default.yml b/config-default.yml index 8c0092e76..b4dc34e85 100644 --- a/config-default.yml +++ b/config-default.yml @@ -39,7 +39,7 @@ style: status_offline: "<:status_offline:470326266537705472>" badge_staff: "<:discord_staff:743882896498098226>" - badge_partner: "<:partner:743882897131569323>" + badge_partner: "<:partner:748666453242413136>" badge_hypesquad: "<:hypesquad_events:743882896892362873>" badge_bug_hunter: "<:bug_hunter_lvl1:743882896372269137>" badge_hypesquad_bravery: "<:hypesquad_bravery:743882896745693335>" -- cgit v1.2.3 From 2fcf07fd041fa58beca52cfa33540343b54e85fd Mon Sep 17 00:00:00 2001 From: AtieP Date: Sat, 29 Aug 2020 09:10:45 +0200 Subject: Remove unused variables and imports --- bot/cogs/help.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/cogs/help.py b/bot/cogs/help.py index 76aaf655c..6caa211a6 100644 --- a/bot/cogs/help.py +++ b/bot/cogs/help.py @@ -10,7 +10,7 @@ from fuzzywuzzy import fuzz, process from fuzzywuzzy.utils import full_process from bot import constants -from bot.constants import Channels, Emojis, STAFF_ROLES +from bot.constants import Channels, STAFF_ROLES from bot.decorators import redirect_output from bot.pagination import LinePaginator from bot.utils.messages import wait_for_deletion @@ -18,7 +18,6 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) COMMANDS_PER_PAGE = 8 -DELETE_EMOJI = Emojis.trashcan PREFIX = constants.Bot.prefix Category = namedtuple("Category", ["name", "description", "cogs"]) -- cgit v1.2.3 From 8b10533851ca3fe3b44dd6662f634ae89550ad16 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Sat, 29 Aug 2020 01:38:04 -0700 Subject: Completely gutted the wolfram command. Moved to seasonalbot/bot/exts/evergreen/wolfram.py --- bot/cogs/wolfram.py | 280 ---------------------------------------------------- bot/constants.py | 8 -- config-default.yml | 7 -- 3 files changed, 295 deletions(-) delete mode 100644 bot/cogs/wolfram.py diff --git a/bot/cogs/wolfram.py b/bot/cogs/wolfram.py deleted file mode 100644 index e6cae3bb8..000000000 --- a/bot/cogs/wolfram.py +++ /dev/null @@ -1,280 +0,0 @@ -import logging -from io import BytesIO -from typing import Callable, List, Optional, Tuple -from urllib import parse - -import discord -from dateutil.relativedelta import relativedelta -from discord import Embed -from discord.ext import commands -from discord.ext.commands import BucketType, Cog, Context, check, group - -from bot.bot import Bot -from bot.constants import Colours, STAFF_ROLES, Wolfram -from bot.pagination import ImagePaginator -from bot.utils.time import humanize_delta - -log = logging.getLogger(__name__) - -APPID = Wolfram.key -DEFAULT_OUTPUT_FORMAT = "JSON" -QUERY = "http://api.wolframalpha.com/v2/{request}?{data}" -WOLF_IMAGE = "https://www.symbols.com/gi.php?type=1&id=2886&i=1" - -MAX_PODS = 20 - -# Allows for 10 wolfram calls pr user pr day -usercd = commands.CooldownMapping.from_cooldown(Wolfram.user_limit_day, 60*60*24, BucketType.user) - -# Allows for max api requests / days in month per day for the entire guild (Temporary) -guildcd = commands.CooldownMapping.from_cooldown(Wolfram.guild_limit_day, 60*60*24, BucketType.guild) - - -async def send_embed( - ctx: Context, - message_txt: str, - colour: int = Colours.soft_red, - footer: str = None, - img_url: str = None, - f: discord.File = None -) -> None: - """Generate & send a response embed with Wolfram as the author.""" - embed = Embed(colour=colour) - embed.description = message_txt - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - if footer: - embed.set_footer(text=footer) - - if img_url: - embed.set_image(url=img_url) - - await ctx.send(embed=embed, file=f) - - -def custom_cooldown(*ignore: List[int]) -> Callable: - """ - Implement per-user and per-guild cooldowns for requests to the Wolfram API. - - A list of roles may be provided to ignore the per-user cooldown - """ - async def predicate(ctx: Context) -> bool: - if ctx.invoked_with == 'help': - # if the invoked command is help we don't want to increase the ratelimits since it's not actually - # invoking the command/making a request, so instead just check if the user/guild are on cooldown. - guild_cooldown = not guildcd.get_bucket(ctx.message).get_tokens() == 0 # if guild is on cooldown - if not any(r.id in ignore for r in ctx.author.roles): # check user bucket if user is not ignored - return guild_cooldown and not usercd.get_bucket(ctx.message).get_tokens() == 0 - return guild_cooldown - - user_bucket = usercd.get_bucket(ctx.message) - - if all(role.id not in ignore for role in ctx.author.roles): - user_rate = user_bucket.update_rate_limit() - - if user_rate: - # Can't use api; cause: member limit - delta = relativedelta(seconds=int(user_rate)) - cooldown = humanize_delta(delta) - message = ( - "You've used up your limit for Wolfram|Alpha requests.\n" - f"Cooldown: {cooldown}" - ) - await send_embed(ctx, message) - return False - - guild_bucket = guildcd.get_bucket(ctx.message) - guild_rate = guild_bucket.update_rate_limit() - - # Repr has a token attribute to read requests left - log.debug(guild_bucket) - - if guild_rate: - # Can't use api; cause: guild limit - message = ( - "The max limit of requests for the server has been reached for today.\n" - f"Cooldown: {int(guild_rate)}" - ) - await send_embed(ctx, message) - return False - - return True - return check(predicate) - - -async def get_pod_pages(ctx: Context, bot: Bot, query: str) -> Optional[List[Tuple]]: - """Get the Wolfram API pod pages for the provided query.""" - async with ctx.channel.typing(): - url_str = parse.urlencode({ - "input": query, - "appid": APPID, - "output": DEFAULT_OUTPUT_FORMAT, - "format": "image,plaintext" - }) - request_url = QUERY.format(request="query", data=url_str) - - async with bot.http_session.get(request_url) as response: - json = await response.json(content_type='text/plain') - - result = json["queryresult"] - - if result["error"]: - # API key not set up correctly - if result["error"]["msg"] == "Invalid appid": - message = "Wolfram API key is invalid or missing." - log.warning( - "API key seems to be missing, or invalid when " - f"processing a wolfram request: {url_str}, Response: {json}" - ) - await send_embed(ctx, message) - return - - message = "Something went wrong internally with your request, please notify staff!" - log.warning(f"Something went wrong getting a response from wolfram: {url_str}, Response: {json}") - await send_embed(ctx, message) - return - - if not result["success"]: - message = f"I couldn't find anything for {query}." - await send_embed(ctx, message) - return - - if not result["numpods"]: - message = "Could not find any results." - await send_embed(ctx, message) - return - - pods = result["pods"] - pages = [] - for pod in pods[:MAX_PODS]: - subs = pod.get("subpods") - - for sub in subs: - title = sub.get("title") or sub.get("plaintext") or sub.get("id", "") - img = sub["img"]["src"] - pages.append((title, img)) - return pages - - -class Wolfram(Cog): - """Commands for interacting with the Wolfram|Alpha API.""" - - def __init__(self, bot: Bot): - self.bot = bot - - @group(name="wolfram", aliases=("wolf", "wa"), invoke_without_command=True) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_command(self, ctx: Context, *, query: str) -> None: - """Requests all answers on a single image, sends an image of all related pods.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="simple", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - image_bytes = await response.read() - - f = discord.File(BytesIO(image_bytes), filename="image.png") - image_url = "attachment://image.png" - - if status == 501: - message = "Failed to get response" - footer = "" - color = Colours.soft_red - elif status == 400: - message = "No input found" - footer = "" - color = Colours.soft_red - elif status == 403: - message = "Wolfram API key is invalid or missing." - footer = "" - color = Colours.soft_red - else: - message = "" - footer = "View original for a bigger picture." - color = Colours.soft_orange - - # Sends a "blank" embed if no request is received, unsure how to fix - await send_embed(ctx, message, color, footer=footer, img_url=image_url, f=f) - - @wolfram_command.command(name="page", aliases=("pa", "p")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_page_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - embed = Embed() - embed.set_author(name="Wolfram Alpha", - icon_url=WOLF_IMAGE, - url="https://www.wolframalpha.com/") - embed.colour = Colours.soft_orange - - await ImagePaginator.paginate(pages, ctx, embed) - - @wolfram_command.command(name="cut", aliases=("c",)) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_cut_command(self, ctx: Context, *, query: str) -> None: - """ - Requests a drawn image of given query. - - Keywords worth noting are, "like curve", "curve", "graph", "pokemon", etc. - """ - pages = await get_pod_pages(ctx, self.bot, query) - - if not pages: - return - - if len(pages) >= 2: - page = pages[1] - else: - page = pages[0] - - await send_embed(ctx, page[0], colour=Colours.soft_orange, img_url=page[1]) - - @wolfram_command.command(name="short", aliases=("sh", "s")) - @custom_cooldown(*STAFF_ROLES) - async def wolfram_short_command(self, ctx: Context, *, query: str) -> None: - """Requests an answer to a simple question.""" - url_str = parse.urlencode({ - "i": query, - "appid": APPID, - }) - query = QUERY.format(request="result", data=url_str) - - # Give feedback that the bot is working. - async with ctx.channel.typing(): - async with self.bot.http_session.get(query) as response: - status = response.status - response_text = await response.text() - - if status == 501: - message = "Failed to get response" - color = Colours.soft_red - elif status == 400: - message = "No input found" - color = Colours.soft_red - elif response_text == "Error 1: Invalid appid": - message = "Wolfram API key is invalid or missing." - color = Colours.soft_red - else: - message = response_text - color = Colours.soft_orange - - await send_embed(ctx, message, color) - - -def setup(bot: Bot) -> None: - """Load the Wolfram cog.""" - bot.add_cog(Wolfram(bot)) diff --git a/bot/constants.py b/bot/constants.py index f3db80279..17fe34e95 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -514,14 +514,6 @@ class Reddit(metaclass=YAMLGetter): secret: Optional[str] -class Wolfram(metaclass=YAMLGetter): - section = "wolfram" - - user_limit_day: int - guild_limit_day: int - key: Optional[str] - - class AntiSpam(metaclass=YAMLGetter): section = 'anti_spam' diff --git a/config-default.yml b/config-default.yml index b4dc34e85..a0f601728 100644 --- a/config-default.yml +++ b/config-default.yml @@ -393,13 +393,6 @@ reddit: secret: !ENV "REDDIT_SECRET" -wolfram: - # Max requests per day. - user_limit_day: 10 - guild_limit_day: 67 - key: !ENV "WOLFRAM_API_KEY" - - big_brother: log_delay: 15 header_message_limit: 15 -- cgit v1.2.3 From 40ad0def564109884c607c78f95c67518d7a70a5 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 11:53:08 -0500 Subject: Everyone Ping: Add rules to default config file --- config-default.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config-default.yml b/config-default.yml index 8c0092e76..3a5918983 100644 --- a/config-default.yml +++ b/config-default.yml @@ -385,6 +385,9 @@ anti_spam: interval: 10 max: 3 + everyone_ping: + enabled: true + reddit: subreddits: -- cgit v1.2.3 From df4ef2e520cd672f0bb46b9d5d09a04647ca2ccf Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:06:19 -0500 Subject: Everyone Ping: Added rule Added the filter rule to the bot/rules folder. --- bot/rules/everyone_ping.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 bot/rules/everyone_ping.py diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py new file mode 100644 index 000000000..29a734478 --- /dev/null +++ b/bot/rules/everyone_ping.py @@ -0,0 +1,31 @@ +from typing import Dict, Iterable, List, Optional, Tuple + +from discord import Member, Message + + +async def apply( + last_message: Message, + recent_messages: List[Message], + config: Dict[str, int], +) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: + """Detects if a user has sent an '@everyone' ping.""" + relevant_messages = tuple( + msg for msg in recent_messages if msg.author == last_message.author + ) + + ev_msgs_ct = 0 + if config["enabled"]: + for msg in relevant_messages: + ev_role = msg.guild.default_role + msg_roles = msg.role_mentions + + if ev_role in msg_roles: + ev_msgs_ct += 1 + + if ev_msgs_ct > 0: + return ( + f"pinged the everyone role {ev_msgs_ct} times", + (last_message.author), + relevant_messages, + ) + return None -- cgit v1.2.3 From 99aa7d55a72fdbf4265820e9d6f70d95132faa8f Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:14:29 -0500 Subject: Everyone Ping: Added rule to recognized rules Added mapping to anti-spam cog, then also edited __init__ in the rules folder to expose the apply function. --- bot/cogs/antispam.py | 3 ++- bot/rules/__init__.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index bc31cbd95..d003f962b 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -34,7 +34,8 @@ RULE_FUNCTION_MAPPING = { 'links': rules.apply_links, 'mentions': rules.apply_mentions, 'newlines': rules.apply_newlines, - 'role_mentions': rules.apply_role_mentions + 'role_mentions': rules.apply_role_mentions, + 'everyone_ping': rules.apply_everyone_ping, } diff --git a/bot/rules/__init__.py b/bot/rules/__init__.py index a01ceae73..8a69cadee 100644 --- a/bot/rules/__init__.py +++ b/bot/rules/__init__.py @@ -10,3 +10,4 @@ from .links import apply as apply_links from .mentions import apply as apply_mentions from .newlines import apply as apply_newlines from .role_mentions import apply as apply_role_mentions +from .everyone_ping import apply as apply_everyone_ping -- cgit v1.2.3 From f873e685e34f1af62f2bc49bc3e37265c327b3ea Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 19:30:34 -0500 Subject: Everyone Ping: Added required values to config The `max` and `interval` values were required, so they were added to the config file and the rule was modified to accept these new values. --- bot/rules/everyone_ping.py | 2 +- config-default.yml | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 29a734478..342727093 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -14,7 +14,7 @@ async def apply( ) ev_msgs_ct = 0 - if config["enabled"]: + if config["max"]: for msg in relevant_messages: ev_role = msg.guild.default_role msg_roles = msg.role_mentions diff --git a/config-default.yml b/config-default.yml index 3a5918983..18d7f4b0e 100644 --- a/config-default.yml +++ b/config-default.yml @@ -386,7 +386,8 @@ anti_spam: max: 3 everyone_ping: - enabled: true + interval: 1 + max: 1 reddit: -- cgit v1.2.3 From c55b7e3749166d06f66193692a7ded5d1317a154 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Fri, 28 Aug 2020 20:21:01 -0500 Subject: Everyone Ping: Fixed rule, edited config Changed the method of checking for an everyone ping. Also changed the config to act as `min pings` instead of `ping enabled/disabled`. --- bot/rules/everyone_ping.py | 16 ++++++---------- config-default.yml | 4 ++-- 2 files changed, 8 insertions(+), 12 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 342727093..bfc400831 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -14,18 +14,14 @@ async def apply( ) ev_msgs_ct = 0 - if config["max"]: - for msg in relevant_messages: - ev_role = msg.guild.default_role - msg_roles = msg.role_mentions + for msg in relevant_messages: + if '@everyone' in msg.content: + ev_msgs_ct += 1 - if ev_role in msg_roles: - ev_msgs_ct += 1 - - if ev_msgs_ct > 0: + if ev_msgs_ct >= config['max']: return ( - f"pinged the everyone role {ev_msgs_ct} times", - (last_message.author), + f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", + (last_message.author,), relevant_messages, ) return None diff --git a/config-default.yml b/config-default.yml index 18d7f4b0e..8546b5310 100644 --- a/config-default.yml +++ b/config-default.yml @@ -386,8 +386,8 @@ anti_spam: max: 3 everyone_ping: - interval: 1 - max: 1 + interval: 10 + max: 0 reddit: -- cgit v1.2.3 From 24002b6b585962bf9218ad643727b30d4ed018dd Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 16:06:19 -0500 Subject: Everyone ping: Send embed on ping, fixed check When a user pings the everyone role, they now get an embed explaining why what they did was wrong. The ping detection was also fixed to not thing that every message was a ping (changed form `>=` to `>`). --- bot/rules/everyone_ping.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index bfc400831..65ee1062c 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,6 +1,14 @@ +import logging +import textwrap from typing import Dict, Iterable, List, Optional, Tuple -from discord import Member, Message +from discord import Embed, Member, Message + +from bot.cogs.moderation.utils import send_private_embed +from bot.constants import Colours + +# For embed sender +log = logging.getLogger(__name__) async def apply( @@ -15,10 +23,28 @@ async def apply( ev_msgs_ct = 0 for msg in relevant_messages: - if '@everyone' in msg.content: + if "@everyone" in msg.content: ev_msgs_ct += 1 - if ev_msgs_ct >= config['max']: + if ev_msgs_ct > config["max"]: + # Send the user an embed giving them more info: + member_count = "{:,}".format(last_message.guild.member_count).split( + "," + )[0] + embed_text = textwrap.dedent( + f""" + Hello {last_message.author.display_name}, please don't try to ping {member_count}k people. + **It will not have good results.** + If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. + """ + ) + print(embed_text) + embed = Embed( + title="Everyone Ping Mute Info", + colour=Colours.soft_red, + description=embed_text, + ) + await send_private_embed(last_message.author, embed) return ( f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", (last_message.author,), -- cgit v1.2.3 From 218e50ce41dea40ec04614db1888cd44db7843b5 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 17:09:24 -0500 Subject: Everyone Ping: Removed debug `print`, spelling Removed a debug `print` statement, fixed a spelling mistake. Also added a comment for the DM string. --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 65ee1062c..b99e75059 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -31,14 +31,14 @@ async def apply( member_count = "{:,}".format(last_message.guild.member_count).split( "," )[0] + # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Hello {last_message.author.display_name}, please don't try to ping {member_count}k people. + Hello {last_message.author.display_name}, please don't try to ping {member_count}K people. **It will not have good results.** If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. """ ) - print(embed_text) embed = Embed( title="Everyone Ping Mute Info", colour=Colours.soft_red, -- cgit v1.2.3 From e42db79c2fd7be4b0c82a5ba4e3f1ca4349745a2 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 18:27:07 -0500 Subject: Everyone Ping: Changed embed text and location The you can view the embed text in the `everyone_ping.py` file. The embed also now sends in the server instead of a DM. --- bot/rules/everyone_ping.py | 20 +++++--------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index b99e75059..47931caae 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -4,7 +4,6 @@ from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.cogs.moderation.utils import send_private_embed from bot.constants import Colours # For embed sender @@ -17,9 +16,7 @@ async def apply( config: Dict[str, int], ) -> Optional[Tuple[str, Iterable[Member], Iterable[Message]]]: """Detects if a user has sent an '@everyone' ping.""" - relevant_messages = tuple( - msg for msg in recent_messages if msg.author == last_message.author - ) + relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author) ev_msgs_ct = 0 for msg in relevant_messages: @@ -28,23 +25,16 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = "{:,}".format(last_message.guild.member_count).split( - "," - )[0] + member_count = "{:,}".format(last_message.guild.member_count).split(",")[0] # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Hello {last_message.author.display_name}, please don't try to ping {member_count}K people. + Please don't try to ping {member_count}K people. **It will not have good results.** - If you want to know what it would be like, imagine pinging Greenland. Please don't ping Greenland. """ ) - embed = Embed( - title="Everyone Ping Mute Info", - colour=Colours.soft_red, - description=embed_text, - ) - await send_private_embed(last_message.author, embed) + embed = Embed(description=embed_text, colour=Colours.soft_red) + await last_message.channel.send(f"Hey {last_message.author.mention}!", embed=embed) return ( f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", (last_message.author,), -- cgit v1.2.3 From dbb05d95420cdd6ff08231ce7b9c67cc46bf3675 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sat, 29 Aug 2020 18:49:02 -0500 Subject: Everyone Ping: Fixed linting error Switched from string.format to f-string for server member count. --- bot/rules/everyone_ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 47931caae..8c1b43628 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -25,7 +25,7 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = "{:,}".format(last_message.guild.member_count).split(",")[0] + member_count = f'{last_message.guild.member_count}'.split(",")[0] # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" -- cgit v1.2.3 From 20f0dfd57f3ed711ef46169b9dcf0e8ee57bcfd1 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 07:32:44 -0500 Subject: Everyone ping: Changed message, cleaned file Changed the message to say the raw member count, not just thousands. Also cleaned up some unused variables and imports in the file. --- bot/rules/everyone_ping.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 8c1b43628..037d7254e 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,4 +1,3 @@ -import logging import textwrap from typing import Dict, Iterable, List, Optional, Tuple @@ -6,9 +5,6 @@ from discord import Embed, Member, Message from bot.constants import Colours -# For embed sender -log = logging.getLogger(__name__) - async def apply( last_message: Message, @@ -25,11 +21,9 @@ async def apply( if ev_msgs_ct > config["max"]: # Send the user an embed giving them more info: - member_count = f'{last_message.guild.member_count}'.split(",")[0] - # Change the `K` to an `M` once the server reaches over 1 million people. embed_text = textwrap.dedent( f""" - Please don't try to ping {member_count}K people. + Please don't try to ping {last_message.guild.member_count} people. **It will not have good results.** """ ) -- cgit v1.2.3 From e7862878bd4233cc9340c00bfb77079c318a0b22 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 07:37:57 -0500 Subject: Everyone ping: added formatting to member count Seperated the member count by commas every three digits. --- bot/rules/everyone_ping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 037d7254e..44e9aade4 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -23,7 +23,7 @@ async def apply( # Send the user an embed giving them more info: embed_text = textwrap.dedent( f""" - Please don't try to ping {last_message.guild.member_count} people. + Please don't try to ping {last_message.guild.member_count:,} people. **It will not have good results.** """ ) -- cgit v1.2.3 From 702ff7e80d859dbc8189e55d1dcf9e3bd5959c7a Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 08:18:52 -0500 Subject: Everyone Ping: PR Review Changed cryptic variable name. Changed ping response to use `bot.constants.NEGATIVE_REPLIES`. Changed ping repsonse to only ping user once. --- bot/rules/everyone_ping.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 44e9aade4..f3790ba2c 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,9 +1,10 @@ +import random import textwrap from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.constants import Colours +from bot.constants import Colours, NEGATIVE_REPLIES async def apply( @@ -14,23 +15,27 @@ async def apply( """Detects if a user has sent an '@everyone' ping.""" relevant_messages = tuple(msg for msg in recent_messages if msg.author == last_message.author) - ev_msgs_ct = 0 + everyone_messages_count = 0 for msg in relevant_messages: if "@everyone" in msg.content: - ev_msgs_ct += 1 + everyone_messages_count += 1 - if ev_msgs_ct > config["max"]: + if everyone_messages_count > config["max"]: # Send the user an embed giving them more info: embed_text = textwrap.dedent( f""" + **{random.choice(NEGATIVE_REPLIES)}** Please don't try to ping {last_message.guild.member_count:,} people. - **It will not have good results.** - """ + """ ) + + # Make embed: embed = Embed(description=embed_text, colour=Colours.soft_red) - await last_message.channel.send(f"Hey {last_message.author.mention}!", embed=embed) + + # Send embed: + await last_message.channel.send(embed=embed) return ( - f"pinged the everyone role {ev_msgs_ct} times in {config['interval']}s", + f"pinged the everyone role {everyone_messages_count} times in {config['interval']}s", (last_message.author,), relevant_messages, ) -- cgit v1.2.3 From 94b89a867942d98138f43ec8d2e6bf8f6607c240 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 09:52:17 -0500 Subject: Everyone Ping: NEGATIVE_REPLIES in title The NEGATIVE_REPLIES header is now the title of the embed. --- bot/rules/everyone_ping.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index f3790ba2c..08415b1e0 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,5 +1,4 @@ import random -import textwrap from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message @@ -22,15 +21,10 @@ async def apply( if everyone_messages_count > config["max"]: # Send the user an embed giving them more info: - embed_text = textwrap.dedent( - f""" - **{random.choice(NEGATIVE_REPLIES)}** - Please don't try to ping {last_message.guild.member_count:,} people. - """ - ) + embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people." # Make embed: - embed = Embed(description=embed_text, colour=Colours.soft_red) + embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red) # Send embed: await last_message.channel.send(embed=embed) -- cgit v1.2.3 From a61d0321c4b7a6e137ccb59d8a3af0428838778c Mon Sep 17 00:00:00 2001 From: Dennis Pham Date: Sun, 30 Aug 2020 17:35:10 -0400 Subject: Allow moderators to use defcon --- bot/cogs/defcon.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/cogs/defcon.py b/bot/cogs/defcon.py index de0f4545e..9087ac454 100644 --- a/bot/cogs/defcon.py +++ b/bot/cogs/defcon.py @@ -10,7 +10,7 @@ from discord.ext.commands import Cog, Context, group from bot.bot import Bot from bot.cogs.moderation import ModLog -from bot.constants import Channels, Colours, Emojis, Event, Icons, Roles +from bot.constants import Channels, Colours, Emojis, Event, Icons, MODERATION_ROLES, Roles from bot.decorators import with_role log = logging.getLogger(__name__) @@ -119,7 +119,7 @@ class Defcon(Cog): ) @group(name='defcon', aliases=('dc',), invoke_without_command=True) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def defcon_group(self, ctx: Context) -> None: """Check the DEFCON status or run a subcommand.""" await ctx.send_help(ctx.command) @@ -163,7 +163,7 @@ class Defcon(Cog): self.bot.stats.gauge("defcon.threshold", days) @defcon_group.command(name='enable', aliases=('on', 'e'), root_aliases=("defon",)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def enable_command(self, ctx: Context) -> None: """ Enable DEFCON mode. Useful in a pinch, but be sure you know what you're doing! @@ -176,7 +176,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='disable', aliases=('off', 'd'), root_aliases=("defoff",)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def disable_command(self, ctx: Context) -> None: """Disable DEFCON mode. Useful in a pinch, but be sure you know what you're doing!""" self.enabled = False @@ -184,7 +184,7 @@ class Defcon(Cog): await self.update_channel_topic() @defcon_group.command(name='status', aliases=('s',)) - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def status_command(self, ctx: Context) -> None: """Check the current status of DEFCON mode.""" embed = Embed( @@ -196,7 +196,7 @@ class Defcon(Cog): await ctx.send(embed=embed) @defcon_group.command(name='days') - @with_role(Roles.admins, Roles.owners) + @with_role(*MODERATION_ROLES) async def days_command(self, ctx: Context, days: int) -> None: """Set how old an account must be to join the server, in days, with DEFCON mode enabled.""" self.days = timedelta(days=days) -- cgit v1.2.3 From 94dcfa584599301f0cfb9e47d4ef9f7f40bdc23c Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Sun, 30 Aug 2020 21:55:39 -0500 Subject: Everyone Ping: PR Review 2 Removed redundant comments. Switched to regex to avoid punishing users for putting `@everyone` in codeblocks. Changed log message since this isn't a anti-spam rule based off of frequency. Added check for `<@&{guild_id}>` ping, also checks for codeblocks. --- bot/rules/everyone_ping.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 08415b1e0..3a8174e44 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -1,9 +1,15 @@ import random +import re from typing import Dict, Iterable, List, Optional, Tuple from discord import Embed, Member, Message -from bot.constants import Colours, NEGATIVE_REPLIES +from bot.constants import Colours, Guild, NEGATIVE_REPLIES + +# Generate regex for checking for pings: +guild_id = Guild.id +EVERYONE_RE_INLINE_CODE = re.compile(rf"(?!`)@everyone(?!`)|(?!`)<@&{guild_id}>(?!`)") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"(?!```\n.*)@everyone(?!\n.*```)|(?!```\n.*)<@&{guild_id}>(?!\n.*```)") async def apply( @@ -16,20 +22,19 @@ async def apply( everyone_messages_count = 0 for msg in relevant_messages: - if "@everyone" in msg.content: + num_everyone_pings_inline = len(re.findall(EVERYONE_RE_INLINE_CODE, msg.content)) + num_everyone_pings_multiline = len(re.findall(EVERYONE_RE_MULTILINE_CODE, msg.content)) + if num_everyone_pings_inline and num_everyone_pings_multiline: everyone_messages_count += 1 if everyone_messages_count > config["max"]: - # Send the user an embed giving them more info: + # Send the channel an embed giving the user more info: embed_text = f"Please don't try to ping {last_message.guild.member_count:,} people." - - # Make embed: embed = Embed(title=random.choice(NEGATIVE_REPLIES), description=embed_text, colour=Colours.soft_red) - - # Send embed: await last_message.channel.send(embed=embed) + return ( - f"pinged the everyone role {everyone_messages_count} times in {config['interval']}s", + "pinged the everyone role", (last_message.author,), relevant_messages, ) -- cgit v1.2.3 From 9c52a99a03777cdfd728f354cdb305398791eac1 Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Mon, 31 Aug 2020 08:17:57 -0500 Subject: Everyone Ping: Regex Fix Changed the regex to not punish users who have text other than `@everyone` in their codeblocks. Multiline codeblocks can now have `@everyone` in them. --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 3a8174e44..560a9ec14 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -8,8 +8,8 @@ from bot.constants import Colours, Guild, NEGATIVE_REPLIES # Generate regex for checking for pings: guild_id = Guild.id -EVERYONE_RE_INLINE_CODE = re.compile(rf"(?!`)@everyone(?!`)|(?!`)<@&{guild_id}>(?!`)") -EVERYONE_RE_MULTILINE_CODE = re.compile(rf"(?!```\n.*)@everyone(?!\n.*```)|(?!```\n.*)<@&{guild_id}>(?!\n.*```)") +EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`)@everyone(?!`)$|^(?!`)<@&{guild_id}>(?!`)$") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```)@everyone(?!```)$|^(?!```)<@&{guild_id}>(?!```)$") async def apply( -- cgit v1.2.3 From 4906406cedaa476b8eb1665bc0e20616c91d7f6b Mon Sep 17 00:00:00 2001 From: MrAwesomeRocks <42477863+MrAwesomeRocks@users.noreply.github.com> Date: Mon, 31 Aug 2020 19:19:57 -0500 Subject: Everyone Ping: Fixed regex to catch *all* pings --- bot/rules/everyone_ping.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/rules/everyone_ping.py b/bot/rules/everyone_ping.py index 560a9ec14..89d9fe570 100644 --- a/bot/rules/everyone_ping.py +++ b/bot/rules/everyone_ping.py @@ -8,8 +8,8 @@ from bot.constants import Colours, Guild, NEGATIVE_REPLIES # Generate regex for checking for pings: guild_id = Guild.id -EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`)@everyone(?!`)$|^(?!`)<@&{guild_id}>(?!`)$") -EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```)@everyone(?!```)$|^(?!```)<@&{guild_id}>(?!```)$") +EVERYONE_RE_INLINE_CODE = re.compile(rf"^(?!`).*@everyone.*(?!`)$|^(?!`).*<@&{guild_id}>.*(?!`)$") +EVERYONE_RE_MULTILINE_CODE = re.compile(rf"^(?!```).*@everyone.*(?!```)$|^(?!```).*<@&{guild_id}>.*(?!```)$") async def apply( -- cgit v1.2.3 From 9bf8e5e394b5f9a8735f235cafe0fd2526be6ab2 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 19:49:38 -0700 Subject: Removed image pagination utility. --- bot/pagination.py | 164 ------------------------------------------------------ 1 file changed, 164 deletions(-) diff --git a/bot/pagination.py b/bot/pagination.py index bab98cacf..182b2fa76 100644 --- a/bot/pagination.py +++ b/bot/pagination.py @@ -374,167 +374,3 @@ class LinePaginator(Paginator): log.debug("Ending pagination and clearing reactions.") with suppress(discord.NotFound): await message.clear_reactions() - - -class ImagePaginator(Paginator): - """ - Helper class that paginates images for embeds in messages. - - Close resemblance to LinePaginator, except focuses on images over text. - - Refer to ImagePaginator.paginate for documentation on how to use. - """ - - def __init__(self, prefix: str = "", suffix: str = ""): - super().__init__(prefix, suffix) - self._current_page = [prefix] - self.images = [] - self._pages = [] - self._count = 0 - - def add_line(self, line: str = '', *, empty: bool = False) -> None: - """Adds a line to each page.""" - if line: - self._count = len(line) - else: - self._count = 0 - self._current_page.append(line) - self.close_page() - - def add_image(self, image: str = None) -> None: - """Adds an image to a page.""" - self.images.append(image) - - @classmethod - async def paginate( - cls, - pages: t.List[t.Tuple[str, str]], - ctx: Context, embed: discord.Embed, - prefix: str = "", - suffix: str = "", - timeout: int = 300, - exception_on_empty_embed: bool = False - ) -> t.Optional[discord.Message]: - """ - Use a paginator and set of reactions to provide pagination over a set of title/image pairs. - - The reactions are used to switch page, or to finish with pagination. - - When used, this will send a message using `ctx.send()` and apply a set of reactions to it. These reactions may - be used to change page, or to remove pagination from the message. - - Note: Pagination will be removed automatically if no reaction is added for five minutes (300 seconds). - - Example: - >>> embed = discord.Embed() - >>> embed.set_author(name="Some Operation", url=url, icon_url=icon) - >>> await ImagePaginator.paginate(pages, ctx, embed) - """ - def check_event(reaction_: discord.Reaction, member: discord.Member) -> bool: - """Checks each reaction added, if it matches our conditions pass the wait_for.""" - return all(( - # Reaction is on the same message sent - reaction_.message.id == message.id, - # The reaction is part of the navigation menu - str(reaction_.emoji) in PAGINATION_EMOJI, - # The reactor is not a bot - not member.bot - )) - - paginator = cls(prefix=prefix, suffix=suffix) - current_page = 0 - - if not pages: - if exception_on_empty_embed: - 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") - pages.append(("(no images to display)", "")) - - for text, image_url in pages: - paginator.add_line(text) - paginator.add_image(image_url) - - embed.description = paginator.pages[current_page] - image = paginator.images[current_page] - - if image: - embed.set_image(url=image) - - if len(paginator.pages) <= 1: - return await ctx.send(embed=embed) - - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - message = await ctx.send(embed=embed) - - for emoji in PAGINATION_EMOJI: - await message.add_reaction(emoji) - - while True: - # Start waiting for reactions - try: - reaction, user = await ctx.bot.wait_for("reaction_add", timeout=timeout, check=check_event) - except asyncio.TimeoutError: - log.debug("Timed out waiting for a reaction") - break # We're done, no reactions for the last 5 minutes - - # Deletes the users reaction - await message.remove_reaction(reaction.emoji, user) - - # Delete reaction press - [:trashcan:] - if str(reaction.emoji) == DELETE_EMOJI: - log.debug("Got delete reaction") - return await message.delete() - - # First reaction press - [:track_previous:] - if reaction.emoji == FIRST_EMOJI: - if current_page == 0: - log.debug("Got first page reaction, but we're on the first page - ignoring") - continue - - current_page = 0 - reaction_type = "first" - - # Last reaction press - [:track_next:] - if reaction.emoji == LAST_EMOJI: - if current_page >= len(paginator.pages) - 1: - log.debug("Got last page reaction, but we're on the last page - ignoring") - continue - - current_page = len(paginator.pages) - 1 - reaction_type = "last" - - # Previous reaction press - [:arrow_left: ] - if reaction.emoji == LEFT_EMOJI: - if current_page <= 0: - log.debug("Got previous page reaction, but we're on the first page - ignoring") - continue - - current_page -= 1 - reaction_type = "previous" - - # Next reaction press - [:arrow_right:] - if reaction.emoji == RIGHT_EMOJI: - if current_page >= len(paginator.pages) - 1: - log.debug("Got next page reaction, but we're on the last page - ignoring") - continue - - current_page += 1 - reaction_type = "next" - - # Magic happens here, after page and reaction_type is set - embed.description = paginator.pages[current_page] - - image = paginator.images[current_page] - if image: - embed.set_image(url=image) - - embed.set_footer(text=f"Page {current_page + 1}/{len(paginator.pages)}") - log.debug(f"Got {reaction_type} page reaction - changing to page {current_page + 1}/{len(paginator.pages)}") - - await message.edit(embed=embed) - - log.debug("Ending pagination and clearing reactions.") - with suppress(discord.NotFound): - await message.clear_reactions() -- cgit v1.2.3 From b7644aa822def549e2591b53c69af3cf44355ac9 Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 19:56:24 -0700 Subject: Removed ImagePaginator testing. --- tests/bot/test_pagination.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/tests/bot/test_pagination.py b/tests/bot/test_pagination.py index ce880d457..630f2516d 100644 --- a/tests/bot/test_pagination.py +++ b/tests/bot/test_pagination.py @@ -44,18 +44,3 @@ class LinePaginatorTests(TestCase): 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): - """Tests functionality of the `ImagePaginator`.""" - - def setUp(self): - """Create a paginator for the test method.""" - self.paginator = pagination.ImagePaginator() - - def test_add_image_appends_image(self): - """`add_image` appends the image to the image list.""" - image = 'lemon' - self.paginator.add_image(image) - - assert self.paginator.images == [image] -- cgit v1.2.3 From 10181ab4dc8711c561caca0a2fbc40ff4c4ecf6c Mon Sep 17 00:00:00 2001 From: Xithrius Date: Mon, 31 Aug 2020 20:40:45 -0700 Subject: Removed loading of the Wolfram cog. --- bot/__main__.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index f698b5662..fe2cf90e6 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -74,7 +74,6 @@ bot.load_extension("bot.cogs.token_remover") bot.load_extension("bot.cogs.utils") bot.load_extension("bot.cogs.watchchannels") bot.load_extension("bot.cogs.webhook_remover") -bot.load_extension("bot.cogs.wolfram") if constants.HelpChannels.enable: bot.load_extension("bot.cogs.help_channels") -- cgit v1.2.3 From 03ab17b9383a57591b2f82a0526188efd902f61b Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 1 Sep 2020 11:27:29 +0100 Subject: Added checks to ignore webhook and bot messages --- bot/cogs/antimalware.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/cogs/antimalware.py b/bot/cogs/antimalware.py index c76bd2c60..7894ec48f 100644 --- a/bot/cogs/antimalware.py +++ b/bot/cogs/antimalware.py @@ -55,6 +55,10 @@ class AntiMalware(Cog): if not message.attachments or not message.guild: return + # Ignore webhook and bot messages + if message.webhook_id or message.author.bot: + return + # Check if user is staff, if is, return # Since we only care that roles exist to iterate over, check for the attr rather than a User/Member instance if hasattr(message.author, "roles") and any(role.id in STAFF_ROLES for role in message.author.roles): -- cgit v1.2.3 From 1a47f5d80f2f91c3da5a9626e9a6694381d49cd0 Mon Sep 17 00:00:00 2001 From: wookie184 Date: Tue, 1 Sep 2020 12:22:43 +0100 Subject: Fixed old tests and added 2 new ones --- tests/bot/cogs/test_antimalware.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/bot/cogs/test_antimalware.py b/tests/bot/cogs/test_antimalware.py index ecb7abf00..f50c0492d 100644 --- a/tests/bot/cogs/test_antimalware.py +++ b/tests/bot/cogs/test_antimalware.py @@ -23,6 +23,8 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): } self.cog = antimalware.AntiMalware(self.bot) self.message = MockMessage() + self.message.webhook_id = None + self.message.author.bot = None self.whitelist = [".first", ".second", ".third"] async def test_message_with_allowed_attachment(self): @@ -48,6 +50,26 @@ class AntiMalwareCogTests(unittest.IsolatedAsyncioTestCase): self.message.delete.assert_not_called() + async def test_webhook_message_with_illegal_extension(self): + """A webhook message containing an illegal extension should be ignored.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.webhook_id = 697140105563078727 + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + + async def test_bot_message_with_illegal_extension(self): + """A bot message containing an illegal extension should be ignored.""" + attachment = MockAttachment(filename="python.disallowed") + self.message.author.bot = 409107086526644234 + self.message.attachments = [attachment] + + await self.cog.on_message(self.message) + + self.message.delete.assert_not_called() + async def test_message_with_illegal_extension_gets_deleted(self): """A message containing an illegal extension should send an embed.""" attachment = MockAttachment(filename="python.disallowed") -- cgit v1.2.3 From 1512dcc994dfacd0995a93320efc001550f15212 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Fri, 4 Sep 2020 20:05:03 +0200 Subject: Disable burst_shared filter of the AntiSpam cog Our AntiSpam cog suffers from a race condition that causes it to try and infract the same user multiple times. As that happens frequently with the burst_shared filter, it means that our bot joins in and starts spamming the channel with error messages. Another issue is that burst_shared may cause our bot to send a lot of DMs to a lot of different members. This caused our bot to get a DM ban from Discord after a recent `everyone` ping incident. I've decided to disable the `burst_shared` filter by commenting out the relevant lines but leave the code in place otherwise. This means we still have the implementation handy in case we want to re-enable it on short notice. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/antispam.py | 3 ++- config-default.yml | 10 +++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index d003f962b..b8939113f 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -27,7 +27,8 @@ log = logging.getLogger(__name__) RULE_FUNCTION_MAPPING = { 'attachments': rules.apply_attachments, 'burst': rules.apply_burst, - 'burst_shared': rules.apply_burst_shared, + # burst shared is temporarily disabled due to a bug + # 'burst_shared': rules.apply_burst_shared, 'chars': rules.apply_chars, 'discord_emojis': rules.apply_discord_emojis, 'duplicates': rules.apply_duplicates, diff --git a/config-default.yml b/config-default.yml index 766f7050c..e9324c62f 100644 --- a/config-default.yml +++ b/config-default.yml @@ -352,9 +352,13 @@ anti_spam: interval: 10 max: 7 - burst_shared: - interval: 10 - max: 20 + # Burst shared it (temporarily) disabled to prevent + # the bug that triggers multiple infractions/DMs per + # user. It also tends to catch a lot of innocent users + # now that we're so big. + # burst_shared: + # interval: 10 + # max: 20 chars: interval: 5 -- cgit v1.2.3 From d2e7dd3763d24a2224fe0eefd78852e2a2389850 Mon Sep 17 00:00:00 2001 From: Numerlor <25886452+Numerlor@users.noreply.github.com> Date: Fri, 4 Sep 2020 20:25:26 +0200 Subject: Move bolding markdown outside of text link. On some devices the markdown gets rendered improperly, leaving the asterisks in the message without bolding. --- bot/cogs/help_channels.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 541c6f336..0f9cac89e 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -36,7 +36,7 @@ the **Help: Dormant** category. 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}). +check out our guide on **[asking good questions]({ASKING_GUIDE_URL})**. """ DORMANT_MSG = f""" @@ -47,7 +47,7 @@ channel until it becomes available again. If your question wasn't answered yet, you can claim a new help channel from the \ **Help: Available** category by simply asking your question again. Consider rephrasing the \ question to maximize your chance of getting a good answer. If you're not sure how, have a look \ -through our guide for [**asking a good question**]({ASKING_GUIDE_URL}). +through our guide for **[asking a good question]({ASKING_GUIDE_URL})**. """ CoroutineFunc = t.Callable[..., t.Coroutine] -- cgit v1.2.3 From 53cb77bb2d541d0be61bc3c25e37b54601963b7c Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 5 Sep 2020 11:07:58 +0200 Subject: Disable everyone_ping filter in AntiSpam cog As there are a few bugs in the implementation, I've temporarily disabled the at-everyone ping filter in the AntiSpam cog. We can disable it after we've fixed the bugs. Signed-off-by: Sebastiaan Zeeff --- bot/cogs/antispam.py | 4 +++- config-default.yml | 8 +++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/cogs/antispam.py b/bot/cogs/antispam.py index b8939113f..3ad487d8c 100644 --- a/bot/cogs/antispam.py +++ b/bot/cogs/antispam.py @@ -36,7 +36,9 @@ RULE_FUNCTION_MAPPING = { 'mentions': rules.apply_mentions, 'newlines': rules.apply_newlines, 'role_mentions': rules.apply_role_mentions, - 'everyone_ping': rules.apply_everyone_ping, + # the everyone filter is temporarily disabled until + # it has been improved. + # 'everyone_ping': rules.apply_everyone_ping, } diff --git a/config-default.yml b/config-default.yml index e9324c62f..6e7cff92d 100644 --- a/config-default.yml +++ b/config-default.yml @@ -389,9 +389,11 @@ anti_spam: interval: 10 max: 3 - everyone_ping: - interval: 10 - max: 0 + # The everyone ping filter is temporarily disabled + # until we've fixed a couple of bugs. + # everyone_ping: + # interval: 10 + # max: 0 reddit: -- cgit v1.2.3