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 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 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 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