From b1f2b40623f45daf880186fa825fd69a7fc12092 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:23:27 -0700 Subject: Move code block formatting detection to a separate extension/cog It was really out of place in the BotCog, which is meant more for general, simple utility commands. --- bot/cogs/bot.py | 324 +--------------------------------------- bot/cogs/codeblock/__init__.py | 7 + bot/cogs/codeblock/cog.py | 332 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 342 insertions(+), 321 deletions(-) create mode 100644 bot/cogs/codeblock/__init__.py create mode 100644 bot/cogs/codeblock/cog.py diff --git a/bot/cogs/bot.py b/bot/cogs/bot.py index a79b37d25..89c691ccd 100644 --- a/bot/cogs/bot.py +++ b/bot/cogs/bot.py @@ -1,22 +1,15 @@ -import ast import logging -import re -import time -from typing import Optional, Tuple +from typing import Optional -from discord import Embed, Message, RawMessageUpdateEvent, TextChannel +from discord import Embed, TextChannel from discord.ext.commands import Cog, Context, command, group from bot.bot import Bot -from bot.cogs.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs +from bot.constants import Guild, MODERATION_ROLES, Roles, URLs from bot.decorators import with_role -from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') - class BotCog(Cog, name="Bot"): """Bot information commands.""" @@ -24,19 +17,6 @@ class BotCog(Cog, name="Bot"): def __init__(self, bot: Bot): self.bot = bot - # Stores allowed channels plus epoch time since last call. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to cooldown - self.channel_whitelist = ( - Channels.bot_commands, - ) - - # Stores improperly formatted Python codeblock message ids and the corresponding bot message - self.codeblock_message_ids = {} - @group(invoke_without_command=True, name="bot", hidden=True) @with_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: @@ -77,304 +57,6 @@ class BotCog(Cog, name="Bot"): embed = Embed(description=text) await ctx.send(embed=embed) - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. - - Tries to strip out REPL Python code out of msg and returns the stripped msg. - - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True - - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks - - @Cog.listener() - async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - parse_codeblock = ( - ( - is_help_channel - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - # Increase amount of codeblock correction in stats - self.bot.stats.incr("codeblock_corrections") - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - 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) - ) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" - ) - - @Cog.listener() - async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - return - - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) - - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) - - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - def setup(bot: Bot) -> None: """Load the Bot cog.""" diff --git a/bot/cogs/codeblock/__init__.py b/bot/cogs/codeblock/__init__.py new file mode 100644 index 000000000..466933191 --- /dev/null +++ b/bot/cogs/codeblock/__init__.py @@ -0,0 +1,7 @@ +from bot.bot import Bot +from .cog import CodeBlockCog + + +def setup(bot: Bot) -> None: + """Load the CodeBlockCog cog.""" + bot.add_cog(CodeBlockCog(bot)) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py new file mode 100644 index 000000000..7e35e24a9 --- /dev/null +++ b/bot/cogs/codeblock/cog.py @@ -0,0 +1,332 @@ +import ast +import logging +import re +import time +from typing import Optional, Tuple + +from discord import Embed, Message, RawMessageUpdateEvent +from discord.ext.commands import Bot, Cog + +from bot.cogs.token_remover import TokenRemover +from bot.constants import Categories, Channels, DEBUG_MODE +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') + + +class CodeBlockCog(Cog, name="Code Block"): + """Detect improperly formatted code blocks and suggest proper formatting.""" + + def __init__(self, bot: Bot): + self.bot = bot + + # Stores allowed channels plus epoch time since last call. + self.channel_cooldowns = { + Channels.python_discussion: 0, + } + + # These channels will also work, but will not be subject to cooldown + self.channel_whitelist = ( + Channels.bot_commands, + ) + + # Stores improperly formatted Python codeblock message ids and the corresponding bot message + self.codeblock_message_ids = {} + + def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: + """ + Strip msg in order to find Python code. + + Tries to strip out Python code out of msg and returns the stripped block or + None if the block is a valid Python codeblock. + """ + if msg.count("\n") >= 3: + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: + log.trace( + "Someone wrote a message that was already a " + "valid Python syntax highlighted code block. No action taken." + ) + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. + else: + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code + + def fix_indentation(self, msg: str) -> str: + """Attempts to fix badly indented code.""" + def unindent(code: str, skip_spaces: int = 0) -> str: + """Unindents all code down to the number of spaces given in skip_spaces.""" + final = "" + current = code[0] + leading_spaces = 0 + + # Get numbers of spaces before code in the first line. + while current == " ": + current = code[leading_spaces + 1] + leading_spaces += 1 + leading_spaces -= skip_spaces + + # If there are any, remove that number of spaces from every line. + if leading_spaces > 0: + for line in code.splitlines(keepends=True): + line = line[leading_spaces:] + final += line + return final + else: + return code + + # Apply fix for "all lines are overindented" case. + msg = unindent(msg) + + # If the first line does not end with a colon, we can be + # certain the next line will be on the same indentation level. + # + # If it does end with a colon, we will need to indent all successive + # lines one additional level. + first_line = msg.splitlines()[0] + code = "".join(msg.splitlines(keepends=True)[1:]) + if not first_line.endswith(":"): + msg = f"{first_line}\n{unindent(code)}" + else: + msg = f"{first_line}\n{unindent(code, 4)}" + return msg + + def repl_stripping(self, msg: str) -> Tuple[str, bool]: + """ + Strip msg in order to extract Python code out of REPL output. + + Tries to strip out REPL Python code out of msg and returns the stripped msg. + + Returns True for the boolean if REPL code was found in the input msg. + """ + final = "" + for line in msg.splitlines(keepends=True): + if line.startswith(">>>") or line.startswith("..."): + final += line[4:] + log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") + if not final: + log.trace(f"Found no REPL code in \n\n{msg}\n\n") + return msg, False + else: + log.trace(f"Found REPL code in \n\n{msg}\n\n") + return final.rstrip(), True + + def has_bad_ticks(self, msg: Message) -> bool: + """Check to see if msg contains ticks that aren't '`'.""" + not_backticks = [ + "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", + "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", + "\u3003\u3003\u3003" + ] + + return msg.content[:3] in not_backticks + + @Cog.listener() + async def on_message(self, msg: Message) -> None: + """ + Detect poorly formatted Python code in new messages. + + If poorly formatted code is detected, send the user a helpful message explaining how to do + properly formatted Python syntax highlighting codeblocks. + """ + is_help_channel = ( + getattr(msg.channel, "category", None) + and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + parse_codeblock = ( + ( + is_help_channel + or msg.channel.id in self.channel_cooldowns + or msg.channel.id in self.channel_whitelist + ) + and not msg.author.bot + and len(msg.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(msg) + ) + + if parse_codeblock: # no token in the msg + on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 + if not on_cooldown or DEBUG_MODE: + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + 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) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + if ( + # Checks to see if the message was called out by the bot + payload.message_id not in self.codeblock_message_ids + # Makes sure that there is content in the message + or payload.data.get("content") is None + # Makes sure there's a channel id in the message payload + or payload.data.get("channel_id") is None + ): + return + + # Retrieve channel and message objects for use later + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + user_message = await channel.fetch_message(payload.message_id) + + # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + + # If the message is fixed, delete the bot message and the entry from the id dictionary + if has_fixed_codeblock is None: + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") -- cgit v1.2.3 From 652bc5a1be4c181221ee40087a9c79d01fad10b8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:28:46 -0700 Subject: Code block: add helper function to check for help channels --- bot/cogs/codeblock/cog.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 7e35e24a9..af283120d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -4,6 +4,7 @@ import re import time from typing import Optional, Tuple +import discord from discord import Embed, Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog @@ -173,6 +174,14 @@ class CodeBlockCog(Cog, name="Code Block"): return msg.content[:3] in not_backticks + @staticmethod + def is_help_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is in one of the help categories.""" + return ( + getattr(channel, "category", None) + and channel.category.id in (Categories.help_available, Categories.help_in_use) + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -181,13 +190,9 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - is_help_channel = ( - getattr(msg.channel, "category", None) - and msg.channel.category.id in (Categories.help_available, Categories.help_in_use) - ) parse_codeblock = ( ( - is_help_channel + self.is_help_channel(msg.channel) or msg.channel.id in self.channel_cooldowns or msg.channel.id in self.channel_whitelist ) -- cgit v1.2.3 From 8af716254eb88bbf401665441a8d0ac1ca054671 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:34:11 -0700 Subject: Code block: add helper function to check channel is valid --- bot/cogs/codeblock/cog.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index af283120d..a1733ea99 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -182,6 +182,14 @@ class CodeBlockCog(Cog, name="Code Block"): and channel.category.id in (Categories.help_available, Categories.help_in_use) ) + def is_valid_channel(self, channel: discord.TextChannel) -> bool: + """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + return ( + self.is_help_channel(channel) + or channel.id in self.channel_cooldowns + or channel.id in self.channel_whitelist + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -191,11 +199,7 @@ class CodeBlockCog(Cog, name="Code Block"): properly formatted Python syntax highlighting codeblocks. """ parse_codeblock = ( - ( - self.is_help_channel(msg.channel) - or msg.channel.id in self.channel_cooldowns - or msg.channel.id in self.channel_whitelist - ) + self.is_valid_channel(msg.channel) and not msg.author.bot and len(msg.content.splitlines()) > 3 and not TokenRemover.find_token_in_message(msg) -- cgit v1.2.3 From 3b967c5228e439e127d096510d3097896536add3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:38:57 -0700 Subject: Code block: add helper function to check if msg should be parsed * Check for bot author first because it's a simpler/faster check --- bot/cogs/codeblock/cog.py | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index a1733ea99..9dd42fa81 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -190,6 +190,24 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + def should_parse(self, message: discord.Message) -> bool: + """ + Return True if `message` should be parsed. + + A qualifying message: + + 1. Is not authored by a bot + 2. Is in a valid channel + 3. Has more than 3 lines + 4. Has no bot token + """ + return ( + not message.author.bot + and self.is_valid_channel(message.channel) + and len(message.content.splitlines()) > 3 + and not TokenRemover.find_token_in_message(message) + ) + @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -198,14 +216,7 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - parse_codeblock = ( - self.is_valid_channel(msg.channel) - and not msg.author.bot - and len(msg.content.splitlines()) > 3 - and not TokenRemover.find_token_in_message(msg) - ) - - if parse_codeblock: # no token in the msg + if self.should_parse(msg): # no token in the msg on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 if not on_cooldown or DEBUG_MODE: try: -- cgit v1.2.3 From 76eff088a6e2aa832165087d441effee26d8fead Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:42:16 -0700 Subject: Code block: add helper function to check for channel cooldown --- bot/cogs/codeblock/cog.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 9dd42fa81..be7c3df84 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -182,6 +182,14 @@ class CodeBlockCog(Cog, name="Code Block"): and channel.category.id in (Categories.help_available, Categories.help_in_use) ) + def is_on_cooldown(self, channel: discord.TextChannel) -> bool: + """ + Return True if an embed was sent for `channel` in the last 300 seconds. + + Note: only channels in the `channel_cooldowns` have cooldowns enabled. + """ + return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( @@ -217,8 +225,7 @@ class CodeBlockCog(Cog, name="Code Block"): properly formatted Python syntax highlighting codeblocks. """ if self.should_parse(msg): # no token in the msg - on_cooldown = (time.time() - self.channel_cooldowns.get(msg.channel.id, 0)) < 300 - if not on_cooldown or DEBUG_MODE: + if not self.is_on_cooldown(msg.channel) or DEBUG_MODE: try: if self.has_bad_ticks(msg): ticks = msg.content[:3] -- cgit v1.2.3 From 8f79a8bf5f1a7372c6de7d768f1593d5da599789 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:43:25 -0700 Subject: Code block: invert conditions to reduce nesting --- bot/cogs/codeblock/cog.py | 209 ++++++++++++++++++++++++---------------------- 1 file changed, 107 insertions(+), 102 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index be7c3df84..36c761764 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -224,113 +224,118 @@ class CodeBlockCog(Cog, name="Code Block"): If poorly formatted code is detected, send the user a helpful message explaining how to do properly formatted Python syntax highlighting codeblocks. """ - if self.should_parse(msg): # no token in the msg - if not self.is_on_cooldown(msg.channel) or DEBUG_MODE: - try: - if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) + if not self.should_parse(msg): + return - else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - 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) - ) - else: - return + # When debugging, ignore cooldowns. + if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + return + + try: + if self.has_bad_ticks(msg): + ticks = msg.content[:3] + content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) + if content is None: + return - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() + content, repl_code = content - except SyntaxError: - log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + + else: + howto = "" + content = self.codeblock_stripping(msg.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + howto += ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" ) + log.debug(f"{msg.author} posted something that needed to be put inside python code " + "blocks. Sending the user some instructions.") + else: + log.trace("The code consists only of expressions, not sending instructions") + + if howto != "": + howto_embed = Embed(description=howto) + bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) + 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) + ) + else: + return + + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() + + except SyntaxError: + log.trace( + f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " + "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " + f"The message that was posted was:\n\n{msg.content}\n\n" + ) + @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: """Check to see if an edited message (previously called out) still contains poorly formatted code.""" -- cgit v1.2.3 From 644918f7a4952a8c5eb96c2c1181a3784e73cfb5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:53:05 -0700 Subject: Code block: add helper function to send the embed --- bot/cogs/codeblock/cog.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 36c761764..a4cd743e4 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -198,6 +198,20 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + async def send_guide_embed(self, message: discord.Message, description: str) -> None: + """ + Send an embed with `description` as a guide for an improperly formatted `message`. + + The embed will be deleted automatically after 5 minutes. + """ + embed = Embed(description=description) + bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) + self.codeblock_message_ids[message.id] = bot_message.id + + self.bot.loop.create_task( + wait_for_deletion(bot_message, user_ids=(message.author.id,), client=self.bot) + ) + def should_parse(self, message: discord.Message) -> bool: """ Return True if `message` should be parsed. @@ -316,13 +330,7 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace("The code consists only of expressions, not sending instructions") if howto != "": - howto_embed = Embed(description=howto) - bot_message = await msg.channel.send(f"Hey {msg.author.mention}!", embed=howto_embed) - 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) - ) + await self.send_guide_embed(msg, howto) else: return -- cgit v1.2.3 From fc5d7407dc0e52461c8940cf2eabb832e9c7a4a7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 13 Apr 2020 22:56:35 -0700 Subject: Code block: move final send/cooldown code outside the try-except Reduces nesting for improved readability. The code would have never thrown a syntax error in the manner expected anyway. --- bot/cogs/codeblock/cog.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index a4cd743e4..312a7034e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -328,21 +328,18 @@ class CodeBlockCog(Cog, name="Code Block"): "blocks. Sending the user some instructions.") else: log.trace("The code consists only of expressions, not sending instructions") - - if howto != "": - await self.send_guide_embed(msg, howto) - else: - return - - if msg.channel.id not in self.channel_whitelist: - self.channel_cooldowns[msg.channel.id] = time.time() - except SyntaxError: log.trace( f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " f"The message that was posted was:\n\n{msg.content}\n\n" ) + return + + if howto: + await self.send_guide_embed(msg, howto) + if msg.channel.id not in self.channel_whitelist: + self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: -- cgit v1.2.3 From aa37ffc42abf70135d17c3810bb2d35f810f965f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:38:24 -0700 Subject: Code block: move bad ticks message creation to a new function --- bot/cogs/codeblock/cog.py | 70 +++++++++++++++++++++++++---------------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 312a7034e..ddbe081dd 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -105,6 +105,42 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Returning message.\n\n{content}\n\n") return (content,), repl_code + def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: + """Return the guide message to output for bad code block ticks in `message`.""" + ticks = message.content[:3] + content = self.codeblock_stripping(f"```{message.content[3:-3]}```", True) + if content is None: + return + + content, repl_code = content + + if len(content) == 2: + content = content[1] + else: + content = content[0] + + space_left = 204 + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + + return ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the codeblock should start. " + f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" + "**Here is an example of how it should look:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: @@ -247,39 +283,7 @@ class CodeBlockCog(Cog, name="Code Block"): try: if self.has_bad_ticks(msg): - ticks = msg.content[:3] - content = self.codeblock_stripping(f"```{msg.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto = ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - + howto = self.format_bad_ticks_message(msg) else: howto = "" content = self.codeblock_stripping(msg.content, False) -- cgit v1.2.3 From 254fa81c691d387fa5fae661b56d642da7375863 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:45:25 -0700 Subject: Code block: move standard guide message creation to a new function * Rename `howto` variable to `description` --- bot/cogs/codeblock/cog.py | 105 ++++++++++++++++++++++++---------------------- 1 file changed, 55 insertions(+), 50 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ddbe081dd..7a9ca8e04 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -141,6 +141,57 @@ class CodeBlockCog(Cog, name="Code Block"): f"```python\n{content}\n```" ) + def format_guide_message(self, message: discord.Message) -> Optional[str]: + """Return the guide message to output for a poorly formatted code block in `message`.""" + content = self.codeblock_stripping(message.content, False) + if content is None: + return + + content, repl_code = content + # Attempts to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content[0]) + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: + # Shorten the code to 10 lines and/or 204 characters. + space_left = 204 + if content and repl_code: + content = content[1] + else: + content = content[0] + + if len(content) >= space_left: + current_length = 0 + lines_walked = 0 + for line in content.splitlines(keepends=True): + if current_length + len(line) > space_left or lines_walked == 10: + break + current_length += len(line) + lines_walked += 1 + content = content[:current_length] + "#..." + + log.debug( + f"{message.author} posted something that needed to be put inside python code " + f"blocks. Sending the user some instructions." + ) + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) + else: + log.trace("The code consists only of expressions, not sending instructions") + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: @@ -283,55 +334,9 @@ class CodeBlockCog(Cog, name="Code Block"): try: if self.has_bad_ticks(msg): - howto = self.format_bad_ticks_message(msg) + description = self.format_bad_ticks_message(msg) else: - howto = "" - content = self.codeblock_stripping(msg.content, False) - if content is None: - return - - content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 - if content and repl_code: - content = content[1] - else: - content = content[0] - - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." - - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - howto += ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - log.debug(f"{msg.author} posted something that needed to be put inside python code " - "blocks. Sending the user some instructions.") - else: - log.trace("The code consists only of expressions, not sending instructions") + description = self.format_guide_message(msg) except SyntaxError: log.trace( f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " @@ -340,8 +345,8 @@ class CodeBlockCog(Cog, name="Code Block"): ) return - if howto: - await self.send_guide_embed(msg, howto) + if description: + await self.send_guide_embed(msg, description) if msg.channel.id not in self.channel_whitelist: self.channel_cooldowns[msg.channel.id] = time.time() -- cgit v1.2.3 From d0232f76cdf09ecf61ca1329f09f6f78f3e3cf23 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 09:58:46 -0700 Subject: Code block: make invalid backticks a constant set A set should be faster since it's being used to test for membership. A constant just means it won't need to be redefined every time the function is called. * Make `has_bad_ticks` a static method * Add comments describing characters represented by the Unicode escapes --- bot/cogs/codeblock/cog.py | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 7a9ca8e04..e435d036c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,6 +15,18 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') +INVALID_BACKTICKS = { + "'''", + '"""', + "\u00b4\u00b4\u00b4", # ACUTE ACCENT + "\u2018\u2018\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019\u2019\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032\u2032\u2032", # PRIME + "\u201c\u201c\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d\u201d\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033\u2033\u2033", # DOUBLE PRIME + "\u3003\u3003\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +} class CodeBlockCog(Cog, name="Code Block"): @@ -251,15 +263,10 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Found REPL code in \n\n{msg}\n\n") return final.rstrip(), True - def has_bad_ticks(self, msg: Message) -> bool: - """Check to see if msg contains ticks that aren't '`'.""" - not_backticks = [ - "'''", '"""', "\u00b4\u00b4\u00b4", "\u2018\u2018\u2018", "\u2019\u2019\u2019", - "\u2032\u2032\u2032", "\u201c\u201c\u201c", "\u201d\u201d\u201d", "\u2033\u2033\u2033", - "\u3003\u3003\u3003" - ] - - return msg.content[:3] in not_backticks + @staticmethod + def has_bad_ticks(message: discord.Message) -> bool: + """Return True if `message` starts with 3 characters which look like but aren't '`'.""" + return message.content[:3] in INVALID_BACKTICKS @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: -- cgit v1.2.3 From 66a3af006a7e9928afd55d0f4ccf48d886b79487 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 14 Apr 2020 10:02:37 -0700 Subject: Code block: simplify log message --- bot/cogs/codeblock/cog.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e435d036c..c49d7574c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -346,9 +346,8 @@ class CodeBlockCog(Cog, name="Code Block"): description = self.format_guide_message(msg) except SyntaxError: log.trace( - f"{msg.author} posted in a help channel, and when we tried to parse it as Python code, " - "ast.parse raised a SyntaxError. This probably just means it wasn't Python code. " - f"The message that was posted was:\n\n{msg.content}\n\n" + f"SyntaxError while parsing code block sent by {msg.author}; " + f"code posted probably just wasn't Python:\n\n{msg.content}\n\n" ) return -- cgit v1.2.3 From 381872deedd39c171f3fff3312c6049c19c4371f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 15 Apr 2020 21:07:38 -0700 Subject: Code block: ignore if code block has *any* language If the code was valid Python syntax, the guide embed would be sent despite a non-Python language being explicitly specified for the code block by the message author. * Make the code block language regex a compiled pattern constant Fixes #829 --- bot/cogs/codeblock/cog.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c49d7574c..fc515c8df 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,6 +15,7 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_])\n(.*?)```", re.DOTALL) INVALID_BACKTICKS = { "'''", '"""', @@ -57,11 +58,8 @@ class CodeBlockCog(Cog, name="Code Block"): """ if msg.count("\n") >= 3: # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if re.search("```(?:py|python)\n(.*?)```", msg, re.IGNORECASE | re.DOTALL) and not bad_ticks: - log.trace( - "Someone wrote a message that was already a " - "valid Python syntax highlighted code block. No action taken." - ) + if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: + log.trace("Code block already has valid syntax highlighting; no action taken") return None else: -- cgit v1.2.3 From e3c0f7c00b78484f8d802e3e70e0b711122580ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 17 Apr 2020 08:50:04 -0700 Subject: Code block: use a more efficient line count check --- bot/cogs/codeblock/cog.py | 116 +++++++++++++++++++++++----------------------- 1 file changed, 59 insertions(+), 57 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index fc515c8df..6699abd2f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -56,64 +56,66 @@ class CodeBlockCog(Cog, name="Code Block"): Tries to strip out Python code out of msg and returns the stripped block or None if the block is a valid Python codeblock. """ - if msg.count("\n") >= 3: - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: - log.trace("Code block already has valid syntax highlighting; no action taken") - return None + if len(msg.split("\n", 3)) <= 3: + return None - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None + # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. + if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: + log.trace("Code block already has valid syntax highlighting; no action taken") + return None + + else: + # Stripping backticks from every line of the message. + log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") + content = "" + for line in msg.splitlines(keepends=True): + content += line.strip("`") + + content = content.strip() + + # Remove "Python" or "Py" from start of the message if it exists. + log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") + pycode = False + if content.lower().startswith("python"): + content = content[6:] + pycode = True + elif content.lower().startswith("py"): + content = content[2:] + pycode = True + + if pycode: + content = content.splitlines(keepends=True) + + # Check if there might be code in the first line, and preserve it. + first_line = content[0] + if " " in content[0]: + first_space = first_line.index(" ") + content[0] = first_line[first_space:] + content = "".join(content) + + # If there's no code we can just get rid of the first line. else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code + content = "".join(content[1:]) + + # Strip it again to remove any leading whitespace. This is neccessary + # if the first line of the message looked like ```python + old = content.strip() + + # Strips REPL code out of the message if there is any. + content, repl_code = self.repl_stripping(old) + if old != content: + return (content, old), repl_code + + # Try to apply indentation fixes to the code. + content = self.fix_indentation(content) + + # Check if the code contains backticks, if it does ignore the message. + if "`" in content: + log.trace("Detected ` inside the code, won't reply") + return None + else: + log.trace(f"Returning message.\n\n{content}\n\n") + return (content,), repl_code def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: """Return the guide message to output for bad code block ticks in `message`.""" @@ -318,7 +320,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and len(message.content.splitlines()) > 3 + and len(message.content.split("\n", 3)) > 3 and not TokenRemover.find_token_in_message(message) ) -- cgit v1.2.3 From b914d236b8129ae2616424629922db81a79eeead Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 20:44:15 -0700 Subject: Code block: fix code block language regex It was missing a quantifier to match more than 1 character. --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6699abd2f..cde16bd9f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,7 +15,7 @@ from bot.utils.messages import wait_for_deletion log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_])\n(.*?)```", re.DOTALL) +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) INVALID_BACKTICKS = { "'''", '"""', -- cgit v1.2.3 From 964d14a150edf583c7211ddaad74ce67ee98cd80 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:22:40 -0700 Subject: Code block: add regex to search for any code blocks This regex supports both valid and invalid ticks. The ticks are in a group so it's later possible to detect if valid ones were used. --- bot/cogs/codeblock/cog.py | 37 +++++++++++++++++++++++++------------ 1 file changed, 25 insertions(+), 12 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index cde16bd9f..292735f3f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -16,18 +16,31 @@ log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) -INVALID_BACKTICKS = { - "'''", - '"""', - "\u00b4\u00b4\u00b4", # ACUTE ACCENT - "\u2018\u2018\u2018", # LEFT SINGLE QUOTATION MARK - "\u2019\u2019\u2019", # RIGHT SINGLE QUOTATION MARK - "\u2032\u2032\u2032", # PRIME - "\u201c\u201c\u201c", # LEFT DOUBLE QUOTATION MARK - "\u201d\u201d\u201d", # RIGHT DOUBLE QUOTATION MARK - "\u2033\u2033\u2033", # DOUBLE PRIME - "\u3003\u3003\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +TICKS = { + "`", + "'", + '"', + "\u00b4", # ACUTE ACCENT + "\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032", # PRIME + "\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033", # DOUBLE PRIME + "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } +RE_CODE_BLOCK = re.compile( + fr""" + ( + ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + ) + ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. + """, + re.DOTALL | re.VERBOSE +) class CodeBlockCog(Cog, name="Code Block"): @@ -266,7 +279,7 @@ class CodeBlockCog(Cog, name="Code Block"): @staticmethod def has_bad_ticks(message: discord.Message) -> bool: """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in INVALID_BACKTICKS + return message.content[:3] in TICKS @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: -- cgit v1.2.3 From f51b2cacdb8824b51517d10a479be9ec0629d066 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:40:06 -0700 Subject: Code block: add function to find invalid code blocks * Create a `NamedTuple` representing a code block --- bot/cogs/codeblock/cog.py | 34 ++++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 292735f3f..6e87f9f15 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -2,7 +2,7 @@ import ast import logging import re import time -from typing import Optional, Tuple +from typing import NamedTuple, Optional, Sequence, Tuple import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -16,8 +16,9 @@ log = logging.getLogger(__name__) RE_MARKDOWN = re.compile(r'([*_~`|>])') RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) +BACKTICK = "`" TICKS = { - "`", + BACKTICK, "'", '"', "\u00b4", # ACUTE ACCENT @@ -43,6 +44,14 @@ RE_CODE_BLOCK = re.compile( ) +class CodeBlock(NamedTuple): + """Represents a Markdown code block.""" + + content: str + language: str + tick: str + + class CodeBlockCog(Cog, name="Code Block"): """Detect improperly formatted code blocks and suggest proper formatting.""" @@ -217,6 +226,27 @@ class CodeBlockCog(Cog, name="Code Block"): else: log.trace("The code consists only of expressions, not sending instructions") + @staticmethod + def find_invalid_code_blocks(message: str) -> Sequence[CodeBlock]: + """ + Find and return all invalid Markdown code blocks in the `message`. + + An invalid code block is considered to be one which uses invalid back ticks. + + If the `message` contains at least one valid code block, return an empty sequence. This is + based on the assumption that if the user managed to get one code block right, they already + know how to fix the rest themselves. + """ + code_blocks = [] + for _, tick, language, content in RE_CODE_BLOCK.finditer(message): + if tick == BACKTICK: + return () + else: + code_block = CodeBlock(content, language.strip(), tick) + code_blocks.append(code_block) + + return code_blocks + def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: -- cgit v1.2.3 From 1db3327239c65def7e3ddfcc54453cdadf240a90 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 3 May 2020 21:45:55 -0700 Subject: Code block: return code blocks with valid ticks but no lang Such code block will be useful down the road for sending information on including a language specified if the content successfully parses as valid Python. --- bot/cogs/codeblock/cog.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6e87f9f15..970cbd63d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -227,26 +227,23 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace("The code consists only of expressions, not sending instructions") @staticmethod - def find_invalid_code_blocks(message: str) -> Sequence[CodeBlock]: + def find_code_blocks(message: str) -> Sequence[CodeBlock]: """ - Find and return all invalid Markdown code blocks in the `message`. + Find and return all Markdown code blocks in the `message`. - An invalid code block is considered to be one which uses invalid back ticks. - - If the `message` contains at least one valid code block, return an empty sequence. This is - based on the assumption that if the user managed to get one code block right, they already - know how to fix the rest themselves. + If the `message` contains at least one code block with valid ticks and a specified language, + return an empty sequence. This is based on the assumption that if the user managed to get + one code block right, they already know how to fix the rest themselves. """ code_blocks = [] for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - if tick == BACKTICK: + language = language.strip() + if tick == BACKTICK and language: return () else: - code_block = CodeBlock(content, language.strip(), tick) + code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) - return code_blocks - def fix_indentation(self, msg: str) -> str: """Attempts to fix badly indented code.""" def unindent(code: str, skip_spaces: int = 0) -> str: -- cgit v1.2.3 From 7169d2a6828babc3f670b9936a1e9111e1fe3948 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 4 May 2020 10:43:29 -0700 Subject: Code block: add function to truncate content The code was duplicated in each of the format message functions. The function also ensures content is truncated to 10 lines. Previously, code could have skipped truncating by being 100 lines long but under 204 characters in length. --- bot/cogs/codeblock/cog.py | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 970cbd63d..c5704b730 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -153,16 +153,7 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - space_left = 204 - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." + content = self.truncate(content) content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) return ( @@ -190,22 +181,12 @@ class CodeBlockCog(Cog, name="Code Block"): # This check is to avoid all nodes being parsed as expressions. # (e.g. words over multiple lines) if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - # Shorten the code to 10 lines and/or 204 characters. - space_left = 204 if content and repl_code: content = content[1] else: content = content[0] - if len(content) >= space_left: - current_length = 0 - lines_walked = 0 - for line in content.splitlines(keepends=True): - if current_length + len(line) > space_left or lines_walked == 10: - break - current_length += len(line) - lines_walked += 1 - content = content[:current_length] + "#..." + content = self.truncate(content) log.debug( f"{message.author} posted something that needed to be put inside python code " @@ -364,6 +345,20 @@ class CodeBlockCog(Cog, name="Code Block"): and not TokenRemover.find_token_in_message(message) ) + @staticmethod + def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: + """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" + current_length = 0 + lines_walked = 0 + + for line in content.splitlines(keepends=True): + if current_length + len(line) > max_chars or lines_walked == max_lines: + break + current_length += len(line) + lines_walked += 1 + + return content[:current_length] + "#..." + @Cog.listener() async def on_message(self, msg: Message) -> None: """ -- cgit v1.2.3 From 4c0c58252034a28debcee57aa0bb6b3a72e653d5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 5 May 2020 18:51:27 -0700 Subject: Code block: add function to check for valid Python code --- bot/cogs/codeblock/cog.py | 72 ++++++++++++++++++++++++++++------------------- 1 file changed, 43 insertions(+), 29 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c5704b730..92bf43feb 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -173,39 +173,33 @@ class CodeBlockCog(Cog, name="Code Block"): return content, repl_code = content - # Attempts to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content[0]) - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body) or repl_code: - if content and repl_code: - content = content[1] - else: - content = content[0] + if not repl_code and not self.is_python_code(content[0]): + return - content = self.truncate(content) + if content and repl_code: + content = content[1] + else: + content = content[0] - log.debug( - f"{message.author} posted something that needed to be put inside python code " - f"blocks. Sending the user some instructions." - ) + content = self.truncate(content) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) - return ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - else: - log.trace("The code consists only of expressions, not sending instructions") + log.debug( + f"{message.author} posted something that needed to be put inside python code " + f"blocks. Sending the user some instructions." + ) + + content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n" + f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + f"```python\n{content}\n```" + ) @staticmethod def find_code_blocks(message: str) -> Sequence[CodeBlock]: @@ -305,6 +299,26 @@ class CodeBlockCog(Cog, name="Code Block"): """ return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + @staticmethod + def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python consisting of more than just expressions.""" + try: + # Attempt to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content) + except SyntaxError: + log.trace("Code is not valid Python.") + return False + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body): + return True + else: + log.trace("Code consists only of expressions.") + return False + def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( -- cgit v1.2.3 From fb6017a8a00f5c54ea4532ff035abe8f34500f6f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 5 May 2020 19:43:41 -0700 Subject: Code block: exclude code blocks 3 lines or shorter --- bot/cogs/codeblock/cog.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 92bf43feb..64f9a4cbc 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -206,6 +206,8 @@ class CodeBlockCog(Cog, name="Code Block"): """ Find and return all Markdown code blocks in the `message`. + Code blocks with 3 or less lines are excluded. + If the `message` contains at least one code block with valid ticks and a specified language, return an empty sequence. This is based on the assumption that if the user managed to get one code block right, they already know how to fix the rest themselves. @@ -215,7 +217,7 @@ class CodeBlockCog(Cog, name="Code Block"): language = language.strip() if tick == BACKTICK and language: return () - else: + elif len(content.split("\n", 3)) > 3: code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) -- cgit v1.2.3 From edc6c9a39c7681a72fca7ba053f5161f46eadfb9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 11:22:28 -0700 Subject: Code block: add function to check if REPL code exists The `repl_stripping` function was re-purposed. The plan going forward is to not show the user's code in the output so actual stripping is no longer necessary. --- bot/cogs/codeblock/cog.py | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 64f9a4cbc..25791801e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -260,25 +260,18 @@ class CodeBlockCog(Cog, name="Code Block"): msg = f"{first_line}\n{unindent(code, 4)}" return msg - def repl_stripping(self, msg: str) -> Tuple[str, bool]: - """ - Strip msg in order to extract Python code out of REPL output. + @staticmethod + def is_repl_code(content: str, threshold: int = 3) -> bool: + """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + repl_lines = 0 + for line in content.splitlines(): + if line.startswith(">>> ") or line.startswith("... "): + repl_lines += 1 - Tries to strip out REPL Python code out of msg and returns the stripped msg. + if repl_lines == threshold: + return True - Returns True for the boolean if REPL code was found in the input msg. - """ - final = "" - for line in msg.splitlines(keepends=True): - if line.startswith(">>>") or line.startswith("..."): - final += line[4:] - log.trace(f"Formatted: \n\n{msg}\n\n to \n\n{final}\n\n") - if not final: - log.trace(f"Found no REPL code in \n\n{msg}\n\n") - return msg, False - else: - log.trace(f"Found REPL code in \n\n{msg}\n\n") - return final.rstrip(), True + return False @staticmethod def has_bad_ticks(message: discord.Message) -> bool: -- cgit v1.2.3 From 4d05e1de961d13389936896bba7704b8618be9c0 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 11:43:09 -0700 Subject: Code block: remove obsolete functions The user's original code will not be displayed in the output so there is no longer a need for the functions which format their code. --- bot/cogs/codeblock/cog.py | 109 +--------------------------------------------- 1 file changed, 1 insertion(+), 108 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 25791801e..d0ffcab3f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -2,7 +2,7 @@ import ast import logging import re import time -from typing import NamedTuple, Optional, Sequence, Tuple +from typing import NamedTuple, Optional, Sequence import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -71,74 +71,6 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - def codeblock_stripping(self, msg: str, bad_ticks: bool) -> Optional[Tuple[Tuple[str, ...], str]]: - """ - Strip msg in order to find Python code. - - Tries to strip out Python code out of msg and returns the stripped block or - None if the block is a valid Python codeblock. - """ - if len(msg.split("\n", 3)) <= 3: - return None - - # Filtering valid Python codeblocks and exiting if a valid Python codeblock is found. - if RE_CODE_BLOCK_LANGUAGE.search(msg) and not bad_ticks: - log.trace("Code block already has valid syntax highlighting; no action taken") - return None - - else: - # Stripping backticks from every line of the message. - log.trace(f"Stripping backticks from message.\n\n{msg}\n\n") - content = "" - for line in msg.splitlines(keepends=True): - content += line.strip("`") - - content = content.strip() - - # Remove "Python" or "Py" from start of the message if it exists. - log.trace(f"Removing 'py' or 'python' from message.\n\n{content}\n\n") - pycode = False - if content.lower().startswith("python"): - content = content[6:] - pycode = True - elif content.lower().startswith("py"): - content = content[2:] - pycode = True - - if pycode: - content = content.splitlines(keepends=True) - - # Check if there might be code in the first line, and preserve it. - first_line = content[0] - if " " in content[0]: - first_space = first_line.index(" ") - content[0] = first_line[first_space:] - content = "".join(content) - - # If there's no code we can just get rid of the first line. - else: - content = "".join(content[1:]) - - # Strip it again to remove any leading whitespace. This is neccessary - # if the first line of the message looked like ```python - old = content.strip() - - # Strips REPL code out of the message if there is any. - content, repl_code = self.repl_stripping(old) - if old != content: - return (content, old), repl_code - - # Try to apply indentation fixes to the code. - content = self.fix_indentation(content) - - # Check if the code contains backticks, if it does ignore the message. - if "`" in content: - log.trace("Detected ` inside the code, won't reply") - return None - else: - log.trace(f"Returning message.\n\n{content}\n\n") - return (content,), repl_code - def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: """Return the guide message to output for bad code block ticks in `message`.""" ticks = message.content[:3] @@ -221,45 +153,6 @@ class CodeBlockCog(Cog, name="Code Block"): code_block = CodeBlock(content, language, tick) code_blocks.append(code_block) - def fix_indentation(self, msg: str) -> str: - """Attempts to fix badly indented code.""" - def unindent(code: str, skip_spaces: int = 0) -> str: - """Unindents all code down to the number of spaces given in skip_spaces.""" - final = "" - current = code[0] - leading_spaces = 0 - - # Get numbers of spaces before code in the first line. - while current == " ": - current = code[leading_spaces + 1] - leading_spaces += 1 - leading_spaces -= skip_spaces - - # If there are any, remove that number of spaces from every line. - if leading_spaces > 0: - for line in code.splitlines(keepends=True): - line = line[leading_spaces:] - final += line - return final - else: - return code - - # Apply fix for "all lines are overindented" case. - msg = unindent(msg) - - # If the first line does not end with a colon, we can be - # certain the next line will be on the same indentation level. - # - # If it does end with a colon, we will need to indent all successive - # lines one additional level. - first_line = msg.splitlines()[0] - code = "".join(msg.splitlines(keepends=True)[1:]) - if not first_line.endswith(":"): - msg = f"{first_line}\n{unindent(code)}" - else: - msg = f"{first_line}\n{unindent(code, 4)}" - return msg - @staticmethod def is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" -- cgit v1.2.3 From 89c54fbda81d790d09213fa3093772261d0c4947 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 14:59:04 -0700 Subject: Code block: move parsing functions to a separate module This reduces clutter in the cog. The cog should only have Discord- related functionality. --- bot/cogs/codeblock/cog.py | 128 +++--------------------------------------- bot/cogs/codeblock/parsing.py | 117 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 119 deletions(-) create mode 100644 bot/cogs/codeblock/parsing.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index d0ffcab3f..dad0cc9cc 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,8 +1,6 @@ -import ast import logging -import re import time -from typing import NamedTuple, Optional, Sequence +from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -11,46 +9,10 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion +from . import parsing log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) -BACKTICK = "`" -TICKS = { - BACKTICK, - "'", - '"', - "\u00b4", # ACUTE ACCENT - "\u2018", # LEFT SINGLE QUOTATION MARK - "\u2019", # RIGHT SINGLE QUOTATION MARK - "\u2032", # PRIME - "\u201c", # LEFT DOUBLE QUOTATION MARK - "\u201d", # RIGHT DOUBLE QUOTATION MARK - "\u2033", # DOUBLE PRIME - "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF -} -RE_CODE_BLOCK = re.compile( - fr""" - ( - ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. - \2{{2}} # Match the previous group 2 more times to ensure it's the same char. - ) - ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. - (.+?) # Match the actual code within the block. - \1 # Match the same 3 ticks used at the start of the block. - """, - re.DOTALL | re.VERBOSE -) - - -class CodeBlock(NamedTuple): - """Represents a Markdown code block.""" - - content: str - language: str - tick: str - class CodeBlockCog(Cog, name="Code Block"): """Detect improperly formatted code blocks and suggest proper formatting.""" @@ -85,8 +47,8 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - content = self.truncate(content) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + content = parsing.truncate(content) + content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) return ( "It looks like you are trying to paste code into this channel.\n\n" @@ -106,7 +68,7 @@ class CodeBlockCog(Cog, name="Code Block"): content, repl_code = content - if not repl_code and not self.is_python_code(content[0]): + if not repl_code and not parsing.is_python_code(content[0]): return if content and repl_code: @@ -114,14 +76,14 @@ class CodeBlockCog(Cog, name="Code Block"): else: content = content[0] - content = self.truncate(content) + content = parsing.truncate(content) log.debug( f"{message.author} posted something that needed to be put inside python code " f"blocks. Sending the user some instructions." ) - content_escaped_markdown = RE_MARKDOWN.sub(r'\\\1', content) + content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -133,44 +95,6 @@ class CodeBlockCog(Cog, name="Code Block"): f"```python\n{content}\n```" ) - @staticmethod - def find_code_blocks(message: str) -> Sequence[CodeBlock]: - """ - Find and return all Markdown code blocks in the `message`. - - Code blocks with 3 or less lines are excluded. - - If the `message` contains at least one code block with valid ticks and a specified language, - return an empty sequence. This is based on the assumption that if the user managed to get - one code block right, they already know how to fix the rest themselves. - """ - code_blocks = [] - for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - language = language.strip() - if tick == BACKTICK and language: - return () - elif len(content.split("\n", 3)) > 3: - code_block = CodeBlock(content, language, tick) - code_blocks.append(code_block) - - @staticmethod - def is_repl_code(content: str, threshold: int = 3) -> bool: - """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" - repl_lines = 0 - for line in content.splitlines(): - if line.startswith(">>> ") or line.startswith("... "): - repl_lines += 1 - - if repl_lines == threshold: - return True - - return False - - @staticmethod - def has_bad_ticks(message: discord.Message) -> bool: - """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in TICKS - @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" @@ -187,26 +111,6 @@ class CodeBlockCog(Cog, name="Code Block"): """ return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 - @staticmethod - def is_python_code(content: str) -> bool: - """Return True if `content` is valid Python consisting of more than just expressions.""" - try: - # Attempt to parse the message into an AST node. - # Invalid Python code will raise a SyntaxError. - tree = ast.parse(content) - except SyntaxError: - log.trace("Code is not valid Python.") - return False - - # Multiple lines of single words could be interpreted as expressions. - # This check is to avoid all nodes being parsed as expressions. - # (e.g. words over multiple lines) - if not all(isinstance(node, ast.Expr) for node in tree.body): - return True - else: - log.trace("Code consists only of expressions.") - return False - def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" return ( @@ -247,20 +151,6 @@ class CodeBlockCog(Cog, name="Code Block"): and not TokenRemover.find_token_in_message(message) ) - @staticmethod - def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: - """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" - current_length = 0 - lines_walked = 0 - - for line in content.splitlines(keepends=True): - if current_length + len(line) > max_chars or lines_walked == max_lines: - break - current_length += len(line) - lines_walked += 1 - - return content[:current_length] + "#..." - @Cog.listener() async def on_message(self, msg: Message) -> None: """ @@ -277,7 +167,7 @@ class CodeBlockCog(Cog, name="Code Block"): return try: - if self.has_bad_ticks(msg): + if parsing.has_bad_ticks(msg): description = self.format_bad_ticks_message(msg) else: description = self.format_guide_message(msg) @@ -311,7 +201,7 @@ class CodeBlockCog(Cog, name="Code Block"): user_message = await channel.fetch_message(payload.message_id) # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), self.has_bad_ticks(user_message)) + has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), parsing.has_bad_ticks(user_message)) # If the message is fixed, delete the bot message and the entry from the id dictionary if has_fixed_codeblock is None: diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py new file mode 100644 index 000000000..7a096758b --- /dev/null +++ b/bot/cogs/codeblock/parsing.py @@ -0,0 +1,117 @@ +import ast +import logging +import re +from typing import NamedTuple, Sequence + +import discord + +log = logging.getLogger(__name__) + +RE_MARKDOWN = re.compile(r'([*_~`|>])') +RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) +BACKTICK = "`" +TICKS = { + BACKTICK, + "'", + '"', + "\u00b4", # ACUTE ACCENT + "\u2018", # LEFT SINGLE QUOTATION MARK + "\u2019", # RIGHT SINGLE QUOTATION MARK + "\u2032", # PRIME + "\u201c", # LEFT DOUBLE QUOTATION MARK + "\u201d", # RIGHT DOUBLE QUOTATION MARK + "\u2033", # DOUBLE PRIME + "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF +} +RE_CODE_BLOCK = re.compile( + fr""" + ( + ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + ) + ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. + """, + re.DOTALL | re.VERBOSE +) + + +class CodeBlock(NamedTuple): + """Represents a Markdown code block.""" + + content: str + language: str + tick: str + + +def find_code_blocks(message: str) -> Sequence[CodeBlock]: + """ + Find and return all Markdown code blocks in the `message`. + + Code blocks with 3 or less lines are excluded. + + If the `message` contains at least one code block with valid ticks and a specified language, + return an empty sequence. This is based on the assumption that if the user managed to get + one code block right, they already know how to fix the rest themselves. + """ + code_blocks = [] + for _, tick, language, content in RE_CODE_BLOCK.finditer(message): + language = language.strip() + if tick == BACKTICK and language: + return () + elif len(content.split("\n", 3)) > 3: + code_block = CodeBlock(content, language, tick) + code_blocks.append(code_block) + + +def has_bad_ticks(message: discord.Message) -> bool: + """Return True if `message` starts with 3 characters which look like but aren't '`'.""" + return message.content[:3] in TICKS + + +def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python consisting of more than just expressions.""" + try: + # Attempt to parse the message into an AST node. + # Invalid Python code will raise a SyntaxError. + tree = ast.parse(content) + except SyntaxError: + log.trace("Code is not valid Python.") + return False + + # Multiple lines of single words could be interpreted as expressions. + # This check is to avoid all nodes being parsed as expressions. + # (e.g. words over multiple lines) + if not all(isinstance(node, ast.Expr) for node in tree.body): + return True + else: + log.trace("Code consists only of expressions.") + return False + + +def is_repl_code(content: str, threshold: int = 3) -> bool: + """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + repl_lines = 0 + for line in content.splitlines(): + if line.startswith(">>> ") or line.startswith("... "): + repl_lines += 1 + + if repl_lines == threshold: + return True + + return False + + +def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: + """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" + current_length = 0 + lines_walked = 0 + + for line in content.splitlines(keepends=True): + if current_length + len(line) > max_chars or lines_walked == max_lines: + break + current_length += len(line) + lines_walked += 1 + + return content[:current_length] + "#..." -- cgit v1.2.3 From 2a7dcccf7a6b352e3f43b4248d00d9ec15af243e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:30:51 -0700 Subject: Code block: rework the instruction formatting functions A new module, `instructions`, was created to house the functions. 4 ways in which code blocks can be incorrect are considered: 1. The code is not within a code block at all 2. Incorrect characters are used for back ticks 3. A language is not specified 4. A language is specified incorrectly Splitting it up into these 4 cases allows for more specific and relevant instructions to be shown to users. If a message has both incorrect back ticks and an issue with the language specifier, the instructions for fixing both issues are combined. The instructions show a generic code example rather than using the original code from the message. This circumvents any ambiguities when parsing their message and trying to fix it. The escaped code block also failed to preserve indentation. This was a problem because some users would copy it anyway and end up with poorly formatted code. By using a simple example that doesn't rely on indentation, it makes it clear the example is not meant to be copied. Finally, the new examples are shorter and thus make the embed not as giant. --- bot/cogs/codeblock/cog.py | 63 --------------------- bot/cogs/codeblock/instructions.py | 113 +++++++++++++++++++++++++++++++++++++ bot/cogs/codeblock/parsing.py | 2 - 3 files changed, 113 insertions(+), 65 deletions(-) create mode 100644 bot/cogs/codeblock/instructions.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index dad0cc9cc..efc22c8a5 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,6 +1,5 @@ import logging import time -from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -33,68 +32,6 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - def format_bad_ticks_message(self, message: discord.Message) -> Optional[str]: - """Return the guide message to output for bad code block ticks in `message`.""" - ticks = message.content[:3] - content = self.codeblock_stripping(f"```{message.content[3:-3]}```", True) - if content is None: - return - - content, repl_code = content - - if len(content) == 2: - content = content[1] - else: - content = content[0] - - content = parsing.truncate(content) - content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) - - return ( - "It looks like you are trying to paste code into this channel.\n\n" - "You seem to be using the wrong symbols to indicate where the codeblock should start. " - f"The correct symbols would be \\`\\`\\`, not `{ticks}`.\n\n" - "**Here is an example of how it should look:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - - def format_guide_message(self, message: discord.Message) -> Optional[str]: - """Return the guide message to output for a poorly formatted code block in `message`.""" - content = self.codeblock_stripping(message.content, False) - if content is None: - return - - content, repl_code = content - - if not repl_code and not parsing.is_python_code(content[0]): - return - - if content and repl_code: - content = content[1] - else: - content = content[0] - - content = parsing.truncate(content) - - log.debug( - f"{message.author} posted something that needed to be put inside python code " - f"blocks. Sending the user some instructions." - ) - - content_escaped_markdown = parsing.RE_MARKDOWN.sub(r'\\\1', content) - return ( - "It looks like you're trying to paste code into this channel.\n\n" - "Discord has support for Markdown, which allows you to post code with full " - "syntax highlighting. Please use these whenever you paste code, as this " - "helps improve the legibility and makes it easier for us to help you.\n\n" - f"**To do this, use the following method:**\n" - f"\\`\\`\\`python\n{content_escaped_markdown}\n\\`\\`\\`\n\n" - "**This will result in the following:**\n" - f"```python\n{content}\n```" - ) - @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py new file mode 100644 index 000000000..0bcd2eda8 --- /dev/null +++ b/bot/cogs/codeblock/instructions.py @@ -0,0 +1,113 @@ +import logging +from typing import Optional + +from . import parsing + +log = logging.getLogger(__name__) + +PY_LANG_CODES = ("python", "py") +EXAMPLE_PY = f"python\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +EXAMPLE_CODE_BLOCKS = ( + "\\`\\`\\`{content}\n\\`\\`\\`\n\n" + "**This will result in the following:**\n" + "```{content}```" +) + + +def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: + """Return instructions on using the correct ticks for `code_block`.""" + valid_ticks = f"\\{parsing.BACKTICK}" * 3 + + # The space at the end is important here because something may be appended! + instructions = ( + "It looks like you are trying to paste code into this channel.\n\n" + "You seem to be using the wrong symbols to indicate where the code block should start. " + f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " + ) + + # Check if the code has an issue with the language specifier. + addition_msg = get_bad_lang_message(code_block.content) + if not addition_msg: + addition_msg = get_no_lang_message(code_block.content) + + # Combine the back ticks message with the language specifier message. The latter will + # already have an example code block. + if addition_msg: + # The first line has a double line break which is not desirable when appending the msg. + addition_msg = addition_msg.replace("\n\n", "\n", 1) + + # Make the first character of the addition lower case. + instructions += "Furthermore, " + addition_msg[0].lower() + addition_msg[1:] + else: + # Determine the example code to put in the code block based on the language specifier. + if code_block.language.lower() in PY_LANG_CODES: + content = EXAMPLE_PY + elif code_block.language: + # It's not feasible to determine what would be a valid example for other languages. + content = f"{code_block.language}\n..." + else: + content = "Hello, world!" + + example_blocks = EXAMPLE_CODE_BLOCKS.format(content) + instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" + + return instructions + + +def get_no_ticks_message(content: str) -> Optional[str]: + """If `content` is Python/REPL code, return instructions on using code blocks.""" + if parsing.is_repl_code(content) or parsing.is_python_code(content): + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + return ( + "It looks like you're trying to paste code into this channel.\n\n" + "Discord has support for Markdown, which allows you to post code with full " + "syntax highlighting. Please use these whenever you paste code, as this " + "helps improve the legibility and makes it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n{example_blocks}" + ) + + +def get_bad_lang_message(content: str) -> Optional[str]: + """ + Return instructions on fixing the Python language specifier for a code block. + + If `content` doesn't start with "python" or "py" as the language specifier, return None. + """ + stripped = content.lstrip().lower() + lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) + + if lang: + # Note that get_bad_ticks_message expects the first line to have an extra newline. + lines = ["It looks like you incorrectly specified a language for your code block.\n"] + + if content.startswith(" "): + lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") + + if stripped[len(lang)] != "\n": + lines.append( + f"Make sure you put your code on a new line following `{lang}`. " + f"There must not be any spaces after `{lang}`." + ) + + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") + + return "\n".join(lines) + + +def get_no_lang_message(content: str) -> Optional[str]: + """ + Return instructions on specifying a language for a code block. + + If `content` is not valid Python or Python REPL code, return None. + """ + if parsing.is_repl_code(content) or parsing.is_python_code(content): + example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + + # Note that get_bad_ticks_message expects the first line to have an extra newline. + return ( + "It looks like you pasted Python code without syntax highlighting.\n\n" + "Please use syntax highlighting to improve the legibility of your code and make" + "it easier for us to help you.\n\n" + f"**To do this, use the following method:**\n{example_blocks}" + ) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 7a096758b..d541441e0 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -7,8 +7,6 @@ import discord log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') -RE_CODE_BLOCK_LANGUAGE = re.compile(r"```(?:[^\W_]+)\n(.*?)```", re.DOTALL) BACKTICK = "`" TICKS = { BACKTICK, -- cgit v1.2.3 From 59dfd276adabeb8ba643a0b22128af7d765d3210 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:33:09 -0700 Subject: Code block: remove truncate function No longer used anywhere. --- bot/cogs/codeblock/parsing.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index d541441e0..bb71aaaaf 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -99,17 +99,3 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: return True return False - - -def truncate(content: str, max_chars: int = 204, max_lines: int = 10) -> str: - """Return `content` truncated to be at most `max_chars` or `max_lines` in length.""" - current_length = 0 - lines_walked = 0 - - for line in content.splitlines(keepends=True): - if current_length + len(line) > max_chars or lines_walked == max_lines: - break - current_length += len(line) - lines_walked += 1 - - return content[:current_length] + "#..." -- cgit v1.2.3 From a61d0564b46ee4f2cb295317cdad6a47bfd88e13 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:41:37 -0700 Subject: Code block: use new formatting functions in on_message --- bot/cogs/codeblock/cog.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index efc22c8a5..959fc138e 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,7 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import parsing +from . import instructions, parsing log = logging.getLogger(__name__) @@ -90,12 +90,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_message(self, msg: Message) -> None: - """ - Detect poorly formatted Python code in new messages. - - If poorly formatted code is detected, send the user a helpful message explaining how to do - properly formatted Python syntax highlighting codeblocks. - """ + """Detect incorrect Markdown code blocks in `msg` and send instructions to fix them.""" if not self.should_parse(msg): return @@ -103,17 +98,25 @@ class CodeBlockCog(Cog, name="Code Block"): if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: return - try: - if parsing.has_bad_ticks(msg): - description = self.format_bad_ticks_message(msg) + blocks = parsing.find_code_blocks(msg.content) + if not blocks: + # No code blocks found in the message. + description = instructions.get_no_ticks_message(msg.content) + else: + # Get the first code block with invalid ticks. + block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) + + if block: + # A code block exists but has invalid ticks. + description = instructions.get_bad_ticks_message(block) else: - description = self.format_guide_message(msg) - except SyntaxError: - log.trace( - f"SyntaxError while parsing code block sent by {msg.author}; " - f"code posted probably just wasn't Python:\n\n{msg.content}\n\n" - ) - return + # Only other possibility is a block with valid ticks but a missing language. + block = blocks[0] + + # Check for a bad language first to avoid parsing content into an AST. + description = instructions.get_bad_lang_message(block.content) + if not description: + description = instructions.get_no_lang_message(block.content) if description: await self.send_guide_embed(msg, description) -- cgit v1.2.3 From 3fe6c4aac91b691de9b60c9fd89d23539a18b9a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 13:53:11 -0700 Subject: Code block: use find_code_blocks to check if an edited msg was fixed * Remove has_bad_ticks - it's obsolete --- bot/cogs/codeblock/cog.py | 17 ++++++++--------- bot/cogs/codeblock/parsing.py | 7 ------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 959fc138e..19ddb8c73 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -125,7 +125,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Check to see if an edited message (previously called out) still contains poorly formatted code.""" + """Delete the instructions message if an edited message had its code blocks fixed.""" if ( # Checks to see if the message was called out by the bot payload.message_id not in self.codeblock_message_ids @@ -136,16 +136,15 @@ class CodeBlockCog(Cog, name="Code Block"): ): return - # Retrieve channel and message objects for use later - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - user_message = await channel.fetch_message(payload.message_id) + # Parse the message to see if the code blocks have been fixed. + code_blocks = parsing.find_code_blocks(payload.data.get("content")) - # Checks to see if the user has corrected their codeblock. If it's fixed, has_fixed_codeblock will be None - has_fixed_codeblock = self.codeblock_stripping(payload.data.get("content"), parsing.has_bad_ticks(user_message)) + # If the message is fixed, delete the bot message and the entry from the id dictionary. + if not code_blocks: + log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - # If the message is fixed, delete the bot message and the entry from the id dictionary - if has_fixed_codeblock is None: + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + await bot_message.delete() del self.codeblock_message_ids[payload.message_id] - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index bb71aaaaf..88a5c7b7a 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -3,8 +3,6 @@ import logging import re from typing import NamedTuple, Sequence -import discord - log = logging.getLogger(__name__) BACKTICK = "`" @@ -63,11 +61,6 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: code_blocks.append(code_block) -def has_bad_ticks(message: discord.Message) -> bool: - """Return True if `message` starts with 3 characters which look like but aren't '`'.""" - return message.content[:3] in TICKS - - def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" try: -- cgit v1.2.3 From 8c34a279175ee1193cb3a4df625f81758c258da5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:08:37 -0700 Subject: Code block: load the extension --- bot/__main__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/__main__.py b/bot/__main__.py index 4e0d4a111..8bbb7fbb3 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -51,6 +51,7 @@ bot.load_extension("bot.cogs.verification") # Feature cogs bot.load_extension("bot.cogs.alias") +bot.load_extension("bot.cogs.codeblock") bot.load_extension("bot.cogs.defcon") bot.load_extension("bot.cogs.duck_pond") bot.load_extension("bot.cogs.eval") -- cgit v1.2.3 From 8782d3018e5cbc4ef04e4b8e74b90025de3004b3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:18:40 -0700 Subject: Code block: fix find_code_blocks iteration and missing return * Add named capture groups to the regex --- bot/cogs/codeblock/parsing.py | 27 ++++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 88a5c7b7a..9adb4e0ab 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -21,13 +21,13 @@ TICKS = { } RE_CODE_BLOCK = re.compile( fr""" - ( - ([{''.join(TICKS)}]) # Put all ticks into a character class within a group. - \2{{2}} # Match the previous group 2 more times to ensure it's the same char. + (?P + (?P[{''.join(TICKS)}]) # Put all ticks into a character class within a group. + \2{{2}} # Match previous group 2 more times to ensure the same char. ) - ([^\W_]+\n)? # Optionally match a language specifier followed by a newline. - (.+?) # Match the actual code within the block. - \1 # Match the same 3 ticks used at the start of the block. + (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (?P.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. """, re.DOTALL | re.VERBOSE ) @@ -52,14 +52,19 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: one code block right, they already know how to fix the rest themselves. """ code_blocks = [] - for _, tick, language, content in RE_CODE_BLOCK.finditer(message): - language = language.strip() - if tick == BACKTICK and language: + for match in RE_CODE_BLOCK.finditer(message): + # Used to ensure non-matched groups have an empty string as the default value. + groups = match.groupdict("") + language = groups["lang"].strip() # Strip the newline cause it's included in the group. + + if groups["tick"] == BACKTICK and language: return () - elif len(content.split("\n", 3)) > 3: - code_block = CodeBlock(content, language, tick) + elif len(groups["code"].split("\n", 3)) > 3: + code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) + return code_blocks + def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" -- cgit v1.2.3 From 38d07cacadfb34fb4caf536eb792d36a066e3629 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:21:23 -0700 Subject: Code block: fix formatting of example code blocks --- bot/cogs/codeblock/instructions.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0bcd2eda8..6d267239d 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -48,7 +48,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: else: content = "Hello, world!" - example_blocks = EXAMPLE_CODE_BLOCKS.format(content) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions @@ -57,7 +57,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -89,7 +89,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -102,7 +102,7 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 29d4962518e1b0aa1664b676c33b631e634ad9ea Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:21:44 -0700 Subject: Code block: fix missing space between words in message --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 6d267239d..0f05e68b1 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -107,7 +107,7 @@ def get_no_lang_message(content: str) -> Optional[str]: # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" - "Please use syntax highlighting to improve the legibility of your code and make" + "Please use syntax highlighting to improve the legibility of your code and make " "it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) -- cgit v1.2.3 From 30967602e2faabb6654d30c1fc7e1c4f4e3d2919 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:24:49 -0700 Subject: Code block: fix formatting of the additional message The newlines should be replaced with a space rather than with 1 newline. To separate the two issues, a double newline is prepended to the entire additional message. --- bot/cogs/codeblock/instructions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0f05e68b1..dec5af874 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -34,10 +34,10 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: # already have an example code block. if addition_msg: # The first line has a double line break which is not desirable when appending the msg. - addition_msg = addition_msg.replace("\n\n", "\n", 1) + addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. - instructions += "Furthermore, " + addition_msg[0].lower() + addition_msg[1:] + instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: -- cgit v1.2.3 From 0eca42cee34672fd59b82d0b36a70627a13d6354 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 14:30:37 -0700 Subject: Code block: use same lang specifier as the user for the py example Keeping examples consistent will hopefully make things clearer to the user. --- bot/cogs/codeblock/instructions.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index dec5af874..9de418765 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -6,7 +6,7 @@ from . import parsing log = logging.getLogger(__name__) PY_LANG_CODES = ("python", "py") -EXAMPLE_PY = f"python\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" "**This will result in the following:**\n" @@ -41,7 +41,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: else: # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: - content = EXAMPLE_PY + content = EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: # It's not feasible to determine what would be a valid example for other languages. content = f"{code_block.language}\n..." @@ -57,7 +57,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -89,7 +89,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang=lang)) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -102,7 +102,7 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY) + example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) # Note that get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 6ec3c712113d350cc027a503ebb0951cfa2fd65a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:39:31 -0700 Subject: Code block: add trace logging --- bot/cogs/codeblock/cog.py | 17 +++++++++++++---- bot/cogs/codeblock/instructions.py | 26 ++++++++++++++++++++++++-- bot/cogs/codeblock/parsing.py | 11 +++++++++++ 3 files changed, 48 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 19ddb8c73..e4b87938d 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -35,6 +35,7 @@ class CodeBlockCog(Cog, name="Code Block"): @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" + log.trace(f"Checking if #{channel} is a help channel.") return ( getattr(channel, "category", None) and channel.category.id in (Categories.help_available, Categories.help_in_use) @@ -46,10 +47,12 @@ class CodeBlockCog(Cog, name="Code Block"): Note: only channels in the `channel_cooldowns` have cooldowns enabled. """ + log.trace(f"Checking if #{channel} is on cooldown.") return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( self.is_help_channel(channel) or channel.id in self.channel_cooldowns @@ -62,6 +65,8 @@ class CodeBlockCog(Cog, name="Code Block"): The embed will be deleted automatically after 5 minutes. """ + log.trace("Sending an embed with code block formatting instructions.") + embed = Embed(description=description) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id @@ -92,25 +97,27 @@ class CodeBlockCog(Cog, name="Code Block"): async def on_message(self, msg: Message) -> None: """Detect incorrect Markdown code blocks in `msg` and send instructions to fix them.""" if not self.should_parse(msg): + log.trace(f"Skipping code block detection of {msg.id}: message doesn't qualify.") return # When debugging, ignore cooldowns. if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return blocks = parsing.find_code_blocks(msg.content) if not blocks: - # No code blocks found in the message. + log.trace(f"No code blocks were found in message {msg.id}.") description = instructions.get_no_ticks_message(msg.content) else: - # Get the first code block with invalid ticks. + log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: - # A code block exists but has invalid ticks. + log.trace(f"A code block exists in {msg.id} but has invalid ticks.") description = instructions.get_bad_ticks_message(block) else: - # Only other possibility is a block with valid ticks but a missing language. + log.trace(f"A code block exists in {msg.id} but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. @@ -121,6 +128,7 @@ class CodeBlockCog(Cog, name="Code Block"): if description: await self.send_guide_embed(msg, description) if msg.channel.id not in self.channel_whitelist: + log.trace(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() @@ -134,6 +142,7 @@ class CodeBlockCog(Cog, name="Code Block"): # Makes sure there's a channel id in the message payload or payload.data.get("channel_id") is None ): + log.trace("Message edit does not qualify for code block detection.") return # Parse the message to see if the code blocks have been fixed. diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 9de418765..28242ce75 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,7 +5,7 @@ from . import parsing log = logging.getLogger(__name__) -PY_LANG_CODES = ("python", "py") +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" @@ -16,6 +16,7 @@ EXAMPLE_CODE_BLOCKS = ( def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" + log.trace("Creating instructions for incorrect code block ticks.") valid_ticks = f"\\{parsing.BACKTICK}" * 3 # The space at the end is important here because something may be appended! @@ -25,7 +26,7 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " ) - # Check if the code has an issue with the language specifier. + log.trace("Check if the bad ticks code block also has issues with the language specifier.") addition_msg = get_bad_lang_message(code_block.content) if not addition_msg: addition_msg = get_no_lang_message(code_block.content) @@ -33,19 +34,26 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: # Combine the back ticks message with the language specifier message. The latter will # already have an example code block. if addition_msg: + log.trace("Language specifier issue found; appending additional instructions.") + # The first line has a double line break which is not desirable when appending the msg. addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: + log.trace("No issues with the language specifier found.") + # Determine the example code to put in the code block based on the language specifier. if code_block.language.lower() in PY_LANG_CODES: + log.trace(f"Code block has a Python language specifier `{code_block.language}`.") content = EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: + log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") # It's not feasible to determine what would be a valid example for other languages. content = f"{code_block.language}\n..." else: + log.trace("Code block has no language specifier (and the code isn't valid Python).") content = "Hello, world!" example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) @@ -56,6 +64,8 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: def get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" + log.trace("Creating instructions for a missing code block.") + if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) return ( @@ -65,6 +75,8 @@ def get_no_ticks_message(content: str) -> Optional[str]: "helps improve the legibility and makes it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) + else: + log.trace("Aborting missing code block instructions: content is not Python code.") def get_bad_lang_message(content: str) -> Optional[str]: @@ -73,6 +85,8 @@ def get_bad_lang_message(content: str) -> Optional[str]: If `content` doesn't start with "python" or "py" as the language specifier, return None. """ + log.trace("Creating instructions for a poorly specified language.") + stripped = content.lstrip().lower() lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) @@ -81,9 +95,11 @@ def get_bad_lang_message(content: str) -> Optional[str]: lines = ["It looks like you incorrectly specified a language for your code block.\n"] if content.startswith(" "): + log.trace("Language specifier was preceded by a space.") lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") if stripped[len(lang)] != "\n": + log.trace("Language specifier was not followed by a newline.") lines.append( f"Make sure you put your code on a new line following `{lang}`. " f"There must not be any spaces after `{lang}`." @@ -93,6 +109,8 @@ def get_bad_lang_message(content: str) -> Optional[str]: lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) + else: + log.trace("Aborting bad language instructions: language specified isn't Python.") def get_no_lang_message(content: str) -> Optional[str]: @@ -101,6 +119,8 @@ def get_no_lang_message(content: str) -> Optional[str]: If `content` is not valid Python or Python REPL code, return None. """ + log.trace("Creating instructions for a missing language.") + if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) @@ -111,3 +131,5 @@ def get_no_lang_message(content: str) -> Optional[str]: "it easier for us to help you.\n\n" f"**To do this, use the following method:**\n{example_blocks}" ) + else: + log.trace("Aborting missing language instructions: content is not Python code.") diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 9adb4e0ab..7409653d7 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -51,6 +51,8 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: return an empty sequence. This is based on the assumption that if the user managed to get one code block right, they already know how to fix the rest themselves. """ + log.trace("Finding all code blocks in a message.") + code_blocks = [] for match in RE_CODE_BLOCK.finditer(message): # Used to ensure non-matched groups have an empty string as the default value. @@ -58,16 +60,20 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: language = groups["lang"].strip() # Strip the newline cause it's included in the group. if groups["tick"] == BACKTICK and language: + log.trace("Message has a valid code block with a language; returning empty tuple.") return () elif len(groups["code"].split("\n", 3)) > 3: code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) + else: + log.trace("Skipped a code block shorter than 4 lines.") return code_blocks def is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" + log.trace("Checking if content is Python code.") try: # Attempt to parse the message into an AST node. # Invalid Python code will raise a SyntaxError. @@ -80,6 +86,7 @@ def is_python_code(content: str) -> bool: # This check is to avoid all nodes being parsed as expressions. # (e.g. words over multiple lines) if not all(isinstance(node, ast.Expr) for node in tree.body): + log.trace("Code is valid python.") return True else: log.trace("Code consists only of expressions.") @@ -88,12 +95,16 @@ def is_python_code(content: str) -> bool: def is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" + log.trace(f"Checking if content is Python REPL code using a threshold of {threshold}.") + repl_lines = 0 for line in content.splitlines(): if line.startswith(">>> ") or line.startswith("... "): repl_lines += 1 if repl_lines == threshold: + log.trace("Content is Python REPL code.") return True + log.trace("Content is not Python REPL code.") return False -- cgit v1.2.3 From 808fe261cb0163fe5759da36e36418fc392cb846 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:46:07 -0700 Subject: Code block: fix valid code block being parsed as a missing block `find_code_blocks` was returning an empty tuple if there was at least one valid code block. However, the caller could not distinguish between that case and simply no code blocks being found. Therefore, None is explicitly returned to distinguish it from a lack of results. --- bot/cogs/codeblock/cog.py | 3 +++ bot/cogs/codeblock/parsing.py | 12 ++++++------ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e4b87938d..15dffce7a 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -106,6 +106,9 @@ class CodeBlockCog(Cog, name="Code Block"): return blocks = parsing.find_code_blocks(msg.content) + if blocks is None: + # None is returned when there's at least one valid block with a language. + return if not blocks: log.trace(f"No code blocks were found in message {msg.id}.") description = instructions.get_no_ticks_message(msg.content) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 7409653d7..055c21118 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -1,7 +1,7 @@ import ast import logging import re -from typing import NamedTuple, Sequence +from typing import NamedTuple, Optional, Sequence log = logging.getLogger(__name__) @@ -41,15 +41,15 @@ class CodeBlock(NamedTuple): tick: str -def find_code_blocks(message: str) -> Sequence[CodeBlock]: +def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. Code blocks with 3 or less lines are excluded. If the `message` contains at least one code block with valid ticks and a specified language, - return an empty sequence. This is based on the assumption that if the user managed to get - one code block right, they already know how to fix the rest themselves. + return None. This is based on the assumption that if the user managed to get one code block + right, they already know how to fix the rest themselves. """ log.trace("Finding all code blocks in a message.") @@ -60,8 +60,8 @@ def find_code_blocks(message: str) -> Sequence[CodeBlock]: language = groups["lang"].strip() # Strip the newline cause it's included in the group. if groups["tick"] == BACKTICK and language: - log.trace("Message has a valid code block with a language; returning empty tuple.") - return () + log.trace("Message has a valid code block with a language; returning None.") + return None elif len(groups["code"].split("\n", 3)) > 3: code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) -- cgit v1.2.3 From 45a13341f0eba0b04d57a5e240748e4939ab97a3 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 22:58:43 -0700 Subject: Code block: move instructions deletion to a separate function --- bot/cogs/codeblock/cog.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 15dffce7a..396353d40 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -59,6 +59,21 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) + async def remove_instructions(self, payload: RawMessageUpdateEvent) -> None: + """ + Remove the code block instructions message. + + `payload` is the data for the message edit event performed by a user which resulted in their + code blocks being corrected. + """ + log.trace("User's incorrect code block has been fixed. Removing instructions message.") + + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] + async def send_guide_embed(self, message: discord.Message, description: str) -> None: """ Send an embed with `description` as a guide for an improperly formatted `message`. @@ -153,10 +168,4 @@ class CodeBlockCog(Cog, name="Code Block"): # If the message is fixed, delete the bot message and the entry from the id dictionary. if not code_blocks: - log.trace("User's incorrect code block has been fixed. Removing bot formatting message.") - - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] + await self.remove_instructions(payload) -- cgit v1.2.3 From e03c194242b16d5f5ef9d937a13daef424800bec Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:12:20 -0700 Subject: Code block: move instructions retrieval to a separate function Not only is it cleaner and more testable, but it allows for other functions to also retrieve instructions. --- bot/cogs/codeblock/cog.py | 32 ++++++-------------------------- bot/cogs/codeblock/instructions.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 396353d40..23d5267a9 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,8 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import instructions, parsing +from . import parsing +from .instructions import get_instructions log = logging.getLogger(__name__) @@ -120,31 +121,10 @@ class CodeBlockCog(Cog, name="Code Block"): log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return - blocks = parsing.find_code_blocks(msg.content) - if blocks is None: - # None is returned when there's at least one valid block with a language. - return - if not blocks: - log.trace(f"No code blocks were found in message {msg.id}.") - description = instructions.get_no_ticks_message(msg.content) - else: - log.trace("Searching results for a code block with invalid ticks.") - block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) - - if block: - log.trace(f"A code block exists in {msg.id} but has invalid ticks.") - description = instructions.get_bad_ticks_message(block) - else: - log.trace(f"A code block exists in {msg.id} but is missing a language.") - block = blocks[0] - - # Check for a bad language first to avoid parsing content into an AST. - description = instructions.get_bad_lang_message(block.content) - if not description: - description = instructions.get_no_lang_message(block.content) - - if description: - await self.send_guide_embed(msg, description) + instructions = get_instructions(msg.content) + if instructions: + await self.send_guide_embed(msg, instructions) + if msg.channel.id not in self.channel_whitelist: log.trace(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 28242ce75..d331dd2ee 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -133,3 +133,34 @@ def get_no_lang_message(content: str) -> Optional[str]: ) else: log.trace("Aborting missing language instructions: content is not Python code.") + + +def get_instructions(content: str) -> Optional[str]: + """Return code block formatting instructions for `content` or None if nothing's wrong.""" + log.trace("Getting formatting instructions.") + + blocks = parsing.find_code_blocks(content) + if blocks is None: + log.trace("At least one valid code block found; no instructions to return.") + return + + if not blocks: + log.trace(f"No code blocks were found in message.") + return get_no_ticks_message(content) + else: + log.trace("Searching results for a code block with invalid ticks.") + block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) + + if block: + log.trace(f"A code block exists but has invalid ticks.") + return get_bad_ticks_message(block) + else: + log.trace(f"A code block exists but is missing a language.") + block = blocks[0] + + # Check for a bad language first to avoid parsing content into an AST. + description = get_bad_lang_message(block.content) + if not description: + description = get_no_lang_message(block.content) + + return description -- cgit v1.2.3 From ee8dae3ff890369ba7cd9badaa0e45ddcb926c8c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:29:13 -0700 Subject: Code block: move bot message retrieval to a separate function This bot message retrieval is the actual part of `remove_instructions` that will soon get re-used elsewhere. * Remove `remove_instructions` since it became a bit too simple given the separation of bot message retrieval. --- bot/cogs/codeblock/cog.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 23d5267a9..276bf8f9b 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -33,6 +33,13 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> discord.Message: + """Return the bot's sent instructions message using the user message ID from a `payload`.""" + log.trace(f"Retrieving instructions message for ID {payload.message_id}") + + channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is in one of the help categories.""" @@ -60,21 +67,6 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) - async def remove_instructions(self, payload: RawMessageUpdateEvent) -> None: - """ - Remove the code block instructions message. - - `payload` is the data for the message edit event performed by a user which resulted in their - code blocks being corrected. - """ - log.trace("User's incorrect code block has been fixed. Removing instructions message.") - - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - bot_message = await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) - - await bot_message.delete() - del self.codeblock_message_ids[payload.message_id] - async def send_guide_embed(self, message: discord.Message, description: str) -> None: """ Send an embed with `description` as a guide for an improperly formatted `message`. @@ -148,4 +140,7 @@ class CodeBlockCog(Cog, name="Code Block"): # If the message is fixed, delete the bot message and the entry from the id dictionary. if not code_blocks: - await self.remove_instructions(payload) + log.trace("User's incorrect code block has been fixed. Removing instructions message.") + bot_message = await self.get_sent_instructions(payload) + await bot_message.delete() + del self.codeblock_message_ids[payload.message_id] -- cgit v1.2.3 From fd4bed07a08a5fdbd482345c99838131dba45e98 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 6 May 2020 23:35:20 -0700 Subject: Code block: edit instructions if edited message is still invalid Editing instructions means the user will always see what is currently relevant to them. Sometimes an incorrect edit could result in a different problem that was not mentioned in the original instructions. This change also fixes detection of fixed messages by using the same detection logic as the original `on_message`. Previously, it considered an edited message without code blocks to be fixed. --- bot/cogs/codeblock/cog.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 276bf8f9b..5844f4d16 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,7 +8,6 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Categories, Channels, DEBUG_MODE from bot.utils.messages import wait_for_deletion -from . import parsing from .instructions import get_instructions log = logging.getLogger(__name__) @@ -136,11 +135,14 @@ class CodeBlockCog(Cog, name="Code Block"): return # Parse the message to see if the code blocks have been fixed. - code_blocks = parsing.find_code_blocks(payload.data.get("content")) + content = payload.data.get("content") + instructions = get_instructions(content) + bot_message = await self.get_sent_instructions(payload) - # If the message is fixed, delete the bot message and the entry from the id dictionary. - if not code_blocks: + if not instructions: log.trace("User's incorrect code block has been fixed. Removing instructions message.") - bot_message = await self.get_sent_instructions(payload) await bot_message.delete() del self.codeblock_message_ids[payload.message_id] + else: + log.trace("Message edited but still has invalid code blocks; editing the instructions.") + await bot_message.edit(content=instructions) -- cgit v1.2.3 From b86d9a66519b2c8b8c50c255c8b23d924be35f5a Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 11:09:44 -0700 Subject: Code block: clarify log messages in message edit event If statement was separated so there could be separate messages that are more specific. The message ID was also included to distinguish events. --- bot/cogs/codeblock/cog.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 5844f4d16..0f0a8cd51 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -123,15 +123,12 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: """Delete the instructions message if an edited message had its code blocks fixed.""" - if ( - # Checks to see if the message was called out by the bot - payload.message_id not in self.codeblock_message_ids - # Makes sure that there is content in the message - or payload.data.get("content") is None - # Makes sure there's a channel id in the message payload - or payload.data.get("channel_id") is None - ): - log.trace("Message edit does not qualify for code block detection.") + if payload.message_id not in self.codeblock_message_ids: + log.trace(f"Ignoring message edit {payload.message_id}: message isn't being tracked.") + return + + if payload.data.get("content") is None or payload.data.get("channel_id") is None: + log.trace(f"Ignoring message edit {payload.message_id}: missing content or channel ID.") return # Parse the message to see if the code blocks have been fixed. -- cgit v1.2.3 From 3728d8a1e8bbf9cfb0dce7a9a548c6527b554290 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 11:16:41 -0700 Subject: Code block: fix error retrieving a deleted instructions message --- bot/cogs/codeblock/cog.py | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 0f0a8cd51..f64ac8c45 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -1,5 +1,6 @@ import logging import time +from typing import Optional import discord from discord import Embed, Message, RawMessageUpdateEvent @@ -32,12 +33,21 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} - async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> discord.Message: - """Return the bot's sent instructions message using the user message ID from a `payload`.""" - log.trace(f"Retrieving instructions message for ID {payload.message_id}") + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]: + """ + Return the bot's sent instructions message associated with a user's message `payload`. + Return None if the message cannot be found. In this case, it's likely the message was + deleted either manually via a reaction or automatically by a timer. + """ + log.trace(f"Retrieving instructions message for ID {payload.message_id}") channel = self.bot.get_channel(int(payload.data.get("channel_id"))) - return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + + try: + return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) + except discord.NotFound: + log.debug("Could not find instructions message; it was probably deleted.") + return None @staticmethod def is_help_channel(channel: discord.TextChannel) -> bool: @@ -134,7 +144,10 @@ class CodeBlockCog(Cog, name="Code Block"): # Parse the message to see if the code blocks have been fixed. content = payload.data.get("content") instructions = get_instructions(content) + bot_message = await self.get_sent_instructions(payload) + if not bot_message: + return if not instructions: log.trace("User's incorrect code block has been fixed. Removing instructions message.") -- cgit v1.2.3 From 2694cbff786154fb8ba1211b0954f12312b71016 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 12:55:16 -0700 Subject: Code block: refactor `send_guide_embed` * Rename to `send_instructions` to be consistent with the use of "instructions" rather than "guide" elsewhere * Rename the `description` parameter to `instructions` --- bot/cogs/codeblock/cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index f64ac8c45..38daa7974 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -76,15 +76,15 @@ class CodeBlockCog(Cog, name="Code Block"): or channel.id in self.channel_whitelist ) - async def send_guide_embed(self, message: discord.Message, description: str) -> None: + async def send_instructions(self, message: discord.Message, instructions: str) -> None: """ - Send an embed with `description` as a guide for an improperly formatted `message`. + Send an embed with `instructions` on fixing an incorrect code block in a `message`. The embed will be deleted automatically after 5 minutes. """ log.trace("Sending an embed with code block formatting instructions.") - embed = Embed(description=description) + embed = Embed(description=instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id @@ -124,7 +124,7 @@ class CodeBlockCog(Cog, name="Code Block"): instructions = get_instructions(msg.content) if instructions: - await self.send_guide_embed(msg, instructions) + await self.send_instructions(msg, instructions) if msg.channel.id not in self.channel_whitelist: log.trace(f"Adding #{msg.channel} to the channel cooldowns.") -- cgit v1.2.3 From 7468aff92bc6cd658b334d89e7049c98b8ae0439 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 16:26:17 -0700 Subject: Code block: rename some things to be "private" --- bot/cogs/codeblock/instructions.py | 44 +++++++++++++++++++------------------- bot/cogs/codeblock/parsing.py | 8 +++---- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index d331dd2ee..abdf092fe 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,16 +5,16 @@ from . import parsing log = logging.getLogger(__name__) -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. -EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. -EXAMPLE_CODE_BLOCKS = ( +_PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +_EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. +_EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" "**This will result in the following:**\n" "```{content}```" ) -def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: +def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") valid_ticks = f"\\{parsing.BACKTICK}" * 3 @@ -27,9 +27,9 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: ) log.trace("Check if the bad ticks code block also has issues with the language specifier.") - addition_msg = get_bad_lang_message(code_block.content) + addition_msg = _get_bad_lang_message(code_block.content) if not addition_msg: - addition_msg = get_no_lang_message(code_block.content) + addition_msg = _get_no_lang_message(code_block.content) # Combine the back ticks message with the language specifier message. The latter will # already have an example code block. @@ -45,9 +45,9 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("No issues with the language specifier found.") # Determine the example code to put in the code block based on the language specifier. - if code_block.language.lower() in PY_LANG_CODES: + if code_block.language.lower() in _PY_LANG_CODES: log.trace(f"Code block has a Python language specifier `{code_block.language}`.") - content = EXAMPLE_PY.format(lang=code_block.language) + content = _EXAMPLE_PY.format(lang=code_block.language) elif code_block.language: log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") # It's not feasible to determine what would be a valid example for other languages. @@ -56,18 +56,18 @@ def get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("Code block has no language specifier (and the code isn't valid Python).") content = "Hello, world!" - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=content) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=content) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions -def get_no_ticks_message(content: str) -> Optional[str]: +def _get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" log.trace("Creating instructions for a missing code block.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -79,7 +79,7 @@ def get_no_ticks_message(content: str) -> Optional[str]: log.trace("Aborting missing code block instructions: content is not Python code.") -def get_bad_lang_message(content: str) -> Optional[str]: +def _get_bad_lang_message(content: str) -> Optional[str]: """ Return instructions on fixing the Python language specifier for a code block. @@ -88,10 +88,10 @@ def get_bad_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a poorly specified language.") stripped = content.lstrip().lower() - lang = next((lang for lang in PY_LANG_CODES if stripped.startswith(lang)), None) + lang = next((lang for lang in _PY_LANG_CODES if stripped.startswith(lang)), None) if lang: - # Note that get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have an extra newline. lines = ["It looks like you incorrectly specified a language for your code block.\n"] if content.startswith(" "): @@ -105,7 +105,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang=lang)) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang=lang)) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -113,7 +113,7 @@ def get_bad_lang_message(content: str) -> Optional[str]: log.trace("Aborting bad language instructions: language specified isn't Python.") -def get_no_lang_message(content: str) -> Optional[str]: +def _get_no_lang_message(content: str) -> Optional[str]: """ Return instructions on specifying a language for a code block. @@ -122,9 +122,9 @@ def get_no_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing language.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = EXAMPLE_CODE_BLOCKS.format(content=EXAMPLE_PY.format(lang="python")) + example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) - # Note that get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have an extra newline. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" "Please use syntax highlighting to improve the legibility of your code and make " @@ -146,21 +146,21 @@ def get_instructions(content: str) -> Optional[str]: if not blocks: log.trace(f"No code blocks were found in message.") - return get_no_ticks_message(content) + return _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: log.trace(f"A code block exists but has invalid ticks.") - return get_bad_ticks_message(block) + return _get_bad_ticks_message(block) else: log.trace(f"A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. - description = get_bad_lang_message(block.content) + description = _get_bad_lang_message(block.content) if not description: - description = get_no_lang_message(block.content) + description = _get_no_lang_message(block.content) return description diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 055c21118..a49ecc8f7 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -6,7 +6,7 @@ from typing import NamedTuple, Optional, Sequence log = logging.getLogger(__name__) BACKTICK = "`" -TICKS = { +_TICKS = { BACKTICK, "'", '"', @@ -19,10 +19,10 @@ TICKS = { "\u2033", # DOUBLE PRIME "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } -RE_CODE_BLOCK = re.compile( +_RE_CODE_BLOCK = re.compile( fr""" (?P - (?P[{''.join(TICKS)}]) # Put all ticks into a character class within a group. + (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. \2{{2}} # Match previous group 2 more times to ensure the same char. ) (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. @@ -54,7 +54,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: log.trace("Finding all code blocks in a message.") code_blocks = [] - for match in RE_CODE_BLOCK.finditer(message): + for match in _RE_CODE_BLOCK.finditer(message): # Used to ensure non-matched groups have an empty string as the default value. groups = match.groupdict("") language = groups["lang"].strip() # Strip the newline cause it's included in the group. -- cgit v1.2.3 From c98666d42e325cc8de11d6a271015b2a546a65b1 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 17:22:23 -0700 Subject: Code block: create a function to format the example code blocks First, this reduces code redundancy. Furthermore, it moves the relatively big block of code for checking the language away from `_get_bad_ticks_message` and into its own, smaller unit. --- bot/cogs/codeblock/instructions.py | 40 ++++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index abdf092fe..bba84c66a 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -14,6 +14,25 @@ _EXAMPLE_CODE_BLOCKS = ( ) +def _get_example(language: str) -> str: + """Return an example of a correct code block using `language` for syntax highlighting.""" + language_lower = language.lower() # It's only valid if it's all lowercase. + + # Determine the example code to put in the code block based on the language specifier. + if language_lower in _PY_LANG_CODES: + log.trace(f"Code block has a Python language specifier `{language}`.") + content = _EXAMPLE_PY.format(lang=language_lower) + elif language_lower: + log.trace(f"Code block has a foreign language specifier `{language}`.") + # It's not feasible to determine what would be a valid example for other languages. + content = f"{language_lower}\n..." + else: + log.trace("Code block has no language specifier.") + content = "Hello, world!" + + return _EXAMPLE_CODE_BLOCKS.format(content=content) + + def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") @@ -43,20 +62,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] else: log.trace("No issues with the language specifier found.") - - # Determine the example code to put in the code block based on the language specifier. - if code_block.language.lower() in _PY_LANG_CODES: - log.trace(f"Code block has a Python language specifier `{code_block.language}`.") - content = _EXAMPLE_PY.format(lang=code_block.language) - elif code_block.language: - log.trace(f"Code block has a foreign language specifier `{code_block.language}`.") - # It's not feasible to determine what would be a valid example for other languages. - content = f"{code_block.language}\n..." - else: - log.trace("Code block has no language specifier (and the code isn't valid Python).") - content = "Hello, world!" - - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=content) + example_blocks = _get_example(code_block.language) instructions += f"\n\n**Here is an example of how it should look:**\n{example_blocks}" return instructions @@ -67,7 +73,7 @@ def _get_no_ticks_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing code block.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) + example_blocks = _get_example("python") return ( "It looks like you're trying to paste code into this channel.\n\n" "Discord has support for Markdown, which allows you to post code with full " @@ -105,7 +111,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{lang}`." ) - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang=lang)) + example_blocks = _get_example(lang) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) @@ -122,7 +128,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: log.trace("Creating instructions for a missing language.") if parsing.is_repl_code(content) or parsing.is_python_code(content): - example_blocks = _EXAMPLE_CODE_BLOCKS.format(content=_EXAMPLE_PY.format(lang="python")) + example_blocks = _get_example("python") # Note that _get_bad_ticks_message expects the first line to have an extra newline. return ( -- cgit v1.2.3 From 2bfac307c4b06682db93e2a75108012a586d1c7d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 18:33:34 -0700 Subject: Code block: use regex to parse incorrect languages Regex is simpler and more versatile in this case. The functions in the `instructions` module should be more focused on formatting than parsing, so the parsing was moved to the `parsing` module. * Move _PY_LANG_CODES to the `parsing` module * Create a separate function in the `parsing` module to parse bad languages --- bot/cogs/codeblock/instructions.py | 30 +++++++++++++---------------- bot/cogs/codeblock/parsing.py | 39 +++++++++++++++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 18 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index bba84c66a..c1a6645b3 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -5,7 +5,6 @@ from . import parsing log = logging.getLogger(__name__) -_PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _EXAMPLE_PY = "{lang}\nprint('Hello, world!')" # Make sure to escape any Markdown symbols here. _EXAMPLE_CODE_BLOCKS = ( "\\`\\`\\`{content}\n\\`\\`\\`\n\n" @@ -16,16 +15,14 @@ _EXAMPLE_CODE_BLOCKS = ( def _get_example(language: str) -> str: """Return an example of a correct code block using `language` for syntax highlighting.""" - language_lower = language.lower() # It's only valid if it's all lowercase. - # Determine the example code to put in the code block based on the language specifier. - if language_lower in _PY_LANG_CODES: + if language.lower() in parsing.PY_LANG_CODES: log.trace(f"Code block has a Python language specifier `{language}`.") - content = _EXAMPLE_PY.format(lang=language_lower) - elif language_lower: + content = _EXAMPLE_PY.format(lang=language) + elif language: log.trace(f"Code block has a foreign language specifier `{language}`.") # It's not feasible to determine what would be a valid example for other languages. - content = f"{language_lower}\n..." + content = f"{language}\n..." else: log.trace("Code block has no language specifier.") content = "Hello, world!" @@ -92,26 +89,25 @@ def _get_bad_lang_message(content: str) -> Optional[str]: If `content` doesn't start with "python" or "py" as the language specifier, return None. """ log.trace("Creating instructions for a poorly specified language.") + info = parsing.parse_bad_language(content) - stripped = content.lstrip().lower() - lang = next((lang for lang in _PY_LANG_CODES if stripped.startswith(lang)), None) - - if lang: + if info: # Note that _get_bad_ticks_message expects the first line to have an extra newline. lines = ["It looks like you incorrectly specified a language for your code block.\n"] + language = info.language - if content.startswith(" "): + if info.leading_spaces: log.trace("Language specifier was preceded by a space.") - lines.append(f"Make sure there are no spaces between the back ticks and `{lang}`.") + lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if stripped[len(lang)] != "\n": + if not info.terminal_newline: log.trace("Language specifier was not followed by a newline.") lines.append( - f"Make sure you put your code on a new line following `{lang}`. " - f"There must not be any spaces after `{lang}`." + f"Make sure you put your code on a new line following `{language}`. " + f"There must not be any spaces after `{language}`." ) - example_blocks = _get_example(lang) + example_blocks = _get_example(language) lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") return "\n".join(lines) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index a49ecc8f7..6fa6811cc 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -22,7 +22,7 @@ _TICKS = { _RE_CODE_BLOCK = re.compile( fr""" (?P - (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. + (?P[{''.join(_TICKS)}]) # Put all ticks into a character class within a group. \2{{2}} # Match previous group 2 more times to ensure the same char. ) (?P[^\W_]+\n)? # Optionally match a language specifier followed by a newline. @@ -32,6 +32,16 @@ _RE_CODE_BLOCK = re.compile( re.DOTALL | re.VERBOSE ) +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +_RE_LANGUAGE = re.compile( + fr""" + ^(?P\s+)? # Optionally match leading spaces from the beginning. + (?P{'|'.join(PY_LANG_CODES)}) # Match a Python language. + (?P\n)? # Optionally match a newline following the language. + """, + re.IGNORECASE | re.VERBOSE +) + class CodeBlock(NamedTuple): """Represents a Markdown code block.""" @@ -41,6 +51,14 @@ class CodeBlock(NamedTuple): tick: str +class BadLanguage(NamedTuple): + """Parsed information about a poorly formatted language specifier.""" + + language: str + leading_spaces: bool + terminal_newline: bool + + def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. @@ -108,3 +126,22 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: log.trace("Content is not Python REPL code.") return False + + +def parse_bad_language(content: str) -> Optional[BadLanguage]: + """ + Return information about a poorly formatted Python language in code block `content`. + + If the language is not Python, return None. + """ + log.trace("Parsing bad language.") + + match = _RE_LANGUAGE.match(content) + if not match: + return None + + return BadLanguage( + language=match["lang"], + leading_spaces=match["spaces"] is not None, + terminal_newline=match["newline"] is not None, + ) -- cgit v1.2.3 From ae0f29ee8680c75d59eefa2f1563f6c906539aa9 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:18:47 -0700 Subject: Code block: add function to create the instructions embed While it may be simple now, if the embed needs to changed later, it won't need to be done in multiple places since everything can rely on this function to create the embed. --- bot/cogs/codeblock/cog.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 38daa7974..ca787b181 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -3,7 +3,7 @@ import time from typing import Optional import discord -from discord import Embed, Message, RawMessageUpdateEvent +from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover @@ -33,6 +33,11 @@ class CodeBlockCog(Cog, name="Code Block"): # Stores improperly formatted Python codeblock message ids and the corresponding bot message self.codeblock_message_ids = {} + @staticmethod + def create_embed(instructions: str) -> discord.Embed: + """Return an embed which displays code block formatting `instructions`.""" + return discord.Embed(description=instructions) + async def get_sent_instructions(self, payload: RawMessageUpdateEvent) -> Optional[Message]: """ Return the bot's sent instructions message associated with a user's message `payload`. @@ -84,7 +89,7 @@ class CodeBlockCog(Cog, name="Code Block"): """ log.trace("Sending an embed with code block formatting instructions.") - embed = Embed(description=instructions) + embed = self.create_embed(instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) self.codeblock_message_ids[message.id] = bot_message.id -- cgit v1.2.3 From cad6957b233ed905ed76d066517866255c8ae7a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:19:46 -0700 Subject: Code block: fix message content being edited instead of the embed --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ca787b181..80d5adff3 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -160,4 +160,4 @@ class CodeBlockCog(Cog, name="Code Block"): del self.codeblock_message_ids[payload.message_id] else: log.trace("Message edited but still has invalid code blocks; editing the instructions.") - await bot_message.edit(content=instructions) + await bot_message.edit(embed=self.create_embed(instructions)) -- cgit v1.2.3 From 4b1a1cdd91023baa0da9959e1cc8b811c0aa9795 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 19:34:20 -0700 Subject: Code block: join bad language instructions by spaces It was a mistake to join them by newlines in the first place. It looks and reads better as a paragraph. * Remove extra space after bad ticks instructions --- bot/cogs/codeblock/instructions.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c1a6645b3..3cc955a1a 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -33,13 +33,12 @@ def _get_example(language: str) -> str: def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: """Return instructions on using the correct ticks for `code_block`.""" log.trace("Creating instructions for incorrect code block ticks.") - valid_ticks = f"\\{parsing.BACKTICK}" * 3 - # The space at the end is important here because something may be appended! + valid_ticks = f"\\{parsing.BACKTICK}" * 3 instructions = ( "It looks like you are trying to paste code into this channel.\n\n" "You seem to be using the wrong symbols to indicate where the code block should start. " - f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`. " + f"The correct symbols would be {valid_ticks}, not `{code_block.tick * 3}`." ) log.trace("Check if the bad ticks code block also has issues with the language specifier.") @@ -52,7 +51,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: if addition_msg: log.trace("Language specifier issue found; appending additional instructions.") - # The first line has a double line break which is not desirable when appending the msg. + # The first line has double newlines which are not desirable when appending the msg. addition_msg = addition_msg.replace("\n\n", " ", 1) # Make the first character of the addition lower case. @@ -92,8 +91,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: info = parsing.parse_bad_language(content) if info: - # Note that _get_bad_ticks_message expects the first line to have an extra newline. - lines = ["It looks like you incorrectly specified a language for your code block.\n"] + lines = [] language = info.language if info.leading_spaces: @@ -107,10 +105,14 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"There must not be any spaces after `{language}`." ) + lines = " ".join(lines) example_blocks = _get_example(language) - lines.append(f"\n**Here is an example of how it should look:**\n{example_blocks}") - return "\n".join(lines) + # Note that _get_bad_ticks_message expects the first line to have two newlines. + return ( + f"It looks like you incorrectly specified a language for your code block.\n\n{lines}" + f"\n\n**Here is an example of how it should look:**\n{example_blocks}" + ) else: log.trace("Aborting bad language instructions: language specified isn't Python.") @@ -126,7 +128,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: if parsing.is_repl_code(content) or parsing.is_python_code(content): example_blocks = _get_example("python") - # Note that _get_bad_ticks_message expects the first line to have an extra newline. + # Note that _get_bad_ticks_message expects the first line to have two newlines. return ( "It looks like you pasted Python code without syntax highlighting.\n\n" "Please use syntax highlighting to improve the legibility of your code and make " -- cgit v1.2.3 From b160119bbdcde230da44279ce3698fb800f5743e Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 20:37:37 -0700 Subject: Code block: don't return bad language instructions if nothing's wrong --- bot/cogs/codeblock/instructions.py | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 3cc955a1a..0c97d2ad4 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -85,26 +85,31 @@ def _get_bad_lang_message(content: str) -> Optional[str]: """ Return instructions on fixing the Python language specifier for a code block. - If `content` doesn't start with "python" or "py" as the language specifier, return None. + If `code_block` does not have a Python language specifier, return None. + If there's nothing wrong with the language specifier, return None. """ log.trace("Creating instructions for a poorly specified language.") + info = parsing.parse_bad_language(content) + if not info: + log.trace("Aborting bad language instructions: language specified isn't Python.") + return - if info: - lines = [] - language = info.language + lines = [] + language = info.language - if info.leading_spaces: - log.trace("Language specifier was preceded by a space.") - lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") + if info.leading_spaces: + log.trace("Language specifier was preceded by a space.") + lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if not info.terminal_newline: - log.trace("Language specifier was not followed by a newline.") - lines.append( - f"Make sure you put your code on a new line following `{language}`. " - f"There must not be any spaces after `{language}`." - ) + if not info.terminal_newline: + log.trace("Language specifier was not followed by a newline.") + lines.append( + f"Make sure you put your code on a new line following `{language}`. " + f"There must not be any spaces after `{language}`." + ) + if lines: lines = " ".join(lines) example_blocks = _get_example(language) @@ -114,7 +119,7 @@ def _get_bad_lang_message(content: str) -> Optional[str]: f"\n\n**Here is an example of how it should look:**\n{example_blocks}" ) else: - log.trace("Aborting bad language instructions: language specified isn't Python.") + log.trace("Nothing wrong with the language specifier; no instructions to return.") def _get_no_lang_message(content: str) -> Optional[str]: -- cgit v1.2.3 From 7b2fff794907fed5e000998e876b7326fb938ca8 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 20:46:09 -0700 Subject: Code block: fix wrong message shown for bad ticks with a valid language When the code block had invalid ticks, instructions for syntax highlighting were being shown despite the code block having a valid language. --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 0c97d2ad4..880572d58 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -43,7 +43,7 @@ def _get_bad_ticks_message(code_block: parsing.CodeBlock) -> Optional[str]: log.trace("Check if the bad ticks code block also has issues with the language specifier.") addition_msg = _get_bad_lang_message(code_block.content) - if not addition_msg: + if not addition_msg and not code_block.language: addition_msg = _get_no_lang_message(code_block.content) # Combine the back ticks message with the language specifier message. The latter will -- cgit v1.2.3 From 8fcbad9d2ee11916e398ae9f63826a90cdc45608 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 21:27:45 -0700 Subject: Code block: document the cog * Add docstrings for modules * Rephrase some docstrings and comments * Fix the grammar of some comments --- bot/cogs/codeblock/cog.py | 43 ++++++++++++++++++++++++++++++++------ bot/cogs/codeblock/instructions.py | 2 ++ bot/cogs/codeblock/parsing.py | 4 +++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 80d5adff3..c1b2b1c68 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -15,22 +15,53 @@ log = logging.getLogger(__name__) class CodeBlockCog(Cog, name="Code Block"): - """Detect improperly formatted code blocks and suggest proper formatting.""" + """ + Detect improperly formatted Markdown code blocks and suggest proper formatting. + + There are four basic ways in which a code block is considered improperly formatted: + + 1. The code is not within a code block at all + * Ignored if the code is not valid Python or Python REPL code + 2. Incorrect characters are used for backticks + 3. A language for syntax highlighting is not specified + * Ignored if the code is not valid Python or Python REPL code + 4. A syntax highlighting language is incorrectly specified + * Ignored if the language specified doesn't look like it was meant for Python + * This can go wrong in two ways: + 1. Spaces before the language + 2. No newline immediately following the language + + Messages with 3 or fewer lines overall are ignored. Each code block is subject to this threshold + as well i.e. the text between the ticks must be greater than 3 lines. Detecting multiple code + blocks is supported. However, if at least one code block is correct, then instructions will not + be sent even if others are incorrect. When multiple incorrect code blocks are found, only the + first one is used as the basis for the instructions sent. + + When an issue is detected, an embed is sent containing specific instructions on fixing what + is wrong. If the user edits their message to fix the code block, the instructions will be + removed. If they fail to fix the code block with an edit, the instructions will be updated to + show what is still incorrect after the user's edit. The embed can be manually deleted with a + reaction. Otherwise, it will automatically be removed after 5 minutes. + + The cog only detects messages in whitelisted channels. Channels may also have a 300-second + cooldown on the instructions being sent. See `__init__` for which channels are whitelisted or + have cooldowns enabled. Note that all help channels are also whitelisted with cooldowns enabled. + """ def __init__(self, bot: Bot): self.bot = bot - # Stores allowed channels plus epoch time since last call. + # Stores allowed channels plus epoch times since the last instructional messages sent. self.channel_cooldowns = { Channels.python_discussion: 0, } - # These channels will also work, but will not be subject to cooldown + # These channels will also work, but will not be subject to a cooldown. self.channel_whitelist = ( Channels.bot_commands, ) - # Stores improperly formatted Python codeblock message ids and the corresponding bot message + # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} @staticmethod @@ -73,7 +104,7 @@ class CodeBlockCog(Cog, name="Code Block"): return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 def is_valid_channel(self, channel: discord.TextChannel) -> bool: - """Return True if `channel` is a help channel, may be on cooldown, or is whitelisted.""" + """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( self.is_help_channel(channel) @@ -137,7 +168,7 @@ class CodeBlockCog(Cog, name="Code Block"): @Cog.listener() async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: - """Delete the instructions message if an edited message had its code blocks fixed.""" + """Delete the instructional message if an edited message had its code blocks fixed.""" if payload.message_id not in self.codeblock_message_ids: log.trace(f"Ignoring message edit {payload.message_id}: message isn't being tracked.") return diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 880572d58..80f82ef34 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -1,3 +1,5 @@ +"""This module generates and formats instructional messages about fixing Markdown code blocks.""" + import logging from typing import Optional diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 6fa6811cc..1bdb3b492 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -1,3 +1,5 @@ +"""This module provides functions for parsing Markdown code blocks.""" + import ast import logging import re @@ -63,7 +65,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: """ Find and return all Markdown code blocks in the `message`. - Code blocks with 3 or less lines are excluded. + Code blocks with 3 or fewer lines are excluded. If the `message` contains at least one code block with valid ticks and a specified language, return None. This is based on the assumption that if the user managed to get one code block -- cgit v1.2.3 From 211aad8fc14ec81cb6e04cfaf70f6e50221bbc57 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 21:46:39 -0700 Subject: Move some functions into a new channel utility module * Change `is_help_channel` to`internally use `is_in_category` --- bot/cogs/codeblock/cog.py | 14 +++----------- bot/cogs/help_channels.py | 43 +++++++++++++++++-------------------------- bot/utils/channel.py | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 37 deletions(-) create mode 100644 bot/utils/channel.py diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index c1b2b1c68..3c119814f 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -7,7 +7,8 @@ from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover -from bot.constants import Categories, Channels, DEBUG_MODE +from bot.constants import Channels, DEBUG_MODE +from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion from .instructions import get_instructions @@ -85,15 +86,6 @@ class CodeBlockCog(Cog, name="Code Block"): log.debug("Could not find instructions message; it was probably deleted.") return None - @staticmethod - def is_help_channel(channel: discord.TextChannel) -> bool: - """Return True if `channel` is in one of the help categories.""" - log.trace(f"Checking if #{channel} is a help channel.") - return ( - getattr(channel, "category", None) - and channel.category.id in (Categories.help_available, Categories.help_in_use) - ) - def is_on_cooldown(self, channel: discord.TextChannel) -> bool: """ Return True if an embed was sent for `channel` in the last 300 seconds. @@ -107,7 +99,7 @@ class CodeBlockCog(Cog, name="Code Block"): """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" log.trace(f"Checking if #{channel} qualifies for code block detection.") return ( - self.is_help_channel(channel) + is_help_channel(channel) or channel.id in self.channel_cooldowns or channel.id in self.channel_whitelist ) diff --git a/bot/cogs/help_channels.py b/bot/cogs/help_channels.py index 6ff285c37..513ce31d0 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -15,6 +15,7 @@ from discord.ext import commands from bot import constants from bot.bot import Bot +from bot.utils import channel as channel_utils from bot.utils.checks import with_role_check from bot.utils.scheduling import Scheduler @@ -370,11 +371,18 @@ class HelpChannels(Scheduler, commands.Cog): log.trace("Getting the CategoryChannel objects for the help categories.") try: - self.available_category = await self.try_get_channel( - constants.Categories.help_available + self.available_category = await channel_utils.try_get_channel( + constants.Categories.help_available, + self.bot + ) + self.in_use_category = await channel_utils.try_get_channel( + constants.Categories.help_in_use, + self.bot + ) + self.dormant_category = await channel_utils.try_get_channel( + constants.Categories.help_dormant, + self.bot ) - self.in_use_category = await self.try_get_channel(constants.Categories.help_in_use) - self.dormant_category = await self.try_get_channel(constants.Categories.help_dormant) except discord.HTTPException: log.exception("Failed to get a category; cog will be removed") self.bot.remove_cog(self.qualified_name) @@ -431,12 +439,6 @@ class HelpChannels(Scheduler, commands.Cog): embed = message.embeds[0] return message.author == self.bot.user and embed.description.strip() == description.strip() - @staticmethod - def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: - """Return True if `channel` is within a category with `category_id`.""" - actual_category = getattr(channel, "category", None) - return actual_category is not None and actual_category.id == category_id - async def move_idle_channel(self, channel: discord.TextChannel, has_task: bool = True) -> None: """ Make the `channel` dormant if idle or schedule the move if still active. @@ -488,7 +490,7 @@ class HelpChannels(Scheduler, commands.Cog): options should be avoided, as it may interfere with the category move we perform. """ # Get a fresh copy of the category from the bot to avoid the cache mismatch issue we had. - category = await self.try_get_channel(category_id) + category = await channel_utils.try_get_channel(category_id, self.bot) payload = [{"id": c.id, "position": c.position} for c in category.channels] @@ -634,7 +636,7 @@ class HelpChannels(Scheduler, commands.Cog): channel = message.channel # Confirm the channel is an in use help channel - if self.is_in_category(channel, constants.Categories.help_in_use): + if channel_utils.is_in_category(channel, constants.Categories.help_in_use): log.trace(f"Checking if #{channel} ({channel.id}) has been answered.") # Check if there is an entry in unanswered (does not persist across restarts) @@ -659,7 +661,8 @@ class HelpChannels(Scheduler, commands.Cog): await self.check_for_answer(message) - if not self.is_in_category(channel, constants.Categories.help_available) or self.is_excluded_channel(channel): + is_available = channel_utils.is_in_category(channel, constants.Categories.help_available) + if not is_available or self.is_excluded_channel(channel): return # Ignore messages outside the Available category or in excluded channels. log.trace("Waiting for the cog to be ready before processing messages.") @@ -669,7 +672,7 @@ class HelpChannels(Scheduler, 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 not channel_utils.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." @@ -802,18 +805,6 @@ class HelpChannels(Scheduler, commands.Cog): log.trace(f"Dormant message not found in {channel_info}; sending a new message.") await channel.send(embed=embed) - async def try_get_channel(self, channel_id: int) -> discord.abc.GuildChannel: - """Attempt to get or fetch a channel and return it.""" - log.trace(f"Getting the channel {channel_id}.") - - channel = self.bot.get_channel(channel_id) - if not channel: - log.debug(f"Channel {channel_id} is not in cache; fetching from API.") - channel = await self.bot.fetch_channel(channel_id) - - log.trace(f"Channel #{channel} ({channel_id}) retrieved.") - return channel - 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.") diff --git a/bot/utils/channel.py b/bot/utils/channel.py new file mode 100644 index 000000000..47f70ce31 --- /dev/null +++ b/bot/utils/channel.py @@ -0,0 +1,34 @@ +import logging + +import discord + +from bot.constants import Categories + +log = logging.getLogger(__name__) + + +def is_help_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is in one of the help categories (excluding dormant).""" + log.trace(f"Checking if #{channel} is a help channel.") + categories = (Categories.help_available, Categories.help_in_use) + + return any(is_in_category(channel, category) for category in categories) + + +def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: + """Return True if `channel` is within a category with `category_id`.""" + actual_category = getattr(channel, "category", None) + return actual_category is not None and actual_category.id == category_id + + +async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel: + """Attempt to get or fetch a channel and return it.""" + log.trace(f"Getting the channel {channel_id}.") + + channel = client.get_channel(channel_id) + if not channel: + log.debug(f"Channel {channel_id} is not in cache; fetching from API.") + channel = await client.fetch_channel(channel_id) + + log.trace(f"Channel #{channel} ({channel_id}) retrieved.") + return channel -- cgit v1.2.3 From 4cd82783b4aec4e76ecbf1abf6549da68379dc66 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 22:19:12 -0700 Subject: Code block: fix missing newline before generic example --- bot/cogs/codeblock/instructions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 80f82ef34..5c573c2ff 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -27,7 +27,7 @@ def _get_example(language: str) -> str: content = f"{language}\n..." else: log.trace("Code block has no language specifier.") - content = "Hello, world!" + content = "\nHello, world!" return _EXAMPLE_CODE_BLOCKS.format(content=content) -- cgit v1.2.3 From a219c946a92bc81363fa6acdbf007e8c3aff28b4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 7 May 2020 22:30:00 -0700 Subject: Code block: adjust logging levels --- bot/cogs/codeblock/cog.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 3c119814f..74f122936 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -110,7 +110,7 @@ class CodeBlockCog(Cog, name="Code Block"): The embed will be deleted automatically after 5 minutes. """ - log.trace("Sending an embed with code block formatting instructions.") + log.info(f"Sending code block formatting instructions for message {message.id}.") embed = self.create_embed(instructions) bot_message = await message.channel.send(f"Hey {message.author.mention}!", embed=embed) @@ -155,7 +155,7 @@ class CodeBlockCog(Cog, name="Code Block"): await self.send_instructions(msg, instructions) if msg.channel.id not in self.channel_whitelist: - log.trace(f"Adding #{msg.channel} to the channel cooldowns.") + log.debug(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() @Cog.listener() @@ -178,9 +178,9 @@ class CodeBlockCog(Cog, name="Code Block"): return if not instructions: - log.trace("User's incorrect code block has been fixed. Removing instructions message.") + log.info("User's incorrect code block has been fixed. Removing instructions message.") await bot_message.delete() del self.codeblock_message_ids[payload.message_id] else: - log.trace("Message edited but still has invalid code blocks; editing the instructions.") + log.info("Message edited but still has invalid code blocks; editing the instructions.") await bot_message.edit(embed=self.create_embed(instructions)) -- cgit v1.2.3 From a2cac1da6ae309fc8c77a019336348fb236f1bdb Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 8 May 2020 17:32:34 -0700 Subject: Create a utility function to count lines in a string --- bot/cogs/codeblock/cog.py | 3 ++- bot/cogs/codeblock/parsing.py | 4 +++- bot/utils/__init__.py | 8 ++++++++ 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 74f122936..ecaf51aa0 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -8,6 +8,7 @@ from discord.ext.commands import Bot, Cog from bot.cogs.token_remover import TokenRemover from bot.constants import Channels, DEBUG_MODE +from bot.utils import has_lines from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion from .instructions import get_instructions @@ -134,7 +135,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and len(message.content.split("\n", 3)) > 3 + and has_lines(message.content, 4) and not TokenRemover.find_token_in_message(message) ) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 1bdb3b492..332a1deb0 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -5,6 +5,8 @@ import logging import re from typing import NamedTuple, Optional, Sequence +from bot.utils import has_lines + log = logging.getLogger(__name__) BACKTICK = "`" @@ -82,7 +84,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: if groups["tick"] == BACKTICK and language: log.trace("Message has a valid code block with a language; returning None.") return None - elif len(groups["code"].split("\n", 3)) > 3: + elif has_lines(groups["code"], 4): code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) else: diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 5a6e1811b..4a02dc802 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -13,6 +13,14 @@ class CogABCMeta(CogMeta, ABCMeta): pass +def has_lines(string: str, count: int) -> bool: + """Return True if `string` has at least `count` lines.""" + split = string.split("\n", count - 1) + + # Make sure the last part isn't empty, which would happen if there was a final newline. + return split[-1] and len(split) == count + + def pad_base64(data: str) -> str: """Return base64 `data` with padding characters to ensure its length is a multiple of 4.""" return data + "=" * (-len(data) % 4) -- cgit v1.2.3 From 99a1734e8c6ace3e7a6418882f8dae40a3877534 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Fri, 8 May 2020 17:43:21 -0700 Subject: Code block: add configurable variables --- bot/cogs/codeblock/cog.py | 29 +++++++++++------------------ bot/cogs/codeblock/parsing.py | 3 ++- bot/constants.py | 9 +++++++++ config-default.yml | 21 +++++++++++++++++++-- 4 files changed, 41 insertions(+), 21 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index ecaf51aa0..e3917751b 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -6,8 +6,8 @@ import discord from discord import Message, RawMessageUpdateEvent from discord.ext.commands import Bot, Cog +from bot import constants from bot.cogs.token_remover import TokenRemover -from bot.constants import Channels, DEBUG_MODE from bot.utils import has_lines from bot.utils.channel import is_help_channel from bot.utils.messages import wait_for_deletion @@ -33,8 +33,7 @@ class CodeBlockCog(Cog, name="Code Block"): 1. Spaces before the language 2. No newline immediately following the language - Messages with 3 or fewer lines overall are ignored. Each code block is subject to this threshold - as well i.e. the text between the ticks must be greater than 3 lines. Detecting multiple code + Messages or code blocks must meet a minimum line count to be detected. Detecting multiple code blocks is supported. However, if at least one code block is correct, then instructions will not be sent even if others are incorrect. When multiple incorrect code blocks are found, only the first one is used as the basis for the instructions sent. @@ -45,23 +44,17 @@ class CodeBlockCog(Cog, name="Code Block"): show what is still incorrect after the user's edit. The embed can be manually deleted with a reaction. Otherwise, it will automatically be removed after 5 minutes. - The cog only detects messages in whitelisted channels. Channels may also have a 300-second - cooldown on the instructions being sent. See `__init__` for which channels are whitelisted or - have cooldowns enabled. Note that all help channels are also whitelisted with cooldowns enabled. + The cog only detects messages in whitelisted channels. Channels may also have a cooldown on the + instructions being sent. Note all help channels are also whitelisted with cooldowns enabled. + + For configurable parameters, see the `code_block` section in config-default.py. """ def __init__(self, bot: Bot): self.bot = bot # Stores allowed channels plus epoch times since the last instructional messages sent. - self.channel_cooldowns = { - Channels.python_discussion: 0, - } - - # These channels will also work, but will not be subject to a cooldown. - self.channel_whitelist = ( - Channels.bot_commands, - ) + self.channel_cooldowns = {channel: 0.0 for channel in constants.CodeBlock.cooldown_channels} # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} @@ -102,7 +95,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( is_help_channel(channel) or channel.id in self.channel_cooldowns - or channel.id in self.channel_whitelist + or channel.id in constants.CodeBlock.channel_whitelist ) async def send_instructions(self, message: discord.Message, instructions: str) -> None: @@ -135,7 +128,7 @@ class CodeBlockCog(Cog, name="Code Block"): return ( not message.author.bot and self.is_valid_channel(message.channel) - and has_lines(message.content, 4) + and has_lines(message.content, constants.CodeBlock.minimum_lines) and not TokenRemover.find_token_in_message(message) ) @@ -147,7 +140,7 @@ class CodeBlockCog(Cog, name="Code Block"): return # When debugging, ignore cooldowns. - if self.is_on_cooldown(msg.channel) and not DEBUG_MODE: + if self.is_on_cooldown(msg.channel) and not constants.DEBUG_MODE: log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") return @@ -155,7 +148,7 @@ class CodeBlockCog(Cog, name="Code Block"): if instructions: await self.send_instructions(msg, instructions) - if msg.channel.id not in self.channel_whitelist: + if msg.channel.id not in constants.CodeBlock.channel_whitelist: log.debug(f"Adding #{msg.channel} to the channel cooldowns.") self.channel_cooldowns[msg.channel.id] = time.time() diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 332a1deb0..89f8111fc 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -5,6 +5,7 @@ import logging import re from typing import NamedTuple, Optional, Sequence +from bot import constants from bot.utils import has_lines log = logging.getLogger(__name__) @@ -84,7 +85,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: if groups["tick"] == BACKTICK and language: log.trace("Message has a valid code block with a language; returning None.") return None - elif has_lines(groups["code"], 4): + elif has_lines(groups["code"], constants.CodeBlock.minimum_lines): code_block = CodeBlock(groups["code"], language, groups["tick"]) code_blocks.append(code_block) else: diff --git a/bot/constants.py b/bot/constants.py index 470221369..6c9654e89 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -540,6 +540,15 @@ class BigBrother(metaclass=YAMLGetter): header_message_limit: int +class CodeBlock(metaclass=YAMLGetter): + section = 'code_block' + + channel_whitelist: List[int] + cooldown_channels: List[int] + cooldown_seconds: int + minimum_lines: int + + class Free(metaclass=YAMLGetter): section = 'free' diff --git a/config-default.yml b/config-default.yml index 3388e5f78..845a20979 100644 --- a/config-default.yml +++ b/config-default.yml @@ -137,8 +137,8 @@ guild: dev_log: &DEV_LOG 622895325144940554 # Discussion - meta: 429409067623251969 - python_discussion: 267624335836053506 + meta: 429409067623251969 + python_discussion: &PY_DISCUSSION 267624335836053506 # Python Help: Available how_to_get_help: 704250143020417084 @@ -522,6 +522,23 @@ big_brother: header_message_limit: 15 +code_block: + # The channels in which code blocks will be detected. They are not subject to a cooldown. + channel_whitelist: + - *BOT_CMD + + # The channels which will be affected by a cooldown. These channels are also whitelisted. + cooldown_channels: + - *PY_DISCUSSION + + # Sending instructions triggers a cooldown on a per-channel basis. + # More instruction messages will not be sent in the same channel until the cooldown has elapsed. + cooldown_seconds: 300 + + # The minimum amount of lines a message or code block must have for instructions to be sent. + minimum_lines: 4 + + free: # Seconds to elapse for a channel # to be considered inactive. -- cgit v1.2.3 From da816921db5295a33d7af918f329e770c03d73a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 13 May 2020 18:51:31 -0700 Subject: Code block: simplify retrieval of channel ID from payload --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index e3917751b..20b86eb24 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -72,7 +72,7 @@ class CodeBlockCog(Cog, name="Code Block"): deleted either manually via a reaction or automatically by a timer. """ log.trace(f"Retrieving instructions message for ID {payload.message_id}") - channel = self.bot.get_channel(int(payload.data.get("channel_id"))) + channel = self.bot.get_channel(payload.channel_id) try: return await channel.fetch_message(self.codeblock_message_ids[payload.message_id]) -- cgit v1.2.3 From e98100fed8b3c62e337a1c0abeeaee30bc08befa Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 3 Jun 2020 12:26:27 -0700 Subject: Code block: add stats * Increment `codeblock_corrections` when instructions are sent * Import our Bot subclass instead of discord.py's --- bot/cogs/codeblock/cog.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 20b86eb24..6032e911c 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -4,9 +4,10 @@ from typing import Optional import discord from discord import Message, RawMessageUpdateEvent -from discord.ext.commands import Bot, Cog +from discord.ext.commands import Cog from bot import constants +from bot.bot import Bot from bot.cogs.token_remover import TokenRemover from bot.utils import has_lines from bot.utils.channel import is_help_channel @@ -114,6 +115,9 @@ class CodeBlockCog(Cog, name="Code Block"): wait_for_deletion(bot_message, user_ids=(message.author.id,), client=self.bot) ) + # Increase amount of codeblock correction in stats + self.bot.stats.incr("codeblock_corrections") + def should_parse(self, message: discord.Message) -> bool: """ Return True if `message` should be parsed. -- cgit v1.2.3 From cb0529b327000a39d0329143fb5c3db2504d0219 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 10 Jun 2020 21:42:26 -0700 Subject: Code block: remove needless f-strings --- bot/cogs/codeblock/instructions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 5c573c2ff..c9db80deb 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -156,17 +156,17 @@ def get_instructions(content: str) -> Optional[str]: return if not blocks: - log.trace(f"No code blocks were found in message.") + log.trace("No code blocks were found in message.") return _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: - log.trace(f"A code block exists but has invalid ticks.") + log.trace("A code block exists but has invalid ticks.") return _get_bad_ticks_message(block) else: - log.trace(f"A code block exists but is missing a language.") + log.trace("A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. -- cgit v1.2.3 From 94017fdf0e3c9805e3ead81823f3870d3834edd5 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:09:54 -0700 Subject: Code block: rename BadLanguage attributes The `has_` prefix it clarifies that they're booleans. Co-authored-by: Numerlor --- bot/cogs/codeblock/instructions.py | 4 ++-- bot/cogs/codeblock/parsing.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c9db80deb..4ea5ca094 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -100,11 +100,11 @@ def _get_bad_lang_message(content: str) -> Optional[str]: lines = [] language = info.language - if info.leading_spaces: + if info.has_leading_spaces: log.trace("Language specifier was preceded by a space.") lines.append(f"Make sure there are no spaces between the back ticks and `{language}`.") - if not info.terminal_newline: + if not info.has_terminal_newline: log.trace("Language specifier was not followed by a newline.") lines.append( f"Make sure you put your code on a new line following `{language}`. " diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 89f8111fc..73b6a874e 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -60,8 +60,8 @@ class BadLanguage(NamedTuple): """Parsed information about a poorly formatted language specifier.""" language: str - leading_spaces: bool - terminal_newline: bool + has_leading_spaces: bool + has_terminal_newline: bool def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: -- cgit v1.2.3 From 8f37b6c5aef955bb4fab4f30cdcbea6c3c4888c2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:12:14 -0700 Subject: Code block: make PY_LANG_CODES more visible The declaration was a bit hidden between the two regular expressions. --- bot/cogs/codeblock/parsing.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 73b6a874e..31cbd09b9 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -11,6 +11,7 @@ from bot.utils import has_lines log = logging.getLogger(__name__) BACKTICK = "`" +PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _TICKS = { BACKTICK, "'", @@ -24,6 +25,7 @@ _TICKS = { "\u2033", # DOUBLE PRIME "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } + _RE_CODE_BLOCK = re.compile( fr""" (?P @@ -37,7 +39,6 @@ _RE_CODE_BLOCK = re.compile( re.DOTALL | re.VERBOSE ) -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. _RE_LANGUAGE = re.compile( fr""" ^(?P\s+)? # Optionally match leading spaces from the beginning. -- cgit v1.2.3 From b209997a294c8dd07f08e9f2e3ffdb5afc265285 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:16:25 -0700 Subject: Code block: use config constant for cooldown --- bot/cogs/codeblock/cog.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 6032e911c..2576be966 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -83,12 +83,14 @@ class CodeBlockCog(Cog, name="Code Block"): def is_on_cooldown(self, channel: discord.TextChannel) -> bool: """ - Return True if an embed was sent for `channel` in the last 300 seconds. + Return True if an embed was sent too recently for `channel`. + The cooldown is configured by `constants.CodeBlock.cooldown_seconds`. Note: only channels in the `channel_cooldowns` have cooldowns enabled. """ log.trace(f"Checking if #{channel} is on cooldown.") - return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < 300 + cooldown = constants.CodeBlock.cooldown_seconds + return (time.time() - self.channel_cooldowns.get(channel.id, 0)) < cooldown def is_valid_channel(self, channel: discord.TextChannel) -> bool: """Return True if `channel` is a help channel, may be on a cooldown, or is whitelisted.""" -- cgit v1.2.3 From 50757197956e3bba99dc845cdc264d759cbc8a71 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:17:49 -0700 Subject: Code block: simplify channel cooldown dict creation --- bot/cogs/codeblock/cog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/cog.py b/bot/cogs/codeblock/cog.py index 2576be966..63b971b84 100644 --- a/bot/cogs/codeblock/cog.py +++ b/bot/cogs/codeblock/cog.py @@ -55,7 +55,7 @@ class CodeBlockCog(Cog, name="Code Block"): self.bot = bot # Stores allowed channels plus epoch times since the last instructional messages sent. - self.channel_cooldowns = {channel: 0.0 for channel in constants.CodeBlock.cooldown_channels} + self.channel_cooldowns = dict.fromkeys(constants.CodeBlock.cooldown_channels, 0.0) # Maps users' messages to the messages the bot sent with instructions. self.codeblock_message_ids = {} -- cgit v1.2.3 From 621043a7ebc7574455394959a690913064100101 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:23:34 -0700 Subject: Code block: clarify get_instructions's docstring It wasn't clear that it also parses the message content. --- bot/cogs/codeblock/instructions.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 4ea5ca094..c25b2af5d 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -147,7 +147,11 @@ def _get_no_lang_message(content: str) -> Optional[str]: def get_instructions(content: str) -> Optional[str]: - """Return code block formatting instructions for `content` or None if nothing's wrong.""" + """ + Parse `content` and return code block formatting instructions if something is wrong. + + Return None if `content` lacks code block formatting issues. + """ log.trace("Getting formatting instructions.") blocks = parsing.find_code_blocks(content) -- cgit v1.2.3 From 201895180ffbe88c01e4dbc40dd9cd6c043e2be7 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:28:19 -0700 Subject: HelpChannels: fix is_in_category call It was still using it like it was a method of the class rather than calling it from the channel utils module. --- 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 927d05da8..f0945b83c 100644 --- a/bot/cogs/help_channels.py +++ b/bot/cogs/help_channels.py @@ -715,7 +715,7 @@ class HelpChannels(Scheduler, commands.Cog): The new time for the dormant task is configured with `HelpChannels.deleted_idle_minutes`. """ - if not self.is_in_category(msg.channel, constants.Categories.help_in_use): + if not channel_utils.is_in_category(msg.channel, constants.Categories.help_in_use): return if not await self.is_empty(msg.channel): -- cgit v1.2.3 From c7d466a36d5775eb0a373242b7e4214b4534ad20 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 16:50:16 -0700 Subject: Code block: fix BadLanguage creation Forgot to change the kwarg names when the attributes were renamed. --- bot/cogs/codeblock/parsing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 31cbd09b9..112ca12b6 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -148,6 +148,6 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: return BadLanguage( language=match["lang"], - leading_spaces=match["spaces"] is not None, - terminal_newline=match["newline"] is not None, + has_leading_spaces=match["spaces"] is not None, + has_terminal_newline=match["newline"] is not None, ) -- cgit v1.2.3 From de592dc5eb22d061c9b988844e8c7d695a37fa58 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sat, 27 Jun 2020 20:05:20 -0700 Subject: Code block: support IPython REPL detection --- LICENSE-THIRD-PARTY | 36 ++++++++++++++++++++++++++++++++++++ bot/cogs/codeblock/parsing.py | 23 +++++++++++++++++------ 2 files changed, 53 insertions(+), 6 deletions(-) create mode 100644 LICENSE-THIRD-PARTY diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY new file mode 100644 index 000000000..3349d7c05 --- /dev/null +++ b/LICENSE-THIRD-PARTY @@ -0,0 +1,36 @@ +BSD 3-Clause License + +Applies to: +- _RE_PYTHON_REPL and portions of _RE_IPYTHON_REPL in bot/cogs/codeblock/parsing.py + +- Copyright (c) 2008-Present, IPython Development Team +- Copyright (c) 2001-2007, Fernando Perez +- Copyright (c) 2001, Janko Hauser +- Copyright (c) 2001, Nathaniel Gray + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +* Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 112ca12b6..757acdd0f 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -26,6 +26,9 @@ _TICKS = { "\u3003", # VERTICAL KANA REPEAT MARK UPPER HALF } +_RE_PYTHON_REPL = re.compile(r"^(>>>|\.\.\.)( |$)") +_RE_IPYTHON_REPL = re.compile(r"^((In|Out) \[\d+\]: |\s*\.{3,}: ?)") + _RE_CODE_BLOCK = re.compile( fr""" (?P @@ -118,19 +121,27 @@ def is_python_code(content: str) -> bool: def is_repl_code(content: str, threshold: int = 3) -> bool: - """Return True if `content` has at least `threshold` number of Python REPL-like lines.""" - log.trace(f"Checking if content is Python REPL code using a threshold of {threshold}.") + """Return True if `content` has at least `threshold` number of (I)Python REPL-like lines.""" + log.trace(f"Checking if content is (I)Python REPL code using a threshold of {threshold}.") repl_lines = 0 + patterns = (_RE_PYTHON_REPL, _RE_IPYTHON_REPL) + for line in content.splitlines(): - if line.startswith(">>> ") or line.startswith("... "): - repl_lines += 1 + # Check the line against all patterns. + for pattern in patterns: + if pattern.match(line): + repl_lines += 1 + + # Once a pattern is matched, only use that pattern for the remaining lines. + patterns = (pattern,) + break if repl_lines == threshold: - log.trace("Content is Python REPL code.") + log.trace("Content is (I)Python REPL code.") return True - log.trace("Content is not Python REPL code.") + log.trace("Content is not (I)Python REPL code.") return False -- cgit v1.2.3 From d41f3568542528580e0fe0ff5b43bfbae2dde584 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 28 Jun 2020 18:15:14 -0700 Subject: Code block: re-add indentation fixing function It's still useful to fix indentation to ensure AST is correctly parsed. This function deals with the relatively common case of a the leading spaces of the first line being left out when copy-pasting. --- bot/cogs/codeblock/parsing.py | 49 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 757acdd0f..5b4cb9fdd 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -162,3 +162,52 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: has_leading_spaces=match["spaces"] is not None, has_terminal_newline=match["newline"] is not None, ) + + +def _get_leading_spaces(content: str) -> int: + """Return the number of spaces at the start of the first line in `content`.""" + current = content[0] + leading_spaces = 0 + + while current == " ": + leading_spaces += 1 + current = content[leading_spaces] + + return leading_spaces + + +def _fix_indentation(content: str) -> str: + """ + Attempt to fix badly indented code in `content`. + + In most cases, this works like textwrap.dedent. However, if the first line ends with a colon, + all subsequent lines are re-indented to only be one level deep relative to the first line. + The intent is to fix cases where the leading spaces of the first line of code were accidentally + not copied, which makes the first line appear not indented. + + This is fairly naïve and inaccurate. Therefore, it may break some code that was otherwise valid. + It's meant to catch really common cases, so that's acceptable. Its flaws are: + + - It assumes that if the first line ends with a colon, it is the start of an indented block + - It uses 4 spaces as the indentation, regardless of what the rest of the code uses + """ + lines = content.splitlines(keepends=True) + + # Dedent the first line + first_indent = _get_leading_spaces(content) + first_line = lines[0][first_indent:] + + second_indent = _get_leading_spaces(lines[1]) + + # If the first line ends with a colon, all successive lines need to be indented one + # additional level (assumes an indent width of 4). + if first_line.rstrip().endswith(":"): + second_indent -= 4 + + # All lines must be dedented at least by the same amount as the first line. + first_indent = max(first_indent, second_indent) + + # Dedent the rest of the lines and join them together with the first line. + content = first_line + "".join(line[first_indent:] for line in lines[1:]) + + return content -- cgit v1.2.3 From d8b8c518db9fd8bc0d0eb43afe38845c710af9a2 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 28 Jun 2020 18:21:54 -0700 Subject: Code block: dedent code before validating it If it's indented too far, the AST parser will fail. --- bot/cogs/codeblock/instructions.py | 4 ++-- bot/cogs/codeblock/parsing.py | 17 +++++++++++++++-- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index c25b2af5d..56b85a34f 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -70,7 +70,7 @@ def _get_no_ticks_message(content: str) -> Optional[str]: """If `content` is Python/REPL code, return instructions on using code blocks.""" log.trace("Creating instructions for a missing code block.") - if parsing.is_repl_code(content) or parsing.is_python_code(content): + if parsing.is_python_code(content): example_blocks = _get_example("python") return ( "It looks like you're trying to paste code into this channel.\n\n" @@ -132,7 +132,7 @@ def _get_no_lang_message(content: str) -> Optional[str]: """ log.trace("Creating instructions for a missing language.") - if parsing.is_repl_code(content) or parsing.is_python_code(content): + if parsing.is_python_code(content): example_blocks = _get_example("python") # Note that _get_bad_ticks_message expects the first line to have two newlines. diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 5b4cb9fdd..ea007b6f1 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -3,6 +3,7 @@ import ast import logging import re +import textwrap from typing import NamedTuple, Optional, Sequence from bot import constants @@ -98,7 +99,7 @@ def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: return code_blocks -def is_python_code(content: str) -> bool: +def _is_python_code(content: str) -> bool: """Return True if `content` is valid Python consisting of more than just expressions.""" log.trace("Checking if content is Python code.") try: @@ -120,7 +121,7 @@ def is_python_code(content: str) -> bool: return False -def is_repl_code(content: str, threshold: int = 3) -> bool: +def _is_repl_code(content: str, threshold: int = 3) -> bool: """Return True if `content` has at least `threshold` number of (I)Python REPL-like lines.""" log.trace(f"Checking if content is (I)Python REPL code using a threshold of {threshold}.") @@ -145,6 +146,18 @@ def is_repl_code(content: str, threshold: int = 3) -> bool: return False +def is_python_code(content: str) -> bool: + """Return True if `content` is valid Python code or (I)Python REPL output.""" + dedented = textwrap.dedent(content) + + # Parse AST twice in case _fix_indentation ends up breaking code due to its inaccuracies. + return ( + _is_python_code(dedented) + or _is_repl_code(dedented) + or _is_python_code(_fix_indentation(content)) + ) + + def parse_bad_language(content: str) -> Optional[BadLanguage]: """ Return information about a poorly formatted Python language in code block `content`. -- cgit v1.2.3 From 7c97e1954503185d41ddf3cdc9c9b5b64bbb0a46 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Sun, 23 Aug 2020 10:17:24 -0700 Subject: Code block: clarify that the original message can be edited Fix #497 --- bot/cogs/codeblock/instructions.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/bot/cogs/codeblock/instructions.py b/bot/cogs/codeblock/instructions.py index 56b85a34f..84c7a5ea0 100644 --- a/bot/cogs/codeblock/instructions.py +++ b/bot/cogs/codeblock/instructions.py @@ -161,21 +161,24 @@ def get_instructions(content: str) -> Optional[str]: if not blocks: log.trace("No code blocks were found in message.") - return _get_no_ticks_message(content) + instructions = _get_no_ticks_message(content) else: log.trace("Searching results for a code block with invalid ticks.") block = next((block for block in blocks if block.tick != parsing.BACKTICK), None) if block: log.trace("A code block exists but has invalid ticks.") - return _get_bad_ticks_message(block) + instructions = _get_bad_ticks_message(block) else: log.trace("A code block exists but is missing a language.") block = blocks[0] # Check for a bad language first to avoid parsing content into an AST. - description = _get_bad_lang_message(block.content) - if not description: - description = _get_no_lang_message(block.content) + instructions = _get_bad_lang_message(block.content) + if not instructions: + instructions = _get_no_lang_message(block.content) - return description + if instructions: + instructions += "\nYou can **edit your original message** to correct your code block." + + return instructions -- cgit v1.2.3 From e1d13efb6d8871a53830860827ac2016a6cc279d Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:10:24 -0700 Subject: Use category_id attribute in is_in_category Simplify the code by removing the need to check if the category is None. --- bot/utils/channel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 47f70ce31..851f9e1fe 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -17,8 +17,7 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" - actual_category = getattr(channel, "category", None) - return actual_category is not None and actual_category.id == category_id + return getattr(channel, "category_id", None) == category_id async def try_get_channel(channel_id: int, client: discord.Client) -> discord.abc.GuildChannel: -- cgit v1.2.3 From d53b48b3b370bd87c0c6103cc54fef7a79c24625 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:13:56 -0700 Subject: Stats: use the is_in_category util function --- bot/cogs/stats.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/cogs/stats.py b/bot/cogs/stats.py index d42f55466..7b7470d8d 100644 --- a/bot/cogs/stats.py +++ b/bot/cogs/stats.py @@ -7,6 +7,7 @@ from discord.ext.tasks import loop from bot.bot import Bot from bot.constants import Categories, Channels, Guild, Stats as StatConf +from bot.utils.channel import is_in_category CHANNEL_NAME_OVERRIDES = { @@ -36,8 +37,7 @@ class Stats(Cog): if message.guild.id != Guild.id: return - cat = getattr(message.channel, "category", None) - if cat is not None and cat.id == Categories.modmail: + if is_in_category(message.channel, Categories.modmail): if message.channel.id != Channels.incidents: # Do not report modmail channels to stats, there are too many # of them for interesting statistics to be drawn out of this. -- cgit v1.2.3 From a955b61aa7e4692a99034357c7b56d488327a2a4 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Tue, 25 Aug 2020 11:18:34 -0700 Subject: Code block: make _get_leading_spaces more readable A for loop is less confusing according to reviews. --- bot/cogs/codeblock/parsing.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index ea007b6f1..01c220c61 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -179,14 +179,12 @@ def parse_bad_language(content: str) -> Optional[BadLanguage]: def _get_leading_spaces(content: str) -> int: """Return the number of spaces at the start of the first line in `content`.""" - current = content[0] leading_spaces = 0 - - while current == " ": - leading_spaces += 1 - current = content[leading_spaces] - - return leading_spaces + for char in content: + if char == " ": + leading_spaces += 1 + else: + return leading_spaces def _fix_indentation(content: str) -> str: -- cgit v1.2.3 From 57786e90cab270f8526e03414d62f42fa249a593 Mon Sep 17 00:00:00 2001 From: rohanjnr Date: Tue, 15 Sep 2020 12:22:06 +0530 Subject: Restrict nsfw subreddit(s) or similar (subreddits that require you to be over 18). Changed the return format a little bit for the fetch_posts() function, instead of returning an empty list, it returns a list with a dict holding the error message. --- bot/cogs/reddit.py | 25 ++++++++++++++++++------- 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/bot/cogs/reddit.py b/bot/cogs/reddit.py index 5d9e2c20b..0b002f9b6 100644 --- a/bot/cogs/reddit.py +++ b/bot/cogs/reddit.py @@ -141,12 +141,27 @@ class Reddit(Cog): # Got appropriate response - process and return. content = await response.json() posts = content["data"]["children"] + if posts[0]["data"]["over_18"]: + resp_not_allowed = [ + { + "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." + } + ] + return resp_not_allowed return posts[:amount] await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() # Failed to get appropriate response within allowed number of retries. + resp_failed = [ + { + "error": ( + "Sorry! We couldn't find any posts from that subreddit. " + "If this problem persists, please let us know." + ) + } + ] + return resp_failed # Failed to get appropriate response within allowed number of retries. async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ @@ -164,14 +179,10 @@ class Reddit(Cog): amount=amount, params={"t": time} ) - - if not posts: + if "error" in posts[0]: embed.title = random.choice(ERROR_REPLIES) embed.colour = Colour.red() - embed.description = ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) + embed.description = posts[0]["error"] return embed -- cgit v1.2.3 From f32a665cd0a03d8dbf4802643d32902c99bbd9ee Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Mon, 28 Sep 2020 23:41:48 +0530 Subject: Filter out reddit posts which are meant for users 18 years of older and send the rest. --- bot/exts/info/reddit.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index 606c26aa7..f2aecc498 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -140,7 +140,12 @@ class Reddit(Cog): # Got appropriate response - process and return. content = await response.json() posts = content["data"]["children"] - if posts[0]["data"]["over_18"]: + + for post in posts: + if post["data"]["over_18"]: + posts.remove(post) + + if not posts: resp_not_allowed = [ { "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." -- cgit v1.2.3 From d8fbeedb7ec42b387c7f32d15e45675f987f427b Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 1 Oct 2020 15:27:50 +0530 Subject: handling empty list error in get_top_posts() method and filter posts using list comprehension. --- bot/exts/info/reddit.py | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index f2aecc498..c6aecaa20 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -141,31 +141,14 @@ class Reddit(Cog): content = await response.json() posts = content["data"]["children"] - for post in posts: - if post["data"]["over_18"]: - posts.remove(post) - - if not posts: - resp_not_allowed = [ - { - "error": "Oops ! Looks like this subreddit, doesn't fit in the scope of the server." - } - ] - return resp_not_allowed - return posts[:amount] + filtered_posts = [post for post in posts if not post["data"]["over_18"]] + + return filtered_posts[:amount] await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - resp_failed = [ - { - "error": ( - "Sorry! We couldn't find any posts from that subreddit. " - "If this problem persists, please let us know." - ) - } - ] - return resp_failed # Failed to get appropriate response within allowed number of retries. + return list() async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ @@ -183,10 +166,13 @@ class Reddit(Cog): amount=amount, params={"t": time} ) - if "error" in posts[0]: + if not posts: embed.title = random.choice(ERROR_REPLIES) embed.colour = Colour.red() - embed.description = posts[0]["error"] + embed.description = ( + "Sorry! We couldn't find any SFW posts from that subreddit. " + "If this problem persists, please let us know." + ) return embed -- cgit v1.2.3 From 3554a57cdfd9904e180cbe1689e36fea9df4dfb3 Mon Sep 17 00:00:00 2001 From: RohanJnr Date: Thu, 1 Oct 2020 15:30:27 +0530 Subject: re-add comment. --- bot/exts/info/reddit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index c6aecaa20..0a49e53e7 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -148,7 +148,7 @@ class Reddit(Cog): await asyncio.sleep(3) log.debug(f"Invalid response from: {url} - status code {response.status}, mimetype {response.content_type}") - return list() + return list() # Failed to get appropriate response within allowed number of retries. async def get_top_posts(self, subreddit: Subreddit, time: str = "all", amount: int = 5) -> Embed: """ -- cgit v1.2.3 From ace40ecf463a17ad228541ac9ed9a97df15c624c Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Mon, 5 Oct 2020 19:03:59 -0700 Subject: Code block: support the "pycon" language specifier It's used for code copied from the Python REPL. --- bot/cogs/codeblock/parsing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/cogs/codeblock/parsing.py b/bot/cogs/codeblock/parsing.py index 01c220c61..e67224494 100644 --- a/bot/cogs/codeblock/parsing.py +++ b/bot/cogs/codeblock/parsing.py @@ -12,7 +12,7 @@ from bot.utils import has_lines log = logging.getLogger(__name__) BACKTICK = "`" -PY_LANG_CODES = ("python", "py") # Order is important; "py" is second cause it's a subset. +PY_LANG_CODES = ("python", "pycon", "py") # Order is important; "py" is last cause it's a subset. _TICKS = { BACKTICK, "'", -- cgit v1.2.3 From 90356113d6bf75a9567af5be22cbe5422f2cab4d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:39:51 +0300 Subject: Create base Voice Gate cog --- bot/exts/moderation/voice_gate.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 bot/exts/moderation/voice_gate.py diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py new file mode 100644 index 000000000..198617857 --- /dev/null +++ b/bot/exts/moderation/voice_gate.py @@ -0,0 +1,15 @@ +from discord.ext.commands import Cog + +from bot.bot import Bot + + +class VoiceGate(Cog): + """Voice channels verification management.""" + + def __init__(self, bot: Bot): + self.bot = bot + + +def setup(bot: Bot) -> None: + """Loads the VoiceGate cog.""" + bot.add_cog(VoiceGate(bot)) -- cgit v1.2.3 From 7039702ef29f4dd44db2f08005ac61d6ab83460f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:42:06 +0300 Subject: Define Voice Gate channel, role and requirement in constants.py --- bot/constants.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index bb82b976d..ccc3d505d 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -423,6 +423,7 @@ class Channels(metaclass=YAMLGetter): user_event_announcements: int user_log: int verification: int + voice_gate: int voice_log: int @@ -458,6 +459,7 @@ class Roles(metaclass=YAMLGetter): team_leaders: int unverified: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. + voice_verified: int class Guild(metaclass=YAMLGetter): @@ -577,6 +579,14 @@ class Verification(metaclass=YAMLGetter): kick_confirmation_threshold: float +class VoiceGate(metaclass=YAMLGetter): + section = "voice_gate" + + minimum_days_verified: int + minimum_messages: int + bot_message_delete_delay: int + + class Event(Enum): """ Event names. This does not include every event (for example, raw -- cgit v1.2.3 From 80409d40d0f9d39d08b287d5db460fba7c26ea0d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sat, 10 Oct 2020 17:50:27 +0300 Subject: Add voice gate configuration to config-default.yml --- config-default.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/config-default.yml b/config-default.yml index 3de83dbb1..2d70c17e4 100644 --- a/config-default.yml +++ b/config-default.yml @@ -481,5 +481,11 @@ verification: kick_confirmation_threshold: 0.01 # 1% +voice_gate: + minimum_days_verified: 3 # Days how much user have to be verified to pass Voice Gate + minimum_messages: 50 # How much messages user must have to pass Voice Gate + bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate + + config: required_keys: ['bot.token'] -- cgit v1.2.3 From f76bced0f77cd36a2ce25ff11717c2d277c3de60 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 10 Oct 2020 18:38:10 +0200 Subject: Duckpond: Add a list of already ducked messages Previously race conditions caused the messages to be processed again before knowing the white check mark reaction got added, this seems to solve it --- bot/exts/fun/duck_pond.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 82084ea88..48aa2749c 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -22,6 +22,7 @@ class DuckPond(Cog): self.bot = bot self.webhook_id = constants.Webhooks.duck_pond self.webhook = None + self.ducked_messages = [] self.bot.loop.create_task(self.fetch_webhook()) self.relay_lock = None @@ -176,7 +177,8 @@ class DuckPond(Cog): duck_count = await self.count_ducks(message) # If we've got more than the required amount of ducks, send the message to the duck_pond. - if duck_count >= constants.DuckPond.threshold: + if duck_count >= constants.DuckPond.threshold and message.id not in self.ducked_messages: + self.ducked_messages.append(message.id) await self.locked_relay(message) @Cog.listener() -- cgit v1.2.3 From a660a1ef1ed7d93bff6bf4cb1cdff279a1083324 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 08:07:19 +0300 Subject: Add Metricity DB URL to site (docker-compose.yml) --- docker-compose.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-compose.yml b/docker-compose.yml index cff7d33d6..8be5aac0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: - postgres environment: DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite + METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity SECRET_KEY: suitable-for-development-only STATIC_ROOT: /var/www/static -- cgit v1.2.3 From 9c1f66e43ed35d9fe8ffdc3ae0a4bb7504bb9c93 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:10:21 +0300 Subject: Add voice ban icons and show appeal footer for voice ban --- bot/exts/moderation/infraction/_utils.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 1d91964f1..bff5fcf4c 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -18,9 +18,10 @@ INFRACTION_ICONS = { "note": (Icons.user_warn, None), "superstar": (Icons.superstarify, Icons.unsuperstarify), "warning": (Icons.user_warn, None), + "voice_ban": (Icons.voice_state_red, Icons.voice_state_green), } RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") +APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban") # Type aliases UserObject = t.Union[discord.Member, discord.User] -- cgit v1.2.3 From a4d445a61e06d47afd7cbb152ef4a93a73e6042a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:10:47 +0300 Subject: Implement voice bans (temporary and permanent) --- bot/exts/moderation/infraction/infractions.py | 85 +++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 7cf7075e6..93ec59809 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -15,6 +15,7 @@ from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.exts.moderation.infraction._utils import UserSnowflake +from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.messages import format_user log = logging.getLogger(__name__) @@ -31,6 +32,7 @@ class Infractions(InfractionScheduler, commands.Cog): self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) + self._voice_verified_role = discord.Object(constants.Roles.voice_verified) @commands.Cog.listener() async def on_member_join(self, member: Member) -> None: @@ -88,6 +90,11 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) + @command(aliases=('vban', 'voiceban')) + async def voice_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: + """Permanently ban user from using voice channels.""" + await self.apply_voice_ban(ctx, user, reason) + # endregion # region: Temporary infractions @@ -136,6 +143,32 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, expires_at=duration) + @command(aliases=("tempvban", "tvban")) + async def tempvoiceban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] + ) -> None: + """ + Temporarily voice ban a user for the given reason and duration. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + """ + await self.apply_voice_ban(ctx, user, reason, expires_at=duration) + # endregion # region: Permanent shadow infractions @@ -225,6 +258,11 @@ class Infractions(InfractionScheduler, commands.Cog): """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) + @command(aliases=("uvban",)) + async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active voice ban infraction for the user.""" + await self.pardon_infraction(ctx, "voice_ban", user) + # endregion # region: Base apply functions @@ -319,6 +357,25 @@ class Infractions(InfractionScheduler, commands.Cog): bb_reason = "User has been permanently banned from the server. Automatically removed." await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + @respect_role_hierarchy(member_arg=2) + async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" + if constants.Roles.voice_verified not in [role.id for role in user.roles]: + await ctx.send(":x: Can't apply Voice Ban to user who have not passed the Voice Gate.") + return + + if await _utils.get_active_infraction(ctx, user, "voice_ban"): + return + + infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_update, user.id) + + action = user.remove_roles(self._voice_verified_role, reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + # endregion # region: Base pardon functions @@ -363,6 +420,32 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text + async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" + user = guild.get_member(user_id) + log_text = {} + + if user: + # Add Voice Verified role back to user. + self.mod_log.ignore(Event.member_update, user.id) + await user.add_roles(self._voice_verified_role, reason=reason) + + # DM user about infraction expiration + notified = await _utils.notify_pardon( + user=user, + title="Your Voice Ban have been removed", + content="You can now speak again in voice channels.", + icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] + ) + + log_text["Member"] = format_user(user) + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log.info(f"Failed to remove Voice Ban from user {user_id}: user not found") + log_text["Failure"] = "User was not found in the guild." + + return log_text + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. @@ -377,6 +460,8 @@ class Infractions(InfractionScheduler, commands.Cog): return await self.pardon_mute(user_id, guild, reason) elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) + elif infraction["type"] == "voice_ban": + return await self.pardon_voice_ban(user_id, guild, reason) # endregion -- cgit v1.2.3 From 247e866868a7f0687ceb02a64beb79ebcbb440e5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 10:11:25 +0300 Subject: Remove not used imports --- bot/exts/moderation/infraction/infractions.py | 1 - 1 file changed, 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 93ec59809..2157c040c 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -15,7 +15,6 @@ from bot.decorators import respect_role_hierarchy from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._scheduler import InfractionScheduler from bot.exts.moderation.infraction._utils import UserSnowflake -from bot.utils.checks import has_any_role_check, has_no_roles_check from bot.utils.messages import format_user log = logging.getLogger(__name__) -- cgit v1.2.3 From 0147934b7681cd65496f904e0d8ab15b4331d7c4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 11:35:43 +0300 Subject: Implement Voice Verifying command and delete message in voice gate --- bot/exts/moderation/voice_gate.py | 112 +++++++++++++++++++++++++++++++++++++- 1 file changed, 111 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 198617857..dae19d49e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,6 +1,26 @@ -from discord.ext.commands import Cog +import logging +from contextlib import suppress +from datetime import datetime, timedelta +import discord +from dateutil import parser + +from discord.ext.commands import Cog, Context, command + +from bot.api import ResponseCodeError from bot.bot import Bot +from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event +from bot.decorators import has_no_roles, in_whitelist +from bot.exts.moderation.modlog import ModLog + +log = logging.getLogger(__name__) + +# Messages for case when user don't meet with requirements +NOT_ENOUGH_MESSAGES = f"haven't sent at least {VoiceGateConf.minimum_messages} messages" +NOT_ENOUGH_DAYS_AFTER_VERIFICATION = f"haven't been verified for at least {VoiceGateConf.minimum_days_verified} days" +VOICE_BANNED = "are voice banned" + +FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" class VoiceGate(Cog): @@ -9,6 +29,96 @@ class VoiceGate(Cog): def __init__(self, bot: Bot): self.bot = bot + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @command(aliases=('voiceverify', 'vverify', 'voicev', 'vv')) + @has_no_roles(Roles.voice_verified) + @in_whitelist(channels=(Channels.voice_gate,), redirect=None) + async def voice_verify(self, ctx: Context, *_) -> None: + """ + Apply to be able to use voice within the Discord server. + + In order to use voice you must meet all three of the following criteria: + - You must have over a certain number of messages within the Discord server + - You must have accepted our rules over a certain number of days ago + - You must not be actively banned from using our voice channels + """ + try: + data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") + except ResponseCodeError as e: + if e.status == 404: + await ctx.send(f":x: {ctx.author.mention} Unable to find Metricity data about you.") + log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") + else: + log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") + await ctx.send(f":x: Got unexpected response from site. Please let us know about this.") + return + + # Pre-parse this for better code style + data["verified_at"] = parser.isoparse(data["verified_at"]) + + failed = False + failed_reasons = [] + + if data["verified_at"] > datetime.utcnow() - timedelta(days=VoiceGateConf.minimum_days_verified): + failed_reasons.append(NOT_ENOUGH_DAYS_AFTER_VERIFICATION) + failed = True + self.bot.stats.incr("voice_gate.failed.verified_at") + if data["total_messages"] < VoiceGateConf.minimum_messages: + failed_reasons.append(NOT_ENOUGH_MESSAGES) + failed = True + self.bot.stats.incr("voice_gate.failed.total_messages") + if data["voice_banned"]: + failed_reasons.append(VOICE_BANNED) + failed = True + self.bot.stats.incr("voice_gate.failed.voice_banned") + + if failed: + if len(failed_reasons) > 1: + reasons = f"{', '.join(failed_reasons[:-1])} and {failed_reasons[-1]}" + else: + reasons = failed_reasons[0] + + await ctx.send( + FAILED_MESSAGE.format( + user=ctx.author.mention, + reasons=reasons + ) + ) + return + + self.mod_log.ignore(Event.member_update, ctx.author.id) + await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + await ctx.author.send( + ":tada: Congratulations! You are now Voice Verified and have access to PyDis Voice Channels." + ) + self.bot.stats.incr("voice_gate.passed") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Delete all non-staff messages from voice gate channel that don't invoke voice verify command.""" + # Check is channel voice gate + if message.channel.id != Channels.voice_gate: + return + + # When it's bot sent message, delete it after some time + if message.author.bot: + with suppress(discord.NotFound): + await message.delete(delay=VoiceGateConf.bot_message_delete_delay) + return + + # Then check is member moderator+, because we don't want to delete their messages. + if any(role.id in MODERATION_ROLES for role in message.author.roles): + log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") + return + + self.mod_log.ignore(Event.message_delete, message.id) + with suppress(discord.NotFound): + await message.delete() + def setup(bot: Bot) -> None: """Loads the VoiceGate cog.""" -- cgit v1.2.3 From 22e9c04d63c4a983448efc91a12335a326393e76 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 12:23:02 +0300 Subject: Suppress Voice Gate cog InWhiteListCheckFailure --- bot/exts/moderation/voice_gate.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index dae19d49e..101db90b8 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -12,6 +12,7 @@ from bot.bot import Bot from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog +from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) @@ -119,6 +120,11 @@ class VoiceGate(Cog): with suppress(discord.NotFound): await message.delete() + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, InWhitelistCheckFailure): + error.handled = True + def setup(bot: Bot) -> None: """Loads the VoiceGate cog.""" -- cgit v1.2.3 From 002c53cb922f826c33c58fe35afccee24d5b2689 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 12:52:12 +0300 Subject: Improve voice gate messages deletion --- bot/exts/moderation/voice_gate.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 101db90b8..bd2afb464 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -105,6 +105,9 @@ class VoiceGate(Cog): if message.channel.id != Channels.voice_gate: return + ctx = await self.bot.get_context(message) + is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify" + # When it's bot sent message, delete it after some time if message.author.bot: with suppress(discord.NotFound): @@ -112,11 +115,14 @@ class VoiceGate(Cog): return # Then check is member moderator+, because we don't want to delete their messages. - if any(role.id in MODERATION_ROLES for role in message.author.roles): + if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command == False: log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") return - self.mod_log.ignore(Event.message_delete, message.id) + # Ignore deleted voice verification messages + if ctx.command is not None and ctx.command.name == "voice_verify": + self.mod_log.ignore(Event.message_delete, message.id) + with suppress(discord.NotFound): await message.delete() -- cgit v1.2.3 From 4d967cd27d049bffc2585d2cc8f381f44f59ca61 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:04:35 +0300 Subject: Create test for permanent voice ban --- .../bot/exts/moderation/infraction/test_infractions.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index be1b649e1..27f346648 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -53,3 +53,20 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_infraction.assert_awaited_once_with( self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value ) + + +class VoiceBanTests(unittest.IsolatedAsyncioTestCase): + """Tests for voice ban related functions and commands.""" + + def setUp(self): + self.bot = MockBot() + self.mod = MockMember() + self.user = MockMember() + self.ctx = MockContext(bot=self.bot, author=self.mod) + self.cog = Infractions(self.bot) + + async def test_permanent_voice_ban(self): + """Should call voice ban applying function.""" + self.cog.apply_voice_ban = AsyncMock() + self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) + self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") -- cgit v1.2.3 From b792af63022bf8e435210c9efefccc664c3bbf80 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:08:27 +0300 Subject: Create test for temporary voice ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 27f346648..814959775 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -66,7 +66,13 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog = Infractions(self.bot) async def test_permanent_voice_ban(self): - """Should call voice ban applying function.""" + """Should call voice ban applying function without expiry.""" self.cog.apply_voice_ban = AsyncMock() self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") + + async def test_temporary_voice_ban(self): + """Should call voice ban applying function with expiry.""" + self.cog.apply_voice_ban = AsyncMock() + self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) + self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") -- cgit v1.2.3 From 2b701b05b55d6c62c27497d39b142370693ef88d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 13:13:23 +0300 Subject: Create test for voice unban --- tests/bot/exts/moderation/infraction/test_infractions.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 814959775..02062932e 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -76,3 +76,9 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_voice_ban = AsyncMock() self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + + async def test_voice_unban(self): + """Should call infraction pardoning function.""" + self.cog.pardon_infraction = AsyncMock() + self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) -- cgit v1.2.3 From 8faa82f7d7de795b4a8e2fc7a6dc919994258d6c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:00:09 +0300 Subject: Create test for case when trying to voice ban user who haven't passed gate --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 02062932e..b2b617e51 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -60,8 +60,8 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): def setUp(self): self.bot = MockBot() - self.mod = MockMember() - self.user = MockMember() + self.mod = MockMember(top_role=10) + self.user = MockMember(top_role=1) self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -82,3 +82,12 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.pardon_infraction = AsyncMock() self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) + + @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): + """Should send message and not apply infraction when user don't have voice verified role.""" + self.user.roles = [MockRole(id=987)] + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.ctx.send.assert_awaited_once() + get_active_infraction_mock.assert_not_awaited() -- cgit v1.2.3 From 55a46c937de9c27cd865ff34cfe82c8fb76dc603 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:01:32 +0300 Subject: Simplify post infraction calling and None check --- bot/exts/moderation/infraction/infractions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2157c040c..6a6250238 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -366,8 +366,7 @@ class Infractions(InfractionScheduler, commands.Cog): if await _utils.get_active_infraction(ctx, user, "voice_ban"): return - infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) - if infraction is None: + if infraction := await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs): return self.mod_log.ignore(Event.member_update, user.id) -- cgit v1.2.3 From b7a072c1c43ad5b0779c1e979a1870c002cfd5c3 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:05:42 +0300 Subject: Create test for case when user already have active Voice Ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b2b617e51..510f31db3 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -55,13 +55,14 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): ) +@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Tests for voice ban related functions and commands.""" def setUp(self): self.bot = MockBot() self.mod = MockMember(top_role=10) - self.user = MockMember(top_role=1) + self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -83,7 +84,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) - @patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): """Should send message and not apply infraction when user don't have voice verified role.""" @@ -91,3 +91,12 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.ctx.send.assert_awaited_once() get_active_infraction_mock.assert_not_awaited() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): + """Should return early when user already have Voice Ban infraction.""" + get_active_infraction.return_value = {"foo": "bar"} + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + get_active_infraction.assert_awaited_once() + post_infraction_mock.assert_not_awaited() -- cgit v1.2.3 From a1209554614e3f5b63ab400a754f1d893896754b Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:11:03 +0300 Subject: Revert recent walrus operator change --- bot/exts/moderation/infraction/infractions.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 6a6250238..2157c040c 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -366,7 +366,8 @@ class Infractions(InfractionScheduler, commands.Cog): if await _utils.get_active_infraction(ctx, user, "voice_ban"): return - if infraction := await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs): + infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + if infraction is None: return self.mod_log.ignore(Event.member_update, user.id) -- cgit v1.2.3 From c719169bffcca8898ced04c1fed0264a5b9cd7f6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:11:36 +0300 Subject: Create test for case when posting infraction fails --- tests/bot/exts/moderation/infraction/test_infractions.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 510f31db3..1c3294b39 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,6 +1,6 @@ import textwrap import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, Mock, patch, MagicMock from bot.exts.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -100,3 +100,14 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) get_active_infraction.assert_awaited_once() post_infraction_mock.assert_not_awaited() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): + """Should return early when posting infraction fails.""" + self.cog.mod_log.ignore = MagicMock() + get_active_infraction.return_value = None + post_infraction_mock.return_value = None + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + post_infraction_mock.assert_awaited_once() + self.cog.mod_log.ignore.assert_not_called() -- cgit v1.2.3 From 2a6f86b87aa7bc19a26df739111a678f8fa03083 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:15:16 +0300 Subject: Create test to check does this pass proper kwargs to infraction posting --- tests/bot/exts/moderation/infraction/test_infractions.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 1c3294b39..ebb39320a 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -111,3 +111,15 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) post_infraction_mock.assert_awaited_once() self.cog.mod_log.ignore.assert_not_called() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): + """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" + get_active_infraction.return_value = None + # We don't want that this continue yet + post_infraction_mock.return_value = None + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) + post_infraction_mock.assert_awaited_once_with( + self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 + ) -- cgit v1.2.3 From 06343b5b24aa2b5e9d7d34e39ff604ec4577bcd8 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:16:33 +0300 Subject: Check arguments for get_active_infraction in voice ban tests --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index ebb39320a..37848e9e8 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -98,7 +98,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should return early when user already have Voice Ban infraction.""" get_active_infraction.return_value = {"foo": "bar"} self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - get_active_infraction.assert_awaited_once() + get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") post_infraction_mock.assert_not_awaited() @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") -- cgit v1.2.3 From a4036476bca02cf645c459510c3866c6442020c7 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:22:37 +0300 Subject: Create test for voice ban applying role remove ignore. --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 37848e9e8..d4fb2b119 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -2,6 +2,7 @@ import textwrap import unittest from unittest.mock import AsyncMock, Mock, patch, MagicMock +from bot.constants import Event from bot.exts.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -123,3 +124,17 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): post_infraction_mock.assert_awaited_once_with( self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 ) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): + """Should ignore Voice Verified role removing.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) -- cgit v1.2.3 From d9d3b1a3615f347958cd8e194323b0c9b13d6a35 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:26:40 +0300 Subject: Add Voice Ban test about calling apply_infraction --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index d4fb2b119..1f4a3e7f0 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -138,3 +138,18 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): + """Should ignore Voice Verified role removing.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") -- cgit v1.2.3 From fda0359abfe8644cc2a9452c19713395dec16dab Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:41:51 +0300 Subject: Shorten voice ban reason and create test for it --- bot/exts/moderation/infraction/infractions.py | 3 +++ .../bot/exts/moderation/infraction/test_infractions.py | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2157c040c..0dab3a72e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -372,6 +372,9 @@ class Infractions(InfractionScheduler, commands.Cog): self.mod_log.ignore(Event.member_update, user.id) + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + action = user.remove_roles(self._voice_verified_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 1f4a3e7f0..a6ebe2162 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -153,3 +153,20 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): + """Should truncate reason for voice ban.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) + self.user.remove_roles.assert_called_once_with( + self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") + ) + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") -- cgit v1.2.3 From b8855bced0913f087d25d571fe9a5ccf7f5e1727 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 14:53:33 +0300 Subject: Create test for voice ban pardon when user not found --- tests/bot/exts/moderation/infraction/test_infractions.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index a6ebe2162..ae8c1d35e 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -64,6 +64,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.bot = MockBot() self.mod = MockMember(top_role=10) self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) + self.guild = MockGuild() self.ctx = MockContext(bot=self.bot, author=self.mod) self.cog = Infractions(self.bot) @@ -170,3 +171,9 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") ) self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + + async def test_voice_unban_user_not_found(self): + """Should include info to return dict when user was not found from guild.""" + self.guild.get_member.return_value = None + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, {"Failure": "User was not found in the guild."}) -- cgit v1.2.3 From 6e8e9fd8c3db4ac8a65bed65d2fa1ecbea1c98c5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:02:27 +0300 Subject: Create base test for voice unban --- .../bot/exts/moderation/infraction/test_infractions.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index ae8c1d35e..9d4180902 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -177,3 +177,21 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.guild.get_member.return_value = None result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") self.assertEqual(result, {"Failure": "User was not found in the guild."}) + + @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") + @patch("bot.exts.moderation.infraction.infractions.format_user") + async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): + """Should add role back with ignoring, notify user and return log dictionary..""" + self.cog.mod_log.ignore = MagicMock() + self.guild.get_member.return_value = self.user + notify_pardon_mock.return_value = True + format_user_mock.return_value = "my-user" + + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, { + "Member": "my-user", + "DM": "Sent" + }) + self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") + notify_pardon_mock.assert_awaited_once() -- cgit v1.2.3 From 339769d8c863b192e1b298e211d1ab0261d1b26f Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:04:35 +0300 Subject: Create test for voice unban fail send DM --- tests/bot/exts/moderation/infraction/test_infractions.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 9d4180902..b60c203a1 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -195,3 +195,18 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") notify_pardon_mock.assert_awaited_once() + + @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") + @patch("bot.exts.moderation.infraction.infractions.format_user") + async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): + """Should add role back with ignoring, notify user and return log dictionary..""" + self.guild.get_member.return_value = self.user + notify_pardon_mock.return_value = False + format_user_mock.return_value = "my-user" + + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, { + "Member": "my-user", + "DM": "**Failed**" + }) + notify_pardon_mock.assert_awaited_once() -- cgit v1.2.3 From 7598faddd8f68e9263d1c9748becd49cb1917919 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:05:20 +0300 Subject: Add production voice gate role and channel to configuration --- config-default.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/config-default.yml b/config-default.yml index afdb8fe95..a536a94db 100644 --- a/config-default.yml +++ b/config-default.yml @@ -169,6 +169,7 @@ guild: bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 verification: 352442727016693763 + voice_gate: 764802555427029012 # Staff admins: &ADMINS 365960823622991872 @@ -228,6 +229,7 @@ guild: unverified: 739794855945044069 verified: 352427296948486144 # @Developers on PyDis + voice_verified: 764802720779337729 # Staff admins: &ADMINS_ROLE 267628507062992896 -- cgit v1.2.3 From 0a4bed86d3826e611cd1675d54596a8dcedbe29a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 11 Oct 2020 15:08:21 +0300 Subject: Fix linting for voice gate and voice ban --- bot/exts/moderation/voice_gate.py | 7 +++---- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index bd2afb464..8f2b51dbb 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -4,12 +4,11 @@ from datetime import datetime, timedelta import discord from dateutil import parser - from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Roles, VoiceGate as VoiceGateConf, MODERATION_ROLES, Event +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as VoiceGateConf from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog from bot.utils.checks import InWhitelistCheckFailure @@ -55,7 +54,7 @@ class VoiceGate(Cog): log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") - await ctx.send(f":x: Got unexpected response from site. Please let us know about this.") + await ctx.send(":x: Got unexpected response from site. Please let us know about this.") return # Pre-parse this for better code style @@ -115,7 +114,7 @@ class VoiceGate(Cog): return # Then check is member moderator+, because we don't want to delete their messages. - if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command == False: + if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False: log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") return diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b60c203a1..caa42ba3d 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,6 +1,6 @@ import textwrap import unittest -from unittest.mock import AsyncMock, Mock, patch, MagicMock +from unittest.mock import AsyncMock, MagicMock, Mock, patch from bot.constants import Event from bot.exts.moderation.infraction.infractions import Infractions -- cgit v1.2.3 From c835fe8447b239871957817edf325fe1eeadfa12 Mon Sep 17 00:00:00 2001 From: spitfire-hash Date: Tue, 13 Oct 2020 12:27:27 +0400 Subject: Fixed hardcoded prefix in __main__.py --- bot/__main__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/__main__.py b/bot/__main__.py index da042a5ed..367be1300 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -58,7 +58,7 @@ bot = Bot( redis_session=redis_session, loop=loop, command_prefix=when_mentioned_or(constants.Bot.prefix), - activity=discord.Game(name="Commands: !help"), + activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"), case_insensitive=True, max_messages=10_000, allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), -- cgit v1.2.3 From 7b40cb697bd10f3640c9f5de3a9666d63606f68b Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 13 Oct 2020 14:41:09 +0200 Subject: Verification: implement kick note post helper --- bot/exts/moderation/verification.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c3ad8687e..cb6dd14fb 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -11,6 +11,7 @@ from discord.ext.commands import Cog, Context, command, group, has_any_role from discord.utils import snowflake_time from bot import constants +from bot.api import ResponseCodeError from bot.bot import Bot from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog @@ -355,6 +356,28 @@ class Verification(Cog): return n_success + async def _add_kick_note(self, member: discord.Member) -> None: + """ + Post a note regarding `member` being kicked to site. + + Allows keeping track of kicked members for auditing purposes. + """ + payload = { + "active": False, + "actor": self.bot.user.id, # Bot actions this autonomously + "expires_at": None, + "hidden": True, + "reason": f"Kicked for not having verified after {constants.Verification.kicked_after} days", + "type": "note", + "user": member.id, + } + + log.trace(f"Posting kick note: {payload!r}") + try: + await self.bot.api_client.post("bot/infractions", json=payload) + except ResponseCodeError as api_exc: + log.warning("Failed to post kick note", exc_info=api_exc) + async def _kick_members(self, members: t.Collection[discord.Member]) -> int: """ Kick `members` from the PyDis guild. -- cgit v1.2.3 From ba7429a4efb4c16c27cb7cb8c44cce4bfc13351c Mon Sep 17 00:00:00 2001 From: kwzrd Date: Tue, 13 Oct 2020 14:41:28 +0200 Subject: Verification: add notes to kicked users --- bot/exts/moderation/verification.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index cb6dd14fb..e92524331 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -396,6 +396,7 @@ class Verification(Cog): except discord.HTTPException as suspicious_exception: raise StopExecution(reason=suspicious_exception) await member.kick(reason=f"User has not verified in {constants.Verification.kicked_after} days") + await self._add_kick_note(member) n_kicked = await self._send_requests(members, kick_request, Limit(batch_size=2, sleep_secs=1)) self.bot.stats.incr("verification.kicked", count=n_kicked) -- cgit v1.2.3 From 85d4573f548a4a0b45a75b9c78f102dff647bcfc Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:26:37 +0100 Subject: Add production debug log for native verification --- bot/exts/moderation/verification.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c3ad8687e..8a5937c3d 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -547,6 +547,16 @@ class Verification(Cog): # video. if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) + + # TODO: Temporary, remove soon after asking joe. + await self.mod_log.send_log_message( + icon_url=self.bot.user.avatar_url, + colour=discord.Colour.blurple(), + title="New native gated user", + channel_id=Channels.user_log, + text=f"<@{member.id}> ({member.id})", + ) + return log.trace(f"Sending on join message to new member: {member.id}") -- cgit v1.2.3 From 0c552b0b57f87177346fe43022475800debc9e60 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:28:05 +0100 Subject: Fix channel constant --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index 8a5937c3d..fe7ab5c67 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -553,7 +553,7 @@ class Verification(Cog): icon_url=self.bot.user.avatar_url, colour=discord.Colour.blurple(), title="New native gated user", - channel_id=Channels.user_log, + channel_id=constants.Channels.user_log, text=f"<@{member.id}> ({member.id})", ) -- cgit v1.2.3 From aefd9a31b32dc76c37a51debd2705ce2287ec6b1 Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Tue, 13 Oct 2020 22:30:18 +0100 Subject: Remove trailing whitespace from verification.py --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index fe7ab5c67..d28114298 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -547,7 +547,7 @@ class Verification(Cog): # video. if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) - + # TODO: Temporary, remove soon after asking joe. await self.mod_log.send_log_message( icon_url=self.bot.user.avatar_url, -- cgit v1.2.3 From 1bbb8a5a9236582232472b90ccc217380fdfef6f Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 14 Oct 2020 15:31:12 -0700 Subject: Utils: clarify why has_lines counts by splitting by newlines --- bot/utils/helpers.py | 1 + 1 file changed, 1 insertion(+) diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index b5c13ac9e..3501a3933 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -20,6 +20,7 @@ def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: def has_lines(string: str, count: int) -> bool: """Return True if `string` has at least `count` lines.""" + # Benchmarks show this is significantly faster than using str.count("\n") or a for loop & break. split = string.split("\n", count - 1) # Make sure the last part isn't empty, which would happen if there was a final newline. -- cgit v1.2.3 From d277ac6d3444bed43f921ee95f79255033e367ba Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Wed, 14 Oct 2020 18:53:48 -0700 Subject: Code block: fix _fix_indentation failing for line counts of 1 This could be reproduced by editing a tracked message to a single line of invalid Python that lacks any back ticks. The code was assuming there would be multiple lines because that's what the default value for the threshold is, but this threshold is not applied to edited messages. Fixes BOT-A5 --- bot/exts/info/codeblock/_parsing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py index e67224494..a98218dfb 100644 --- a/bot/exts/info/codeblock/_parsing.py +++ b/bot/exts/info/codeblock/_parsing.py @@ -208,6 +208,10 @@ def _fix_indentation(content: str) -> str: first_indent = _get_leading_spaces(content) first_line = lines[0][first_indent:] + # Can't assume there'll be multiple lines cause line counts of edited messages aren't checked. + if len(lines) == 1: + return first_line + second_indent = _get_leading_spaces(lines[1]) # If the first line ends with a colon, all successive lines need to be indented one -- cgit v1.2.3 From 5f4552f01506e071646c42600f30a515d77908d4 Mon Sep 17 00:00:00 2001 From: kwzrd Date: Thu, 15 Oct 2020 13:36:38 +0200 Subject: Verification: simplify kick note reason This will make it much easier to filter out verification kicks when querying the infraction database. --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index e92524331..c8e5b481f 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -367,7 +367,7 @@ class Verification(Cog): "actor": self.bot.user.id, # Bot actions this autonomously "expires_at": None, "hidden": True, - "reason": f"Kicked for not having verified after {constants.Verification.kicked_after} days", + "reason": "Verification kick", "type": "note", "user": member.id, } -- cgit v1.2.3 From c77e88c564aa83bc5544b681ed86f001d8a3b865 Mon Sep 17 00:00:00 2001 From: MarkKoz Date: Thu, 15 Oct 2020 13:36:59 -0700 Subject: Snekbox: raise paste character length It doesn't make sense for it to be at 1000 when the code gets truncated to 1000 as well. Fixes #1239 --- bot/exts/utils/snekbox.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index ca6fbf5cb..59a27a2be 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -38,7 +38,7 @@ RAW_CODE_REGEX = re.compile( re.DOTALL # "." also matches newlines ) -MAX_PASTE_LEN = 1000 +MAX_PASTE_LEN = 10000 # `!eval` command whitelists EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice) -- cgit v1.2.3 From 91d6f5275d2ddd005b2479ef6fb66ebc08f45c87 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Fri, 16 Oct 2020 23:54:34 +0300 Subject: display inf id actioned in mod channel --- bot/exts/moderation/infraction/_scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 814b17830..dba3f1513 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -138,7 +138,7 @@ class InfractionScheduler: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" elif ctx.channel.id not in MODERATION_CHANNELS: log.trace( - f"Infraction #{id_} context is not in a mod channel; omitting infraction count." + f"Infraction #{id_} context is not in a mod channel; omitting infraction count and id." ) else: log.trace(f"Fetching total infraction count for {user}.") @@ -148,7 +148,7 @@ class InfractionScheduler: params={"user__id": str(user.id)} ) total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)" # Execute the necessary actions to apply the infraction on Discord. if action_coro: -- cgit v1.2.3 From 54fb16322e49dfa60bc496ed696fefe6e69b9b9e Mon Sep 17 00:00:00 2001 From: kwzrd Date: Sat, 17 Oct 2020 00:08:15 +0200 Subject: Verification: avoid logging whole kick note payload Only the `member` is variable, no need to log the rest. Co-authored-by: Numerlor --- bot/exts/moderation/verification.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c8e5b481f..f50ceaffd 100644 --- a/bot/exts/moderation/verification.py +++ b/bot/exts/moderation/verification.py @@ -372,7 +372,7 @@ class Verification(Cog): "user": member.id, } - log.trace(f"Posting kick note: {payload!r}") + log.trace(f"Posting kick note for member {member} ({member.id})") try: await self.bot.api_client.post("bot/infractions", json=payload) except ResponseCodeError as api_exc: -- cgit v1.2.3 From 29d370da244801040f128ad2dca9976c0c7ad61a Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 17 Oct 2020 10:30:13 +0200 Subject: Add sprinters role to filter whitelist I've added the sprinters role to the filter whitelist. This will not affect antispam and antimalware just yet, as they currently default to using the STAFF_ROLES constant. I've also kaizened the config-default.yml file by ensuring there are two linebreaks between all sections. Signed-off-by: Sebastiaan Zeeff --- bot/constants.py | 1 + config-default.yml | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/bot/constants.py b/bot/constants.py index 6c8b933af..0a3e48616 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -456,6 +456,7 @@ class Roles(metaclass=YAMLGetter): owners: int partners: int python_community: int + sprinters: int team_leaders: int unverified: int verified: int # This is the Developers role on PyDis, here named verified for readability reasons. diff --git a/config-default.yml b/config-default.yml index 0e7ebf2e3..c93ab9e0c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -119,6 +119,7 @@ style: voice_state_green: "https://cdn.discordapp.com/emojis/656899770094452754.png" voice_state_red: "https://cdn.discordapp.com/emojis/656899769905709076.png" + guild: id: 267624335836053506 invite: "https://discord.gg/python" @@ -225,6 +226,7 @@ guild: muted: &MUTED_ROLE 277914926603829249 partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 + sprinters: &SPRINTERS 758422482289426471 unverified: 739794855945044069 verified: 352427296948486144 # @Developers on PyDis @@ -261,6 +263,7 @@ guild: reddit: 635408384794951680 talent_pool: 569145364800602132 + filter: # What do we filter? filter_zalgo: false @@ -298,6 +301,7 @@ filter: - *OWNERS_ROLE - *HELPERS_ROLE - *PY_COMMUNITY_ROLE + - *SPRINTERS keys: @@ -326,6 +330,7 @@ urls: bot_avatar: "https://raw.githubusercontent.com/discord-python/branding/master/logos/logo_circle/logo_circle.png" github_bot_repo: "https://github.com/python-discord/bot" + anti_spam: # Clean messages that violate a rule. clean_offending: true @@ -459,10 +464,12 @@ help_channels: notify_roles: - *HELPERS_ROLE + redirect_output: delete_invocation: true delete_delay: 15 + duck_pond: threshold: 4 channel_blacklist: @@ -478,6 +485,7 @@ duck_pond: - *MOD_ANNOUNCEMENTS - *ADMIN_ANNOUNCEMENTS + python_news: mail_lists: - 'python-ideas' -- cgit v1.2.3 From f8e7b3f82244ff33cd8c8a960d7c6e734b87afd6 Mon Sep 17 00:00:00 2001 From: Sebastiaan Zeeff Date: Sat, 17 Oct 2020 10:33:31 +0200 Subject: Use filter role whitelist for all filter features We were using different whitelists for different filters, making it slightly more difficult to maintain the role whitelists. They now all use the same list, which combines our staff roles with the Python community role and the sprinters role. Signed-off-by: Sebastiaan Zeeff --- bot/exts/filters/antimalware.py | 4 ++-- bot/exts/filters/antispam.py | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/bot/exts/filters/antimalware.py b/bot/exts/filters/antimalware.py index 7894ec48f..26f00e91f 100644 --- a/bot/exts/filters/antimalware.py +++ b/bot/exts/filters/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 Channels, STAFF_ROLES, URLs +from bot.constants import Channels, Filter, URLs log = logging.getLogger(__name__) @@ -61,7 +61,7 @@ class AntiMalware(Cog): # 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): + if hasattr(message.author, "roles") and any(role.id in Filter.role_whitelist for role in message.author.roles): return embed = Embed() diff --git a/bot/exts/filters/antispam.py b/bot/exts/filters/antispam.py index 4964283f1..af8528a68 100644 --- a/bot/exts/filters/antispam.py +++ b/bot/exts/filters/antispam.py @@ -15,7 +15,6 @@ from bot.constants import ( AntiSpam as AntiSpamConfig, Channels, Colours, DEBUG_MODE, Event, Filter, Guild as GuildConfig, Icons, - STAFF_ROLES, ) from bot.converters import Duration from bot.exts.moderation.modlog import ModLog @@ -149,7 +148,7 @@ class AntiSpam(Cog): or message.guild.id != GuildConfig.id or message.author.bot or (message.channel.id in Filter.channel_whitelist and not DEBUG_MODE) - or (any(role.id in STAFF_ROLES for role in message.author.roles) and not DEBUG_MODE) + or (any(role.id in Filter.role_whitelist for role in message.author.roles) and not DEBUG_MODE) ): return -- cgit v1.2.3 From 7c5c8fa776e351263ecf6aa24f3d69570443b622 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 16:01:28 +0300 Subject: Centralize moderation channel checks --- bot/exts/info/information.py | 9 ++------- bot/exts/moderation/infraction/_scheduler.py | 5 +++-- bot/exts/moderation/infraction/management.py | 10 ++-------- bot/utils/channel.py | 10 +++++++++- config-default.yml | 4 ++-- 5 files changed, 18 insertions(+), 20 deletions(-) diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 0f50138e7..2d9cab94b 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -14,6 +14,7 @@ from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist from bot.pagination import LinePaginator +from bot.utils.channel import is_mod_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.time import time_since @@ -241,14 +242,8 @@ class Information(Cog): ), ] - # Use getattr to future-proof for commands invoked via DMs. - show_verbose = ( - ctx.channel.id in constants.MODERATION_CHANNELS - or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail - ) - # Show more verbose output in moderation channels for infractions and nominations - if show_verbose: + if is_mod_channel(ctx.channel): fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) else: diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 814b17830..12d831453 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -12,11 +12,12 @@ from discord.ext.commands import Context from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Colours, MODERATION_CHANNELS +from bot.constants import Colours from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._utils import UserSnowflake from bot.exts.moderation.modlog import ModLog from bot.utils import messages, scheduling, time +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -136,7 +137,7 @@ class InfractionScheduler: ) if reason: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in MODERATION_CHANNELS: + elif not is_mod_channel(ctx.channel): log.trace( f"Infraction #{id_} context is not in a mod channel; omitting infraction count." ) diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index cdab1a6c7..394f63da3 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -15,7 +15,7 @@ from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time -from bot.utils.checks import in_whitelist_check +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -295,13 +295,7 @@ class ModManagement(commands.Cog): """Only allow moderators inside moderator channels to invoke the commands in this cog.""" checks = [ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), - in_whitelist_check( - ctx, - channels=constants.MODERATION_CHANNELS, - categories=[constants.Categories.modmail], - redirect=None, - fail_silently=True, - ) + is_mod_channel(ctx.channel) ] return all(checks) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 851f9e1fe..d55faab57 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -2,7 +2,7 @@ import logging import discord -from bot.constants import Categories +from bot.constants import Categories, MODERATION_CHANNELS log = logging.getLogger(__name__) @@ -15,6 +15,14 @@ def is_help_channel(channel: discord.TextChannel) -> bool: return any(is_in_category(channel, category) for category in categories) +def is_mod_channel(channel: discord.TextChannel) -> bool: + """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" + log.trace(f"Checking if #{channel} is a mod channel.") + categories = (Categories.modmail, Categories.logs) + + return channel.id in MODERATION_CHANNELS or any(is_in_category(channel, category) for category in categories) + + def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: """Return True if `channel` is within a category with `category_id`.""" return getattr(channel, "category_id", None) == category_id diff --git a/config-default.yml b/config-default.yml index c93ab9e0c..12f6582ec 100644 --- a/config-default.yml +++ b/config-default.yml @@ -129,6 +129,7 @@ guild: help_in_use: 696958401460043776 help_dormant: 691405908919451718 modmail: 714494672835444826 + logs: 468520609152892958 channels: # Public announcement and news channels @@ -179,7 +180,7 @@ guild: incidents: 714214212200562749 incidents_archive: 720668923636351037 mods: &MODS 305126844661760000 - mod_alerts: &MOD_ALERTS 473092532147060736 + mod_alerts: 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 @@ -202,7 +203,6 @@ guild: moderation_channels: - *ADMINS - *ADMIN_SPAM - - *MOD_ALERTS - *MODS - *MOD_SPAM -- cgit v1.2.3 From 1a330209ca81336b964dce6d6f711f6e127b5d73 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 18:02:21 +0300 Subject: Amended to work with current tests --- bot/utils/channel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index d55faab57..615698cab 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -2,7 +2,8 @@ import logging import discord -from bot.constants import Categories, MODERATION_CHANNELS +from bot import constants +from bot.constants import Categories log = logging.getLogger(__name__) @@ -20,7 +21,8 @@ def is_mod_channel(channel: discord.TextChannel) -> bool: log.trace(f"Checking if #{channel} is a mod channel.") categories = (Categories.modmail, Categories.logs) - return channel.id in MODERATION_CHANNELS or any(is_in_category(channel, category) for category in categories) + return channel.id in constants.MODERATION_CHANNELS \ + or any(is_in_category(channel, category) for category in categories) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From db771de1122d4f60e4531fd8538cdfb7ffeb849a Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sat, 17 Oct 2020 19:15:30 +0300 Subject: Fixed style and linting --- bot/utils/channel.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 615698cab..487794c59 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -21,8 +21,8 @@ def is_mod_channel(channel: discord.TextChannel) -> bool: log.trace(f"Checking if #{channel} is a mod channel.") categories = (Categories.modmail, Categories.logs) - return channel.id in constants.MODERATION_CHANNELS \ - or any(is_in_category(channel, category) for category in categories) + return (channel.id in constants.MODERATION_CHANNELS + or any(is_in_category(channel, category) for category in categories)) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From 39ab2d8a2b00793ccf3ba51f21ece771624e24e0 Mon Sep 17 00:00:00 2001 From: Matteo Bertucci Date: Sat, 17 Oct 2020 19:16:34 +0200 Subject: Allow !eval in #code-help-voice-2 --- bot/constants.py | 1 + bot/exts/utils/snekbox.py | 2 +- config-default.yml | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/bot/constants.py b/bot/constants.py index 0a3e48616..99584ab6c 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -392,6 +392,7 @@ class Channels(metaclass=YAMLGetter): bot_commands: int change_log: int code_help_voice: int + code_help_voice_2: int cooldown: int defcon: int dev_contrib: int diff --git a/bot/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index 59a27a2be..cad451571 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -41,7 +41,7 @@ RAW_CODE_REGEX = re.compile( MAX_PASTE_LEN = 10000 # `!eval` command whitelists -EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice, Channels.code_help_voice_2) EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) diff --git a/config-default.yml b/config-default.yml index c93ab9e0c..fd96ff2c6 100644 --- a/config-default.yml +++ b/config-default.yml @@ -192,6 +192,7 @@ guild: # Voice code_help_voice: 755154969761677312 + code_help_voice_2: 766330079135268884 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 -- cgit v1.2.3 From e214f6e6cd0770625cd9a102b1d14a3772990534 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 18 Oct 2020 00:10:09 +0300 Subject: Added moderation categories section to config --- bot/constants.py | 4 ++++ bot/utils/channel.py | 7 ++++--- config-default.yml | 8 ++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/bot/constants.py b/bot/constants.py index 0a3e48616..2e6c84fc7 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -468,6 +468,7 @@ class Guild(metaclass=YAMLGetter): id: int invite: str # Discord invite, gets embedded in chat moderation_channels: List[int] + moderation_categories: List[int] moderation_roles: List[int] modlog_blacklist: List[int] reminder_whitelist: List[int] @@ -628,6 +629,9 @@ STAFF_ROLES = Guild.staff_roles # Channel combinations MODERATION_CHANNELS = Guild.moderation_channels +# Category combinations +MODERATION_CATEGORIES = Guild.moderation_categories + # Bot replies NEGATIVE_REPLIES = [ "Noooooo!!", diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 487794c59..1e67d1a9b 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -19,10 +19,11 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_mod_channel(channel: discord.TextChannel) -> bool: """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" log.trace(f"Checking if #{channel} is a mod channel.") - categories = (Categories.modmail, Categories.logs) - return (channel.id in constants.MODERATION_CHANNELS - or any(is_in_category(channel, category) for category in categories)) + return ( + channel.id in constants.MODERATION_CHANNELS + or any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES) + ) def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: diff --git a/config-default.yml b/config-default.yml index 12f6582ec..baa5c783a 100644 --- a/config-default.yml +++ b/config-default.yml @@ -128,8 +128,8 @@ guild: help_available: 691405807388196926 help_in_use: 696958401460043776 help_dormant: 691405908919451718 - modmail: 714494672835444826 - logs: 468520609152892958 + modmail: &MODMAIL 714494672835444826 + logs: &LOGS 468520609152892958 channels: # Public announcement and news channels @@ -200,6 +200,10 @@ guild: big_brother_logs: &BB_LOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 + moderation_categories: + - *MODMAIL + - *LOGS + moderation_channels: - *ADMINS - *ADMIN_SPAM -- cgit v1.2.3 From df6f1f39ccd43314218e84a8e242e1f4414c7ea4 Mon Sep 17 00:00:00 2001 From: mbaruh Date: Sun, 18 Oct 2020 00:12:38 +0300 Subject: Improved logging in is_mod_channel --- bot/exts/moderation/infraction/_scheduler.py | 6 +----- bot/utils/channel.py | 19 ++++++++++++------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 12d831453..7f18017ac 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -137,11 +137,7 @@ class InfractionScheduler: ) if reason: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif not is_mod_channel(ctx.channel): - log.trace( - f"Infraction #{id_} context is not in a mod channel; omitting infraction count." - ) - else: + elif is_mod_channel(ctx.channel): log.trace(f"Fetching total infraction count for {user}.") infractions = await self.bot.api_client.get( diff --git a/bot/utils/channel.py b/bot/utils/channel.py index 1e67d1a9b..6bf70bfde 100644 --- a/bot/utils/channel.py +++ b/bot/utils/channel.py @@ -17,13 +17,18 @@ def is_help_channel(channel: discord.TextChannel) -> bool: def is_mod_channel(channel: discord.TextChannel) -> bool: - """Return True if `channel` is one of the moderation channels or in one of the moderation categories.""" - log.trace(f"Checking if #{channel} is a mod channel.") - - return ( - channel.id in constants.MODERATION_CHANNELS - or any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES) - ) + """True if `channel` is considered a mod channel.""" + if channel.id in constants.MODERATION_CHANNELS: + log.trace(f"Channel #{channel} is a configured mod channel") + return True + + elif any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES): + log.trace(f"Channel #{channel} is in a configured mod category") + return True + + else: + log.trace(f"Channel #{channel} is not a mod channel") + return False def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: -- cgit v1.2.3 From bdd4cceccb7e0d8cfbe5ec60937c416ce6f0fb0e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:21:56 +0300 Subject: Remove unnecessary logging about user not found Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 0dab3a72e..93fa16242 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -443,8 +443,7 @@ class Infractions(InfractionScheduler, commands.Cog): log_text["Member"] = format_user(user) log_text["DM"] = "Sent" if notified else "**Failed**" else: - log.info(f"Failed to remove Voice Ban from user {user_id}: user not found") - log_text["Failure"] = "User was not found in the guild." + log_text["Info"] = "User was not found in the guild." return log_text -- cgit v1.2.3 From 3c2ad44a0bb0cd9cf39677da4bf8128bef387379 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:22:23 +0300 Subject: Fix grammar of voice ban pardoning message Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 93fa16242..a5eb720ab 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -435,8 +435,8 @@ class Infractions(InfractionScheduler, commands.Cog): # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, - title="Your Voice Ban have been removed", - content="You can now speak again in voice channels.", + title="Voice ban pardoned", + content="You can now verify yourself for voice access again.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) -- cgit v1.2.3 From bfd740be8cc0368df38a24906df592aa8f27c4e6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:23:44 +0300 Subject: Fix name and aliases of voice ban command Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index a5eb720ab..2ccb1ca97 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -89,8 +89,8 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) - @command(aliases=('vban', 'voiceban')) - async def voice_ban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: + @command(aliases=('vban',)) + async def voiceban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: """Permanently ban user from using voice channels.""" await self.apply_voice_ban(ctx, user, reason) -- cgit v1.2.3 From 29e20171a73990314161e6030f7f884e0f61a122 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:24:27 +0300 Subject: Fix grammar of voice verifing message Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8f2b51dbb..f487c41b2 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -93,7 +93,7 @@ class VoiceGate(Cog): self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") await ctx.author.send( - ":tada: Congratulations! You are now Voice Verified and have access to PyDis Voice Channels." + ":tada: Congratulations! You have been granted permission to use voice channels in Python Discord." ) self.bot.stats.incr("voice_gate.passed") -- cgit v1.2.3 From a8b3d0c8c20364ca9737520ffe8e6a6ce649ad5a Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:27:39 +0300 Subject: Give user free pass when user don't have verified time in metricity --- bot/exts/moderation/voice_gate.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index f487c41b2..bdf7857f0 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -58,7 +58,10 @@ class VoiceGate(Cog): return # Pre-parse this for better code style - data["verified_at"] = parser.isoparse(data["verified_at"]) + if data["verified_at"] is not None: + data["verified_at"] = parser.isoparse(data["verified_at"]) + else: + data["verified_at"] = datetime.now() - timedelta(days=3) failed = False failed_reasons = [] -- cgit v1.2.3 From ea58222a5cfce295392bd5998b5968df89ddfeea Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:29:34 +0300 Subject: Don't add Voice Verified role automatically back --- bot/exts/moderation/infraction/infractions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 2ccb1ca97..fc01eee9e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -428,10 +428,6 @@ class Infractions(InfractionScheduler, commands.Cog): log_text = {} if user: - # Add Voice Verified role back to user. - self.mod_log.ignore(Event.member_update, user.id) - await user.add_roles(self._voice_verified_role, reason=reason) - # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, -- cgit v1.2.3 From 5d732c97daccece1fe7945d92b426be76ee02ea0 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:31:36 +0300 Subject: Fix user not found info field test --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index caa42ba3d..b666e1f85 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -176,7 +176,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): """Should include info to return dict when user was not found from guild.""" self.guild.get_member.return_value = None result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") - self.assertEqual(result, {"Failure": "User was not found in the guild."}) + self.assertEqual(result, {"Info": "User was not found in the guild."}) @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") @patch("bot.exts.moderation.infraction.infractions.format_user") -- cgit v1.2.3 From 77effb0bdf167020f4733b8d8e2bf980a4016f52 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 09:36:53 +0300 Subject: Update tests to not automatically adding back verified after vban expire --- tests/bot/exts/moderation/infraction/test_infractions.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index b666e1f85..f2617cf59 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -182,7 +182,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): @patch("bot.exts.moderation.infraction.infractions.format_user") async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): """Should add role back with ignoring, notify user and return log dictionary..""" - self.cog.mod_log.ignore = MagicMock() self.guild.get_member.return_value = self.user notify_pardon_mock.return_value = True format_user_mock.return_value = "my-user" @@ -192,8 +191,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): "Member": "my-user", "DM": "Sent" }) - self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) - self.user.add_roles.assert_awaited_once_with(self.cog._voice_verified_role, reason="foobar") notify_pardon_mock.assert_awaited_once() @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") -- cgit v1.2.3 From b72963930a6d8d28c794c5973efbb83def39a281 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:04:36 +0300 Subject: Use embeds instead of normal messages and send to DM instead --- bot/exts/moderation/voice_gate.py | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index bdf7857f0..4a7c66278 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta import discord from dateutil import parser +from discord import Colour from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError @@ -46,15 +47,28 @@ class VoiceGate(Cog): - You must have accepted our rules over a certain number of days ago - You must not be actively banned from using our voice channels """ + # Send this as first thing in order to return after sending DM + await ctx.send("Check your DMs for result.") + try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") except ResponseCodeError as e: if e.status == 404: - await ctx.send(f":x: {ctx.author.mention} Unable to find Metricity data about you.") + embed = discord.Embed( + title="Not found", + description=f"{ctx.author.mention} Unable to find Metricity data about you.", + color=Colour.red() + ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: - log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} metricity data.") - await ctx.send(":x: Got unexpected response from site. Please let us know about this.") + embed = discord.Embed( + title="Unexpected response", + description="Got unexpected response from site. Please let us know about this.", + color=Colour.red() + ) + log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") + + await ctx.author.send(embed=embed) return # Pre-parse this for better code style @@ -85,19 +99,22 @@ class VoiceGate(Cog): else: reasons = failed_reasons[0] - await ctx.send( - FAILED_MESSAGE.format( - user=ctx.author.mention, - reasons=reasons - ) + embed = discord.Embed( + title="Voice Gate not passed", + description=FAILED_MESSAGE.format(user=ctx.author.mention, reasons=reasons), + color=Colour.red() ) + await ctx.author.send(embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") - await ctx.author.send( - ":tada: Congratulations! You have been granted permission to use voice channels in Python Discord." + embed = discord.Embed( + title="Congratulations", + description="You have been granted permission to use voice channels in Python Discord.", + color=Colour.green() ) + await ctx.author.send(embed=embed) self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 61206175591841d7ffed7b202c1bcf81d2b9ba99 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:04:49 +0300 Subject: Fix voice ban command name in test --- tests/bot/exts/moderation/infraction/test_infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index f2617cf59..5dbbb8e00 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -71,7 +71,7 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): async def test_permanent_voice_ban(self): """Should call voice ban applying function without expiry.""" self.cog.apply_voice_ban = AsyncMock() - self.assertIsNone(await self.cog.voice_ban(self.cog, self.ctx, self.user, reason="foobar")) + self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") async def test_temporary_voice_ban(self): -- cgit v1.2.3 From 152d105715fcd9843362b09c582773191bf2af9c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:14:23 +0300 Subject: Rework how voice gate do checks --- bot/exts/moderation/voice_gate.py | 42 +++++++++++++++++---------------------- 1 file changed, 18 insertions(+), 24 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 4a7c66278..c367510ad 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -9,20 +9,21 @@ from discord.ext.commands import Cog, Context, command from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as VoiceGateConf +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf from bot.decorators import has_no_roles, in_whitelist from bot.exts.moderation.modlog import ModLog from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) -# Messages for case when user don't meet with requirements -NOT_ENOUGH_MESSAGES = f"haven't sent at least {VoiceGateConf.minimum_messages} messages" -NOT_ENOUGH_DAYS_AFTER_VERIFICATION = f"haven't been verified for at least {VoiceGateConf.minimum_days_verified} days" -VOICE_BANNED = "are voice banned" - FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" +MESSAGE_FIELD_MAP = { + "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", + "voice_banned": "are voice banned", + "total_messages": f"haven't sent at least {GateConf.minimum_messages} messages", +} + class VoiceGate(Cog): """Voice channels verification management.""" @@ -75,23 +76,16 @@ class VoiceGate(Cog): if data["verified_at"] is not None: data["verified_at"] = parser.isoparse(data["verified_at"]) else: - data["verified_at"] = datetime.now() - timedelta(days=3) - - failed = False - failed_reasons = [] - - if data["verified_at"] > datetime.utcnow() - timedelta(days=VoiceGateConf.minimum_days_verified): - failed_reasons.append(NOT_ENOUGH_DAYS_AFTER_VERIFICATION) - failed = True - self.bot.stats.incr("voice_gate.failed.verified_at") - if data["total_messages"] < VoiceGateConf.minimum_messages: - failed_reasons.append(NOT_ENOUGH_MESSAGES) - failed = True - self.bot.stats.incr("voice_gate.failed.total_messages") - if data["voice_banned"]: - failed_reasons.append(VOICE_BANNED) - failed = True - self.bot.stats.incr("voice_gate.failed.voice_banned") + data["verified_at"] = datetime.utcnow() - timedelta(days=3) + + checks = { + "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), + "total_messages": data["total_messages"] < GateConf.minimum_messages, + "voice_banned": data["voice_banned"] + } + failed = any(checks.values()) + failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] + [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] if failed: if len(failed_reasons) > 1: @@ -130,7 +124,7 @@ class VoiceGate(Cog): # When it's bot sent message, delete it after some time if message.author.bot: with suppress(discord.NotFound): - await message.delete(delay=VoiceGateConf.bot_message_delete_delay) + await message.delete(delay=GateConf.bot_message_delete_delay) return # Then check is member moderator+, because we don't want to delete their messages. -- cgit v1.2.3 From ee241f5c3b87cfe576351f9baeed54c4f30147db Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 10:16:28 +0300 Subject: Change message that say to user that he get response to DM --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index c367510ad..05a3b31de 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -49,7 +49,7 @@ class VoiceGate(Cog): - You must not be actively banned from using our voice channels """ # Send this as first thing in order to return after sending DM - await ctx.send("Check your DMs for result.") + await ctx.send("You will get response to DM.") try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") -- cgit v1.2.3 From edc099882df1cbb792e005ce36ec36d974a938ff Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 14:45:22 +0300 Subject: Fix grammar and wording of Voice Gate + Voice Ban Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ++-- bot/exts/moderation/voice_gate.py | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index fc01eee9e..f2ca6a763 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -431,8 +431,8 @@ class Infractions(InfractionScheduler, commands.Cog): # DM user about infraction expiration notified = await _utils.notify_pardon( user=user, - title="Voice ban pardoned", - content="You can now verify yourself for voice access again.", + title="Voice ban ended", + content="You can verify yourself for voice access again.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 05a3b31de..639642068 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -21,7 +21,7 @@ FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass MESSAGE_FIELD_MAP = { "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", "voice_banned": "are voice banned", - "total_messages": f"haven't sent at least {GateConf.minimum_messages} messages", + "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } @@ -49,7 +49,7 @@ class VoiceGate(Cog): - You must not be actively banned from using our voice channels """ # Send this as first thing in order to return after sending DM - await ctx.send("You will get response to DM.") + await ctx.send(f"{ctx.author.mention}, check your DMs.") try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") @@ -57,14 +57,14 @@ class VoiceGate(Cog): if e.status == 404: embed = discord.Embed( title="Not found", - description=f"{ctx.author.mention} Unable to find Metricity data about you.", + description=f"We were unable to find user data for you. Please try again shortly, if this problem persists please contact the server staff through Modmail.", color=Colour.red() ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: embed = discord.Embed( title="Unexpected response", - description="Got unexpected response from site. Please let us know about this.", + description="We encountered an error while attempting to find data for your user. Please try again and let us know if the problem persists.", color=Colour.red() ) log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") @@ -104,7 +104,7 @@ class VoiceGate(Cog): self.mod_log.ignore(Event.member_update, ctx.author.id) await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") embed = discord.Embed( - title="Congratulations", + title="Voice gate passed", description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) -- cgit v1.2.3 From 3c09836736711de1d25e270c643299e0290eb636 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 14:46:11 +0300 Subject: Remove checking does user have voice verified role for voice ban Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index f2ca6a763..d41e6326e 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -359,10 +359,6 @@ class Infractions(InfractionScheduler, commands.Cog): @respect_role_hierarchy(member_arg=2) async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" - if constants.Roles.voice_verified not in [role.id for role in user.roles]: - await ctx.send(":x: Can't apply Voice Ban to user who have not passed the Voice Gate.") - return - if await _utils.get_active_infraction(ctx, user, "voice_ban"): return -- cgit v1.2.3 From 00b2a7551a0ff7e758d546c18849474ca8ee173c Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:10:20 +0300 Subject: Remove _ from infraction type when sending back result --- bot/exts/moderation/infraction/_scheduler.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index dba3f1513..bba80afaf 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -125,7 +125,7 @@ class InfractionScheduler: log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") else: # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type, expiry, reason, icon): + if await _utils.notify_infraction(user, " ".join(infr_type.split("_")).title(), expiry, reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" @@ -166,7 +166,7 @@ class InfractionScheduler: log_content = ctx.author.mention log_title = "failed to apply" - log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}" if isinstance(e, discord.Forbidden): log.warning(f"{log_msg}: bot lacks permissions.") else: @@ -183,7 +183,7 @@ class InfractionScheduler: log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") infr_message = "" else: - infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" + infr_message = f" **{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") @@ -195,7 +195,7 @@ class InfractionScheduler: await self.mod_log.send_log_message( icon_url=icon, colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", + title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {messages.format_user(user)} @@ -272,7 +272,7 @@ class InfractionScheduler: if send_msg: log.trace(f"Sending infraction #{id_} pardon confirmation message.") await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{dm_emoji}{confirm_msg} infraction **{' '.join(infr_type.split('_'))}** for {user.mention}. " f"{log_text.get('Failure', '')}" ) @@ -283,7 +283,7 @@ class InfractionScheduler: await self.mod_log.send_log_message( icon_url=_utils.INFRACTION_ICONS[infr_type][1], colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", + title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", thumbnail=user.avatar_url_as(static_format="png"), text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=footer, -- cgit v1.2.3 From 07187bd53c28f5c837f3a90eb063efea39c0cc09 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:11:43 +0300 Subject: Fix grammar of fail messages of Voice Gate --- bot/exts/moderation/voice_gate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 639642068..325331999 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -19,8 +19,8 @@ log = logging.getLogger(__name__) FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" MESSAGE_FIELD_MAP = { - "verified_at": f"haven't been verified for at least {GateConf.minimum_days_verified} days", - "voice_banned": "are voice banned", + "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", + "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } -- cgit v1.2.3 From 1ce453a13b37f8b4b42ab0b87e0cac242f3b9739 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:21:26 +0300 Subject: Update formatting of voice gate failing embed --- bot/exts/moderation/voice_gate.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 325331999..5516675d1 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -16,7 +16,9 @@ from bot.utils.checks import InWhitelistCheckFailure log = logging.getLogger(__name__) -FAILED_MESSAGE = """{user} you don't meet with our current requirements to pass Voice Gate. You {reasons}.""" +FAILED_MESSAGE = ( + """You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}""" +) MESSAGE_FIELD_MAP = { "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", @@ -88,14 +90,9 @@ class VoiceGate(Cog): [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] if failed: - if len(failed_reasons) > 1: - reasons = f"{', '.join(failed_reasons[:-1])} and {failed_reasons[-1]}" - else: - reasons = failed_reasons[0] - embed = discord.Embed( title="Voice Gate not passed", - description=FAILED_MESSAGE.format(user=ctx.author.mention, reasons=reasons), + description=FAILED_MESSAGE.format(reasons="\n".join(f'- You {reason}.' for reason in failed_reasons)), color=Colour.red() ) await ctx.author.send(embed=embed) -- cgit v1.2.3 From 082a6c0ee67ef627e987d6f9f17f1886eedb2518 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:22:45 +0300 Subject: Use .title() instead of .capitalize() --- bot/exts/moderation/infraction/_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index bff5fcf4c..d0dc3f0a1 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -155,7 +155,7 @@ async def notify_infraction( log.trace(f"Sending {user} a DM about their {infr_type} infraction.") text = INFRACTION_DESCRIPTION_TEMPLATE.format( - type=infr_type.capitalize(), + type=infr_type.title(), expires=expires_at or "N/A", reason=reason or "No reason provided." ) -- cgit v1.2.3 From db0251496884add226e66e0522f27521b1be1496 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:25:48 +0300 Subject: Fix too long lines for Voice Gate --- bot/exts/moderation/voice_gate.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 5516675d1..37db5dc87 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -59,14 +59,21 @@ class VoiceGate(Cog): if e.status == 404: embed = discord.Embed( title="Not found", - description=f"We were unable to find user data for you. Please try again shortly, if this problem persists please contact the server staff through Modmail.", + description=( + "We were unable to find user data for you. " + "Please try again shortly, " + "if this problem persists please contact the server staff through Modmail.", + ), color=Colour.red() ) log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") else: embed = discord.Embed( title="Unexpected response", - description="We encountered an error while attempting to find data for your user. Please try again and let us know if the problem persists.", + description=( + "We encountered an error while attempting to find data for your user. " + "Please try again and let us know if the problem persists." + ), color=Colour.red() ) log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") -- cgit v1.2.3 From e840f0f17d5f9cdfde9c610ef75224ca84fe52a4 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 15:31:00 +0300 Subject: Remove test for case when user don't have VV for voice ban --- tests/bot/exts/moderation/infraction/test_infractions.py | 8 -------- 1 file changed, 8 deletions(-) diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index 5dbbb8e00..bf557a484 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -86,14 +86,6 @@ class VoiceBanTests(unittest.IsolatedAsyncioTestCase): self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) - @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") - async def test_voice_ban_not_having_voice_verified_role(self, get_active_infraction_mock): - """Should send message and not apply infraction when user don't have voice verified role.""" - self.user.roles = [MockRole(id=987)] - self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) - self.ctx.send.assert_awaited_once() - get_active_infraction_mock.assert_not_awaited() - @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): -- cgit v1.2.3 From f195d3d16b9ae4f66a4420f1d8bb7a004a90a7a6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:36:16 +0300 Subject: Remove too much aliases for voice verify command Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 37db5dc87..7c3c6e1b0 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -38,7 +38,7 @@ class VoiceGate(Cog): """Get the currently loaded ModLog cog instance.""" return self.bot.get_cog("ModLog") - @command(aliases=('voiceverify', 'vverify', 'voicev', 'vv')) + @command(aliases=('voiceverify',)) @has_no_roles(Roles.voice_verified) @in_whitelist(channels=(Channels.voice_gate,), redirect=None) async def voice_verify(self, ctx: Context, *_) -> None: -- cgit v1.2.3 From 905d90b27570831ad64d8e08cb8d0bc4d23c614e Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:36:55 +0300 Subject: Use bullet points instead of - for voice verify failing reasons Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 7c3c6e1b0..70583655e 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -99,7 +99,7 @@ class VoiceGate(Cog): if failed: embed = discord.Embed( title="Voice Gate not passed", - description=FAILED_MESSAGE.format(reasons="\n".join(f'- You {reason}.' for reason in failed_reasons)), + description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) await ctx.author.send(embed=embed) -- cgit v1.2.3 From a04575ce7ba43623d79ad4ae5611a093a7a452c5 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:37:25 +0300 Subject: Fix grammar of voice verification config comments Co-authored-by: Joe Banks --- config-default.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config-default.yml b/config-default.yml index 5060e48e0..c712d1eb7 100644 --- a/config-default.yml +++ b/config-default.yml @@ -511,8 +511,8 @@ verification: voice_gate: - minimum_days_verified: 3 # Days how much user have to be verified to pass Voice Gate - minimum_messages: 50 # How much messages user must have to pass Voice Gate + minimum_days_verified: 3 # How many days the user must have been verified for + minimum_messages: 50 # How many messages a user must have to be eligible for voice bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate -- cgit v1.2.3 From 5154b39f8a2dab9531cdb90f27a62dfede49eed6 Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:37:53 +0300 Subject: Fix grammar of voice unban embed description Co-authored-by: Joe Banks --- bot/exts/moderation/infraction/infractions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index d41e6326e..71d873667 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -428,7 +428,7 @@ class Infractions(InfractionScheduler, commands.Cog): notified = await _utils.notify_pardon( user=user, title="Voice ban ended", - content="You can verify yourself for voice access again.", + content="You have been unbanned and can verify yourself again in the server.", icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] ) -- cgit v1.2.3 From 216bdb0947e9fa8b494e03f3be0d85867453f41d Mon Sep 17 00:00:00 2001 From: ks129 <45097959+ks129@users.noreply.github.com> Date: Sun, 18 Oct 2020 16:38:21 +0300 Subject: Use "failed" instead "not passed" for feedback embed of voice gate fail Co-authored-by: Joe Banks --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 70583655e..7cadca153 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -98,7 +98,7 @@ class VoiceGate(Cog): if failed: embed = discord.Embed( - title="Voice Gate not passed", + title="Voice Gate failed", description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) -- cgit v1.2.3 From c49eb6597da7eb0e6973177e4e3e40730267cc11 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 00:59:39 +1000 Subject: Send response in verification if DM fails. At the moment, the bot will attempt to DM the verification result for a member which is reliant on privacy settings allowing member DMs. This commit should add a suitable fallback of sending the response in the voice-verification channel instead. --- bot/exts/moderation/voice_gate.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 7cadca153..8b68b8e2d 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -102,7 +102,10 @@ class VoiceGate(Cog): description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), color=Colour.red() ) - await ctx.author.send(embed=embed) + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + await ctx.channel.send(ctx.author.mention, embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) @@ -112,7 +115,10 @@ class VoiceGate(Cog): description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) - await ctx.author.send(embed=embed) + try: + await ctx.author.send(embed=embed) + except discord.Forbidden: + await ctx.channel.send(ctx.author.mention, embed=embed) self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 572679094288734bbbf9bac5dc59bbe1e7dad155 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 01:15:20 +1000 Subject: Disconnect users on voiceban. On voiceban, a users effective permissions will change to not allow speaking, however this permission isn't effective until rejoining. To ensure a voiceban is immediately in effect, the user will be disconnected from any voice channels. --- bot/exts/moderation/infraction/infractions.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 71d873667..746d4e154 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -371,6 +371,8 @@ class Infractions(InfractionScheduler, commands.Cog): if reason: reason = textwrap.shorten(reason, width=512, placeholder="...") + await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + action = user.remove_roles(self._voice_verified_role, reason=reason) await self.apply_infraction(ctx, infraction, user, action) -- cgit v1.2.3 From 5cc01bc834e8b89b21546870989afb93b11aa554 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 02:53:36 +1000 Subject: Ensure verified users can see verified message. When verified users get their role, they cannot see the voice-verification channel anymore, so I've added a 3 second delay for granting the role in order to ensure there's some time for them to see the response. I've also moved the DM message to only be sent if the DM message succeeds, and to not mention them in-channel to avoid distracting them from the DM notification unnecessarily, as I'm sure they'll see a near-instant response to their command usage in that channel. --- bot/exts/moderation/voice_gate.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index 8b68b8e2d..f158c2906 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -1,3 +1,4 @@ +import asyncio import logging from contextlib import suppress from datetime import datetime, timedelta @@ -50,9 +51,6 @@ class VoiceGate(Cog): - You must have accepted our rules over a certain number of days ago - You must not be actively banned from using our voice channels """ - # Send this as first thing in order to return after sending DM - await ctx.send(f"{ctx.author.mention}, check your DMs.") - try: data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") except ResponseCodeError as e: @@ -104,12 +102,12 @@ class VoiceGate(Cog): ) try: await ctx.author.send(embed=embed) + await ctx.send(f"{ctx.author}, please check your DMs.") except discord.Forbidden: await ctx.channel.send(ctx.author.mention, embed=embed) return self.mod_log.ignore(Event.member_update, ctx.author.id) - await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") embed = discord.Embed( title="Voice gate passed", description="You have been granted permission to use voice channels in Python Discord.", @@ -117,8 +115,14 @@ class VoiceGate(Cog): ) try: await ctx.author.send(embed=embed) + await ctx.send(f"{ctx.author}, please check your DMs.") except discord.Forbidden: await ctx.channel.send(ctx.author.mention, embed=embed) + + # wait a little bit so those who don't get DMs see the response in-channel before losing perms to see it. + await asyncio.sleep(3) + await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + self.bot.stats.incr("voice_gate.passed") @Cog.listener() -- cgit v1.2.3 From 7b8f85752098e5bfd77e033d8eddad3b8e5f2b40 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 02:59:32 +1000 Subject: Address a grammar error in failed reasons. An overlooked grammatical error occurred in exactly 1 (one) of the possible failure reasons when being verified for the voice gate system. This was unacceptable to the masses, so a swift correction has been added to address it, adding 1 (one) additional word to the listed reason. --- bot/exts/moderation/voice_gate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index f158c2906..ee3ac4003 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -22,7 +22,7 @@ FAILED_MESSAGE = ( ) MESSAGE_FIELD_MAP = { - "verified_at": f"have been verified for less {GateConf.minimum_days_verified} days", + "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days", "voice_banned": "have an active voice ban infraction", "total_messages": f"have sent less than {GateConf.minimum_messages} messages", } -- cgit v1.2.3 From 0bf48fce994e51428d679605281a879d2abc9905 Mon Sep 17 00:00:00 2001 From: scragly <29337040+scragly@users.noreply.github.com> Date: Mon, 19 Oct 2020 03:13:12 +1000 Subject: Instruct to reconnect to voice channel if connected on verification. If a user is already connected to a voice channel at the time of getting verified through voice gate, they won't have their permissions actually apply to their current session. This change adds information on verifying so they know they must reconnect to have the changes apply. --- bot/exts/moderation/voice_gate.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py index ee3ac4003..c2743e136 100644 --- a/bot/exts/moderation/voice_gate.py +++ b/bot/exts/moderation/voice_gate.py @@ -113,6 +113,10 @@ class VoiceGate(Cog): description="You have been granted permission to use voice channels in Python Discord.", color=Colour.green() ) + + if ctx.author.voice: + embed.description += "\n\nPlease reconnect to your voice channel to be granted your new permissions." + try: await ctx.author.send(embed=embed) await ctx.send(f"{ctx.author}, please check your DMs.") -- cgit v1.2.3 From 5be3f87751d4bf87c848f278050867ba45c442ec Mon Sep 17 00:00:00 2001 From: Joe Banks Date: Wed, 21 Oct 2020 12:42:08 +0100 Subject: Relay python-dev to mailing lists channel --- config-default.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/config-default.yml b/config-default.yml index 71d4419a7..98c5ff42c 100644 --- a/config-default.yml +++ b/config-default.yml @@ -498,6 +498,7 @@ python_news: - 'python-ideas' - 'python-announce-list' - 'pypi-announce' + - 'python-dev' channel: *PYNEWS_CHANNEL webhook: *PYNEWS_WEBHOOK -- cgit v1.2.3