diff options
28 files changed, 1255 insertions, 402 deletions
diff --git a/LICENSE-THIRD-PARTY b/LICENSE-THIRD-PARTY index a126700a3..eacd9b952 100644 --- a/LICENSE-THIRD-PARTY +++ b/LICENSE-THIRD-PARTY @@ -1,4 +1,40 @@ --------------------------------------------------------------------------------------------------- + BSD 3-Clause License +Applies to: + - Copyright (c) 2008-Present, IPython Development Team + Copyright (c) 2001-2007, Fernando Perez <[email protected]> + Copyright (c) 2001, Janko Hauser <[email protected]> + Copyright (c) 2001, Nathaniel Gray <[email protected]> + All rights reserved. + - bot/exts/info/codeblock/_parsing.py: _RE_PYTHON_REPL and portions of _RE_IPYTHON_REPL +--------------------------------------------------------------------------------------------------- + +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. + +--------------------------------------------------------------------------------------------------- PYTHON SOFTWARE FOUNDATION LICENSE VERSION 2 Applies to: - Copyright © 2001-2020 Python Software Foundation. All rights reserved. diff --git a/bot/__main__.py b/bot/__main__.py index da042a5ed..367be1300 100644 --- a/bot/__main__.py +++ b/bot/__main__.py @@ -58,7 +58,7 @@ bot = Bot( redis_session=redis_session, loop=loop, command_prefix=when_mentioned_or(constants.Bot.prefix), - activity=discord.Game(name="Commands: !help"), + activity=discord.Game(name=f"Commands: {constants.Bot.prefix}help"), case_insensitive=True, max_messages=10_000, allowed_mentions=discord.AllowedMentions(everyone=False, roles=allowed_roles), diff --git a/bot/constants.py b/bot/constants.py index c21fd52e0..b615dcd19 100644 --- a/bot/constants.py +++ b/bot/constants.py @@ -392,6 +392,7 @@ class Channels(metaclass=YAMLGetter): bot_commands: int change_log: int code_help_voice: int + code_help_voice_2: int cooldown: int defcon: int dev_contrib: int @@ -424,6 +425,7 @@ class Channels(metaclass=YAMLGetter): user_event_announcements: int user_log: int verification: int + voice_gate: int voice_log: int @@ -456,9 +458,11 @@ 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. + voice_verified: int class Guild(metaclass=YAMLGetter): @@ -467,6 +471,7 @@ class Guild(metaclass=YAMLGetter): id: int invite: str # Discord invite, gets embedded in chat moderation_channels: List[int] + moderation_categories: List[int] moderation_roles: List[int] modlog_blacklist: List[int] reminder_whitelist: List[int] @@ -528,6 +533,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' @@ -578,6 +592,14 @@ class Verification(metaclass=YAMLGetter): kick_confirmation_threshold: float +class VoiceGate(metaclass=YAMLGetter): + section = "voice_gate" + + minimum_days_verified: int + minimum_messages: int + bot_message_delete_delay: int + + class Event(Enum): """ Event names. This does not include every event (for example, raw @@ -618,6 +640,9 @@ STAFF_ROLES = Guild.staff_roles # Channel combinations MODERATION_CHANNELS = Guild.moderation_channels +# Category combinations +MODERATION_CATEGORIES = Guild.moderation_categories + # Bot replies NEGATIVE_REPLIES = [ "Noooooo!!", diff --git a/bot/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 diff --git a/bot/exts/fun/duck_pond.py b/bot/exts/fun/duck_pond.py index 82084ea88..48aa2749c 100644 --- a/bot/exts/fun/duck_pond.py +++ b/bot/exts/fun/duck_pond.py @@ -22,6 +22,7 @@ class DuckPond(Cog): self.bot = bot self.webhook_id = constants.Webhooks.duck_pond self.webhook = None + self.ducked_messages = [] self.bot.loop.create_task(self.fetch_webhook()) self.relay_lock = None @@ -176,7 +177,8 @@ class DuckPond(Cog): duck_count = await self.count_ducks(message) # If we've got more than the required amount of ducks, send the message to the duck_pond. - if duck_count >= constants.DuckPond.threshold: + if duck_count >= constants.DuckPond.threshold and message.id not in self.ducked_messages: + self.ducked_messages.append(message.id) await self.locked_relay(message) @Cog.listener() diff --git a/bot/exts/help_channels.py b/bot/exts/help_channels.py index f5c9a5dd0..062d4fcfe 100644 --- a/bot/exts/help_channels.py +++ b/bot/exts/help_channels.py @@ -14,6 +14,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.scheduling import Scheduler log = logging.getLogger(__name__) @@ -378,11 +379,18 @@ class HelpChannels(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) @@ -442,12 +450,6 @@ class HelpChannels(commands.Cog): return False return message.author == self.bot.user and bot_msg_desc.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. @@ -498,7 +500,7 @@ class HelpChannels(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] @@ -646,7 +648,7 @@ class HelpChannels(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 @@ -671,7 +673,8 @@ class HelpChannels(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.") @@ -681,7 +684,7 @@ class HelpChannels(commands.Cog): async with self.on_message_lock: log.trace(f"on_message lock acquired for {message.id}.") - if not self.is_in_category(channel, constants.Categories.help_available): + if 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." @@ -719,7 +722,7 @@ class HelpChannels(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): @@ -844,18 +847,6 @@ class HelpChannels(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 pin_wrapper(self, msg_id: int, channel: discord.TextChannel, *, pin: bool) -> bool: """ Pin message `msg_id` in `channel` if `pin` is True or unpin if it's False. diff --git a/bot/exts/info/codeblock/__init__.py b/bot/exts/info/codeblock/__init__.py new file mode 100644 index 000000000..5c55bc5e3 --- /dev/null +++ b/bot/exts/info/codeblock/__init__.py @@ -0,0 +1,8 @@ +from bot.bot import Bot + + +def setup(bot: Bot) -> None: + """Load the CodeBlockCog cog.""" + # Defer import to reduce side effects from importing the codeblock package. + from bot.exts.info.codeblock._cog import CodeBlockCog + bot.add_cog(CodeBlockCog(bot)) diff --git a/bot/exts/info/codeblock/_cog.py b/bot/exts/info/codeblock/_cog.py new file mode 100644 index 000000000..1e0feab0d --- /dev/null +++ b/bot/exts/info/codeblock/_cog.py @@ -0,0 +1,186 @@ +import logging +import time +from typing import Optional + +import discord +from discord import Message, RawMessageUpdateEvent +from discord.ext.commands import Cog + +from bot import constants +from bot.bot import Bot +from bot.exts.filters.token_remover import TokenRemover +from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE +from bot.exts.info.codeblock._instructions import get_instructions +from bot.utils import has_lines +from bot.utils.channel import is_help_channel +from bot.utils.messages import wait_for_deletion + +log = logging.getLogger(__name__) + + +class CodeBlockCog(Cog, name="Code Block"): + """ + 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 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. + + 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 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 = dict.fromkeys(constants.CodeBlock.cooldown_channels, 0.0) + + # Maps users' messages to the messages the bot sent with instructions. + 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`. + + 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(payload.channel_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 + + def is_on_cooldown(self, channel: discord.TextChannel) -> bool: + """ + 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.") + 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.""" + log.trace(f"Checking if #{channel} qualifies for code block detection.") + return ( + is_help_channel(channel) + or channel.id in self.channel_cooldowns + or channel.id in constants.CodeBlock.channel_whitelist + ) + + async def send_instructions(self, message: discord.Message, instructions: str) -> None: + """ + Send an embed with `instructions` on fixing an incorrect code block in a `message`. + + The embed will be deleted automatically after 5 minutes. + """ + 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) + self.codeblock_message_ids[message.id] = bot_message.id + + self.bot.loop.create_task(wait_for_deletion(bot_message, (message.author.id,), 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. + + 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 or webhook token + """ + return ( + not message.author.bot + and self.is_valid_channel(message.channel) + and has_lines(message.content, constants.CodeBlock.minimum_lines) + and not TokenRemover.find_token_in_message(message) + and not WEBHOOK_URL_RE.search(message.content) + ) + + @Cog.listener() + 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 constants.DEBUG_MODE: + log.trace(f"Skipping code block detection of {msg.id}: #{msg.channel} is on cooldown.") + return + + instructions = get_instructions(msg.content) + if instructions: + await self.send_instructions(msg, instructions) + + 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() + + @Cog.listener() + async def on_raw_message_edit(self, payload: RawMessageUpdateEvent) -> None: + """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 + + 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. + 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.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.info("Message edited but still has invalid code blocks; editing the instructions.") + await bot_message.edit(embed=self.create_embed(instructions)) diff --git a/bot/exts/info/codeblock/_instructions.py b/bot/exts/info/codeblock/_instructions.py new file mode 100644 index 000000000..508f157fb --- /dev/null +++ b/bot/exts/info/codeblock/_instructions.py @@ -0,0 +1,184 @@ +"""This module generates and formats instructional messages about fixing Markdown code blocks.""" + +import logging +from typing import Optional + +from bot.exts.info.codeblock import _parsing + +log = logging.getLogger(__name__) + +_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_example(language: str) -> str: + """Return an example of a correct code block using `language` for syntax highlighting.""" + # Determine the example code to put in the code block based on the language specifier. + 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) + 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}\n..." + else: + log.trace("Code block has no language specifier.") + content = "\nHello, 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.") + + 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}`." + ) + + 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 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 + # already have an example code block. + if addition_msg: + log.trace("Language specifier issue found; appending additional instructions.") + + # 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. + instructions += "\n\nFurthermore, " + addition_msg[0].lower() + addition_msg[1:] + else: + log.trace("No issues with the language specifier found.") + 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 + + +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_python_code(content): + 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 " + "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}" + ) + else: + log.trace("Aborting missing code block instructions: content is not Python code.") + + +def _get_bad_lang_message(content: str) -> Optional[str]: + """ + Return instructions on fixing the Python language specifier for a code block. + + 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 + + lines = [] + language = info.language + + 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.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}`. " + f"There must not be any spaces after `{language}`." + ) + + if lines: + lines = " ".join(lines) + example_blocks = _get_example(language) + + # 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("Nothing wrong with the language specifier; no instructions to return.") + + +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. + """ + log.trace("Creating instructions for a missing language.") + + 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. + 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}" + ) + else: + log.trace("Aborting missing language instructions: content is not Python code.") + + +def get_instructions(content: str) -> Optional[str]: + """ + 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) + if blocks is None: + log.trace("At least one valid code block found; no instructions to return.") + return + + if not blocks: + log.trace("No code blocks were found in message.") + 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.") + 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. + instructions = _get_bad_lang_message(block.content) + if not instructions: + instructions = _get_no_lang_message(block.content) + + if instructions: + instructions += "\nYou can **edit your original message** to correct your code block." + + return instructions diff --git a/bot/exts/info/codeblock/_parsing.py b/bot/exts/info/codeblock/_parsing.py new file mode 100644 index 000000000..a98218dfb --- /dev/null +++ b/bot/exts/info/codeblock/_parsing.py @@ -0,0 +1,228 @@ +"""This module provides functions for parsing Markdown code blocks.""" + +import ast +import logging +import re +import textwrap +from typing import NamedTuple, Optional, Sequence + +from bot import constants +from bot.utils import has_lines + +log = logging.getLogger(__name__) + +BACKTICK = "`" +PY_LANG_CODES = ("python", "pycon", "py") # Order is important; "py" is last cause it's a subset. +_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_PYTHON_REPL = re.compile(r"^(>>>|\.\.\.)( |$)") +_RE_IPYTHON_REPL = re.compile(r"^((In|Out) \[\d+\]: |\s*\.{3,}: ?)") + +_RE_CODE_BLOCK = re.compile( + fr""" + (?P<ticks> + (?P<tick>[{''.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<lang>[^\W_]+\n)? # Optionally match a language specifier followed by a newline. + (?P<code>.+?) # Match the actual code within the block. + \1 # Match the same 3 ticks used at the start of the block. + """, + re.DOTALL | re.VERBOSE +) + +_RE_LANGUAGE = re.compile( + fr""" + ^(?P<spaces>\s+)? # Optionally match leading spaces from the beginning. + (?P<lang>{'|'.join(PY_LANG_CODES)}) # Match a Python language. + (?P<newline>\n)? # Optionally match a newline following the language. + """, + re.IGNORECASE | re.VERBOSE +) + + +class CodeBlock(NamedTuple): + """Represents a Markdown code block.""" + + content: str + language: str + tick: str + + +class BadLanguage(NamedTuple): + """Parsed information about a poorly formatted language specifier.""" + + language: str + has_leading_spaces: bool + has_terminal_newline: bool + + +def find_code_blocks(message: str) -> Optional[Sequence[CodeBlock]]: + """ + Find and return all Markdown code blocks in the `message`. + + 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 + 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. + groups = match.groupdict("") + 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 None.") + return None + elif has_lines(groups["code"], constants.CodeBlock.minimum_lines): + 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. + 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): + log.trace("Code is valid python.") + 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 (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(): + # 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 (I)Python REPL code.") + return True + + log.trace("Content is not (I)Python REPL code.") + 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`. + + 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"], + 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`.""" + leading_spaces = 0 + for char in content: + if char == " ": + leading_spaces += 1 + else: + 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:] + + # 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 + # 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 diff --git a/bot/exts/info/information.py b/bot/exts/info/information.py index 0f50138e7..2d9cab94b 100644 --- a/bot/exts/info/information.py +++ b/bot/exts/info/information.py @@ -14,6 +14,7 @@ from bot import constants from bot.bot import Bot from bot.decorators import in_whitelist from bot.pagination import LinePaginator +from bot.utils.channel import is_mod_channel from bot.utils.checks import cooldown_with_role_bypass, has_no_roles_check, in_whitelist_check from bot.utils.time import time_since @@ -241,14 +242,8 @@ class Information(Cog): ), ] - # Use getattr to future-proof for commands invoked via DMs. - show_verbose = ( - ctx.channel.id in constants.MODERATION_CHANNELS - or getattr(ctx.channel, "category_id", None) == constants.Categories.modmail - ) - # Show more verbose output in moderation channels for infractions and nominations - if show_verbose: + if is_mod_channel(ctx.channel): fields.append(await self.expanded_user_infraction_counts(user)) fields.append(await self.user_nomination_counts(user)) else: diff --git a/bot/exts/info/reddit.py b/bot/exts/info/reddit.py index debe40c82..bad4c504d 100644 --- a/bot/exts/info/reddit.py +++ b/bot/exts/info/reddit.py @@ -140,7 +140,10 @@ class Reddit(Cog): # Got appropriate response - process and return. content = await response.json() posts = content["data"]["children"] - return posts[:amount] + + filtered_posts = [post for post in posts if not post["data"]["over_18"]] + + return filtered_posts[:amount] await asyncio.sleep(3) @@ -163,12 +166,11 @@ class Reddit(Cog): amount=amount, params={"t": time} ) - if not posts: embed.title = random.choice(ERROR_REPLIES) embed.colour = Colour.red() embed.description = ( - "Sorry! We couldn't find any posts from that subreddit. " + "Sorry! We couldn't find any SFW posts from that subreddit. " "If this problem persists, please let us know." ) diff --git a/bot/exts/info/stats.py b/bot/exts/info/stats.py index 21aa91873..4d8bb645e 100644 --- a/bot/exts/info/stats.py +++ b/bot/exts/info/stats.py @@ -6,7 +6,7 @@ from discord.ext.tasks import loop from bot.bot import Bot from bot.constants import Categories, Channels, Guild - +from bot.utils.channel import is_in_category CHANNEL_NAME_OVERRIDES = { Channels.off_topic_0: "off_topic_0", @@ -35,8 +35,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. diff --git a/bot/exts/moderation/infraction/_scheduler.py b/bot/exts/moderation/infraction/_scheduler.py index 814b17830..bebade0ae 100644 --- a/bot/exts/moderation/infraction/_scheduler.py +++ b/bot/exts/moderation/infraction/_scheduler.py @@ -12,11 +12,12 @@ from discord.ext.commands import Context from bot import constants from bot.api import ResponseCodeError from bot.bot import Bot -from bot.constants import Colours, MODERATION_CHANNELS +from bot.constants import Colours from bot.exts.moderation.infraction import _utils from bot.exts.moderation.infraction._utils import UserSnowflake from bot.exts.moderation.modlog import ModLog from bot.utils import messages, scheduling, time +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -125,7 +126,7 @@ class InfractionScheduler: log.error(f"Failed to DM {user.id}: could not fetch user (status {e.status})") else: # Accordingly display whether the user was successfully notified via DM. - if await _utils.notify_infraction(user, infr_type, expiry, reason, icon): + if await _utils.notify_infraction(user, " ".join(infr_type.split("_")).title(), expiry, reason, icon): dm_result = ":incoming_envelope: " dm_log_text = "\nDM: Sent" @@ -136,11 +137,7 @@ class InfractionScheduler: ) if reason: end_msg = f" (reason: {textwrap.shorten(reason, width=1500, placeholder='...')})" - elif ctx.channel.id not in MODERATION_CHANNELS: - log.trace( - f"Infraction #{id_} context is not in a mod channel; omitting infraction count." - ) - else: + elif is_mod_channel(ctx.channel): log.trace(f"Fetching total infraction count for {user}.") infractions = await self.bot.api_client.get( @@ -148,7 +145,7 @@ class InfractionScheduler: params={"user__id": str(user.id)} ) total = len(infractions) - end_msg = f" ({total} infraction{ngettext('', 's', total)} total)" + end_msg = f" (#{id_} ; {total} infraction{ngettext('', 's', total)} total)" # Execute the necessary actions to apply the infraction on Discord. if action_coro: @@ -166,7 +163,7 @@ class InfractionScheduler: log_content = ctx.author.mention log_title = "failed to apply" - log_msg = f"Failed to apply {infr_type} infraction #{id_} to {user}" + log_msg = f"Failed to apply {' '.join(infr_type.split('_'))} infraction #{id_} to {user}" if isinstance(e, discord.Forbidden): log.warning(f"{log_msg}: bot lacks permissions.") else: @@ -183,7 +180,7 @@ class InfractionScheduler: log.error(f"Deletion of {infr_type} infraction #{id_} failed with error code {e.status}.") infr_message = "" else: - infr_message = f" **{infr_type}** to {user.mention}{expiry_msg}{end_msg}" + infr_message = f" **{' '.join(infr_type.split('_'))}** to {user.mention}{expiry_msg}{end_msg}" # Send a confirmation message to the invoking context. log.trace(f"Sending infraction #{id_} confirmation message.") @@ -195,7 +192,7 @@ class InfractionScheduler: await self.mod_log.send_log_message( icon_url=icon, colour=Colours.soft_red, - title=f"Infraction {log_title}: {infr_type}", + title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", thumbnail=user.avatar_url_as(static_format="png"), text=textwrap.dedent(f""" Member: {messages.format_user(user)} @@ -272,7 +269,7 @@ class InfractionScheduler: if send_msg: log.trace(f"Sending infraction #{id_} pardon confirmation message.") await ctx.send( - f"{dm_emoji}{confirm_msg} infraction **{infr_type}** for {user.mention}. " + f"{dm_emoji}{confirm_msg} infraction **{' '.join(infr_type.split('_'))}** for {user.mention}. " f"{log_text.get('Failure', '')}" ) @@ -283,7 +280,7 @@ class InfractionScheduler: await self.mod_log.send_log_message( icon_url=_utils.INFRACTION_ICONS[infr_type][1], colour=Colours.soft_green, - title=f"Infraction {log_title}: {infr_type}", + title=f"Infraction {log_title}: {' '.join(infr_type.split('_'))}", thumbnail=user.avatar_url_as(static_format="png"), text="\n".join(f"{k}: {v}" for k, v in log_text.items()), footer=footer, diff --git a/bot/exts/moderation/infraction/_utils.py b/bot/exts/moderation/infraction/_utils.py index 1d91964f1..d0dc3f0a1 100644 --- a/bot/exts/moderation/infraction/_utils.py +++ b/bot/exts/moderation/infraction/_utils.py @@ -18,9 +18,10 @@ INFRACTION_ICONS = { "note": (Icons.user_warn, None), "superstar": (Icons.superstarify, Icons.unsuperstarify), "warning": (Icons.user_warn, None), + "voice_ban": (Icons.voice_state_red, Icons.voice_state_green), } RULES_URL = "https://pythondiscord.com/pages/rules" -APPEALABLE_INFRACTIONS = ("ban", "mute") +APPEALABLE_INFRACTIONS = ("ban", "mute", "voice_ban") # Type aliases UserObject = t.Union[discord.Member, discord.User] @@ -154,7 +155,7 @@ async def notify_infraction( log.trace(f"Sending {user} a DM about their {infr_type} infraction.") text = INFRACTION_DESCRIPTION_TEMPLATE.format( - type=infr_type.capitalize(), + type=infr_type.title(), expires=expires_at or "N/A", reason=reason or "No reason provided." ) diff --git a/bot/exts/moderation/infraction/infractions.py b/bot/exts/moderation/infraction/infractions.py index 7cf7075e6..746d4e154 100644 --- a/bot/exts/moderation/infraction/infractions.py +++ b/bot/exts/moderation/infraction/infractions.py @@ -31,6 +31,7 @@ class Infractions(InfractionScheduler, commands.Cog): self.category = "Moderation" self._muted_role = discord.Object(constants.Roles.muted) + self._voice_verified_role = discord.Object(constants.Roles.voice_verified) @commands.Cog.listener() async def on_member_join(self, member: Member) -> None: @@ -88,6 +89,11 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, max(min(purge_days, 7), 0)) + @command(aliases=('vban',)) + async def voiceban(self, ctx: Context, user: FetchedMember, *, reason: t.Optional[str]) -> None: + """Permanently ban user from using voice channels.""" + await self.apply_voice_ban(ctx, user, reason) + # endregion # region: Temporary infractions @@ -136,6 +142,32 @@ class Infractions(InfractionScheduler, commands.Cog): """ await self.apply_ban(ctx, user, reason, expires_at=duration) + @command(aliases=("tempvban", "tvban")) + async def tempvoiceban( + self, + ctx: Context, + user: FetchedMember, + duration: Expiry, + *, + reason: t.Optional[str] + ) -> None: + """ + Temporarily voice ban a user for the given reason and duration. + + A unit of time should be appended to the duration. + Units (∗case-sensitive): + \u2003`y` - years + \u2003`m` - months∗ + \u2003`w` - weeks + \u2003`d` - days + \u2003`h` - hours + \u2003`M` - minutes∗ + \u2003`s` - seconds + + Alternatively, an ISO 8601 timestamp can be provided for the duration. + """ + await self.apply_voice_ban(ctx, user, reason, expires_at=duration) + # endregion # region: Permanent shadow infractions @@ -225,6 +257,11 @@ class Infractions(InfractionScheduler, commands.Cog): """Prematurely end the active ban infraction for the user.""" await self.pardon_infraction(ctx, "ban", user) + @command(aliases=("uvban",)) + async def unvoiceban(self, ctx: Context, user: FetchedMember) -> None: + """Prematurely end the active voice ban infraction for the user.""" + await self.pardon_infraction(ctx, "voice_ban", user) + # endregion # region: Base apply functions @@ -319,6 +356,26 @@ class Infractions(InfractionScheduler, commands.Cog): bb_reason = "User has been permanently banned from the server. Automatically removed." await bb_cog.apply_unwatch(ctx, user, bb_reason, send_message=False) + @respect_role_hierarchy(member_arg=2) + async def apply_voice_ban(self, ctx: Context, user: UserSnowflake, reason: t.Optional[str], **kwargs) -> None: + """Apply a voice ban infraction with kwargs passed to `post_infraction`.""" + if await _utils.get_active_infraction(ctx, user, "voice_ban"): + return + + infraction = await _utils.post_infraction(ctx, user, "voice_ban", reason, active=True, **kwargs) + if infraction is None: + return + + self.mod_log.ignore(Event.member_update, user.id) + + if reason: + reason = textwrap.shorten(reason, width=512, placeholder="...") + + await user.move_to(None, reason="Disconnected from voice to apply voiceban.") + + action = user.remove_roles(self._voice_verified_role, reason=reason) + await self.apply_infraction(ctx, infraction, user, action) + # endregion # region: Base pardon functions @@ -363,6 +420,27 @@ class Infractions(InfractionScheduler, commands.Cog): return log_text + async def pardon_voice_ban(self, user_id: int, guild: discord.Guild, reason: t.Optional[str]) -> t.Dict[str, str]: + """Add Voice Verified role back to user, DM them a notification, and return a log dict.""" + user = guild.get_member(user_id) + log_text = {} + + if user: + # DM user about infraction expiration + notified = await _utils.notify_pardon( + user=user, + title="Voice ban ended", + content="You have been unbanned and can verify yourself again in the server.", + icon_url=_utils.INFRACTION_ICONS["voice_ban"][1] + ) + + log_text["Member"] = format_user(user) + log_text["DM"] = "Sent" if notified else "**Failed**" + else: + log_text["Info"] = "User was not found in the guild." + + return log_text + async def _pardon_action(self, infraction: _utils.Infraction) -> t.Optional[t.Dict[str, str]]: """ Execute deactivation steps specific to the infraction's type and return a log dict. @@ -377,6 +455,8 @@ class Infractions(InfractionScheduler, commands.Cog): return await self.pardon_mute(user_id, guild, reason) elif infraction["type"] == "ban": return await self.pardon_ban(user_id, guild, reason) + elif infraction["type"] == "voice_ban": + return await self.pardon_voice_ban(user_id, guild, reason) # endregion diff --git a/bot/exts/moderation/infraction/management.py b/bot/exts/moderation/infraction/management.py index cdab1a6c7..394f63da3 100644 --- a/bot/exts/moderation/infraction/management.py +++ b/bot/exts/moderation/infraction/management.py @@ -15,7 +15,7 @@ from bot.exts.moderation.infraction.infractions import Infractions from bot.exts.moderation.modlog import ModLog from bot.pagination import LinePaginator from bot.utils import messages, time -from bot.utils.checks import in_whitelist_check +from bot.utils.channel import is_mod_channel log = logging.getLogger(__name__) @@ -295,13 +295,7 @@ class ModManagement(commands.Cog): """Only allow moderators inside moderator channels to invoke the commands in this cog.""" checks = [ await commands.has_any_role(*constants.MODERATION_ROLES).predicate(ctx), - in_whitelist_check( - ctx, - channels=constants.MODERATION_CHANNELS, - categories=[constants.Categories.modmail], - redirect=None, - fail_silently=True, - ) + is_mod_channel(ctx.channel) ] return all(checks) diff --git a/bot/exts/moderation/verification.py b/bot/exts/moderation/verification.py index c3ad8687e..c599156d0 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": "Verification kick", + "type": "note", + "user": member.id, + } + + 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: + 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. @@ -373,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) @@ -547,6 +571,16 @@ class Verification(Cog): # video. if raw_member.get("is_pending"): await self.member_gating_cache.set(member.id, True) + + # TODO: Temporary, remove soon after asking joe. + await self.mod_log.send_log_message( + icon_url=self.bot.user.avatar_url, + colour=discord.Colour.blurple(), + title="New native gated user", + channel_id=constants.Channels.user_log, + text=f"<@{member.id}> ({member.id})", + ) + return log.trace(f"Sending on join message to new member: {member.id}") diff --git a/bot/exts/moderation/voice_gate.py b/bot/exts/moderation/voice_gate.py new file mode 100644 index 000000000..c2743e136 --- /dev/null +++ b/bot/exts/moderation/voice_gate.py @@ -0,0 +1,168 @@ +import asyncio +import logging +from contextlib import suppress +from datetime import datetime, timedelta + +import discord +from dateutil import parser +from discord import Colour +from discord.ext.commands import Cog, Context, command + +from bot.api import ResponseCodeError +from bot.bot import Bot +from bot.constants import Channels, Event, MODERATION_ROLES, Roles, VoiceGate as GateConf +from bot.decorators import has_no_roles, in_whitelist +from bot.exts.moderation.modlog import ModLog +from bot.utils.checks import InWhitelistCheckFailure + +log = logging.getLogger(__name__) + +FAILED_MESSAGE = ( + """You are not currently eligible to use voice inside Python Discord for the following reasons:\n\n{reasons}""" +) + +MESSAGE_FIELD_MAP = { + "verified_at": f"have been verified for less than {GateConf.minimum_days_verified} days", + "voice_banned": "have an active voice ban infraction", + "total_messages": f"have sent less than {GateConf.minimum_messages} messages", +} + + +class VoiceGate(Cog): + """Voice channels verification management.""" + + def __init__(self, bot: Bot): + self.bot = bot + + @property + def mod_log(self) -> ModLog: + """Get the currently loaded ModLog cog instance.""" + return self.bot.get_cog("ModLog") + + @command(aliases=('voiceverify',)) + @has_no_roles(Roles.voice_verified) + @in_whitelist(channels=(Channels.voice_gate,), redirect=None) + async def voice_verify(self, ctx: Context, *_) -> None: + """ + Apply to be able to use voice within the Discord server. + + In order to use voice you must meet all three of the following criteria: + - You must have over a certain number of messages within the Discord server + - You must have accepted our rules over a certain number of days ago + - You must not be actively banned from using our voice channels + """ + try: + data = await self.bot.api_client.get(f"bot/users/{ctx.author.id}/metricity_data") + except ResponseCodeError as e: + if e.status == 404: + embed = discord.Embed( + title="Not found", + description=( + "We were unable to find user data for you. " + "Please try again shortly, " + "if this problem persists please contact the server staff through Modmail.", + ), + color=Colour.red() + ) + log.info(f"Unable to find Metricity data about {ctx.author} ({ctx.author.id})") + else: + embed = discord.Embed( + title="Unexpected response", + description=( + "We encountered an error while attempting to find data for your user. " + "Please try again and let us know if the problem persists." + ), + color=Colour.red() + ) + log.warning(f"Got response code {e.status} while trying to get {ctx.author.id} Metricity data.") + + await ctx.author.send(embed=embed) + return + + # Pre-parse this for better code style + if data["verified_at"] is not None: + data["verified_at"] = parser.isoparse(data["verified_at"]) + else: + data["verified_at"] = datetime.utcnow() - timedelta(days=3) + + checks = { + "verified_at": data["verified_at"] > datetime.utcnow() - timedelta(days=GateConf.minimum_days_verified), + "total_messages": data["total_messages"] < GateConf.minimum_messages, + "voice_banned": data["voice_banned"] + } + failed = any(checks.values()) + failed_reasons = [MESSAGE_FIELD_MAP[key] for key, value in checks.items() if value is True] + [self.bot.stats.incr(f"voice_gate.failed.{key}") for key, value in checks.items() if value is True] + + if failed: + embed = discord.Embed( + title="Voice Gate failed", + description=FAILED_MESSAGE.format(reasons="\n".join(f'• You {reason}.' for reason in failed_reasons)), + color=Colour.red() + ) + try: + await ctx.author.send(embed=embed) + await ctx.send(f"{ctx.author}, please check your DMs.") + except discord.Forbidden: + await ctx.channel.send(ctx.author.mention, embed=embed) + return + + self.mod_log.ignore(Event.member_update, ctx.author.id) + embed = discord.Embed( + title="Voice gate passed", + description="You have been granted permission to use voice channels in Python Discord.", + color=Colour.green() + ) + + if ctx.author.voice: + embed.description += "\n\nPlease reconnect to your voice channel to be granted your new permissions." + + try: + await ctx.author.send(embed=embed) + await ctx.send(f"{ctx.author}, please check your DMs.") + except discord.Forbidden: + await ctx.channel.send(ctx.author.mention, embed=embed) + + # wait a little bit so those who don't get DMs see the response in-channel before losing perms to see it. + await asyncio.sleep(3) + await ctx.author.add_roles(discord.Object(Roles.voice_verified), reason="Voice Gate passed") + + self.bot.stats.incr("voice_gate.passed") + + @Cog.listener() + async def on_message(self, message: discord.Message) -> None: + """Delete all non-staff messages from voice gate channel that don't invoke voice verify command.""" + # Check is channel voice gate + if message.channel.id != Channels.voice_gate: + return + + ctx = await self.bot.get_context(message) + is_verify_command = ctx.command is not None and ctx.command.name == "voice_verify" + + # When it's bot sent message, delete it after some time + if message.author.bot: + with suppress(discord.NotFound): + await message.delete(delay=GateConf.bot_message_delete_delay) + return + + # Then check is member moderator+, because we don't want to delete their messages. + if any(role.id in MODERATION_ROLES for role in message.author.roles) and is_verify_command is False: + log.trace(f"Excluding moderator message {message.id} from deletion in #{message.channel}.") + return + + # Ignore deleted voice verification messages + if ctx.command is not None and ctx.command.name == "voice_verify": + self.mod_log.ignore(Event.message_delete, message.id) + + with suppress(discord.NotFound): + await message.delete() + + async def cog_command_error(self, ctx: Context, error: Exception) -> None: + """Check for & ignore any InWhitelistCheckFailure.""" + if isinstance(error, InWhitelistCheckFailure): + error.handled = True + + +def setup(bot: Bot) -> None: + """Loads the VoiceGate cog.""" + bot.add_cog(VoiceGate(bot)) diff --git a/bot/exts/utils/bot.py b/bot/exts/utils/bot.py index ba1fd2a5c..69d623581 100644 --- a/bot/exts/utils/bot.py +++ b/bot/exts/utils/bot.py @@ -1,22 +1,14 @@ -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, has_any_role from bot.bot import Bot -from bot.constants import Categories, Channels, DEBUG_MODE, Guild, MODERATION_ROLES, Roles, URLs -from bot.exts.filters.token_remover import TokenRemover -from bot.exts.filters.webhook_remover import WEBHOOK_URL_RE -from bot.utils.messages import wait_for_deletion +from bot.constants import Guild, MODERATION_ROLES, Roles, URLs log = logging.getLogger(__name__) -RE_MARKDOWN = re.compile(r'([*_~`|>])') - class BotCog(Cog, name="Bot"): """Bot information commands.""" @@ -24,19 +16,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) @has_any_role(Roles.verified) async def botinfo_group(self, ctx: Context) -> None: @@ -81,305 +60,6 @@ class BotCog(Cog, name="Bot"): else: await channel.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 necessary - # if the first line of the message looked like ```python <code> - 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) - and not WEBHOOK_URL_RE.search(msg.content) - ) - - 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, (msg.author.id,), 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/exts/utils/snekbox.py b/bot/exts/utils/snekbox.py index ca6fbf5cb..cad451571 100644 --- a/bot/exts/utils/snekbox.py +++ b/bot/exts/utils/snekbox.py @@ -38,10 +38,10 @@ 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) +EVAL_CHANNELS = (Channels.bot_commands, Channels.esoteric, Channels.code_help_voice, Channels.code_help_voice_2) EVAL_CATEGORIES = (Categories.help_available, Categories.help_in_use) EVAL_ROLES = (Roles.helpers, Roles.moderators, Roles.admins, Roles.owners, Roles.python_community, Roles.partners) diff --git a/bot/utils/__init__.py b/bot/utils/__init__.py index 60170a88f..13533a467 100644 --- a/bot/utils/__init__.py +++ b/bot/utils/__init__.py @@ -1,4 +1,4 @@ -from bot.utils.helpers import CogABCMeta, find_nth_occurrence, pad_base64 +from bot.utils.helpers import CogABCMeta, find_nth_occurrence, has_lines, pad_base64 from bot.utils.services import send_to_paste_service -__all__ = ['CogABCMeta', 'find_nth_occurrence', 'pad_base64', 'send_to_paste_service'] +__all__ = ['CogABCMeta', 'find_nth_occurrence', 'has_lines', 'pad_base64', 'send_to_paste_service'] diff --git a/bot/utils/channel.py b/bot/utils/channel.py new file mode 100644 index 000000000..6bf70bfde --- /dev/null +++ b/bot/utils/channel.py @@ -0,0 +1,49 @@ +import logging + +import discord + +from bot import constants +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_mod_channel(channel: discord.TextChannel) -> bool: + """True if `channel` is considered a mod channel.""" + if channel.id in constants.MODERATION_CHANNELS: + log.trace(f"Channel #{channel} is a configured mod channel") + return True + + elif any(is_in_category(channel, category) for category in constants.MODERATION_CATEGORIES): + log.trace(f"Channel #{channel} is in a configured mod category") + return True + + else: + log.trace(f"Channel #{channel} is not a mod channel") + return False + + +def is_in_category(channel: discord.TextChannel, category_id: int) -> bool: + """Return True if `channel` is within a category with `category_id`.""" + return getattr(channel, "category_id", None) == 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 diff --git a/bot/utils/helpers.py b/bot/utils/helpers.py index d9b60af07..3501a3933 100644 --- a/bot/utils/helpers.py +++ b/bot/utils/helpers.py @@ -18,6 +18,15 @@ def find_nth_occurrence(string: str, substring: str, n: int) -> Optional[int]: return index +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. + 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) diff --git a/config-default.yml b/config-default.yml index 4f7b1e217..98c5ff42c 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" @@ -127,7 +128,8 @@ guild: help_available: 691405807388196926 help_in_use: 696958401460043776 help_dormant: 691405908919451718 - modmail: 714494672835444826 + modmail: &MODMAIL 714494672835444826 + logs: &LOGS 468520609152892958 channels: # Public announcement and news channels @@ -145,8 +147,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 @@ -169,6 +171,7 @@ guild: bot_commands: &BOT_CMD 267659945086812160 esoteric: 470884583684964352 verification: 352442727016693763 + voice_gate: 764802555427029012 # Staff admins: &ADMINS 365960823622991872 @@ -178,7 +181,7 @@ guild: incidents: 714214212200562749 incidents_archive: 720668923636351037 mods: &MODS 305126844661760000 - mod_alerts: &MOD_ALERTS 473092532147060736 + mod_alerts: 473092532147060736 mod_spam: &MOD_SPAM 620607373828030464 organisation: &ORGANISATION 551789653284356126 staff_lounge: &STAFF_LOUNGE 464905259261755392 @@ -191,6 +194,7 @@ guild: # Voice code_help_voice: 755154969761677312 + code_help_voice_2: 766330079135268884 admins_voice: &ADMINS_VOICE 500734494840717332 staff_voice: &STAFF_VOICE 412375055910043655 @@ -198,10 +202,13 @@ guild: big_brother_logs: &BB_LOGS 468507907357409333 talent_pool: &TALENT_POOL 534321732593647616 + moderation_categories: + - *MODMAIL + - *LOGS + moderation_channels: - *ADMINS - *ADMIN_SPAM - - *MOD_ALERTS - *MODS - *MOD_SPAM @@ -225,9 +232,11 @@ guild: muted: &MUTED_ROLE 277914926603829249 partners: 323426753857191936 python_community: &PY_COMMUNITY_ROLE 458226413825294336 + sprinters: &SPRINTERS 758422482289426471 unverified: 739794855945044069 verified: 352427296948486144 # @Developers on PyDis + voice_verified: 764802720779337729 # Staff admins: &ADMINS_ROLE 267628507062992896 @@ -261,6 +270,7 @@ guild: reddit: 635408384794951680 talent_pool: 569145364800602132 + filter: # What do we filter? filter_zalgo: false @@ -298,6 +308,7 @@ filter: - *OWNERS_ROLE - *HELPERS_ROLE - *PY_COMMUNITY_ROLE + - *SPRINTERS keys: @@ -326,6 +337,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 @@ -394,6 +406,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. @@ -442,10 +471,12 @@ help_channels: notify_roles: - *HELPERS_ROLE + redirect_output: delete_invocation: true delete_delay: 15 + duck_pond: threshold: 4 channel_blacklist: @@ -461,11 +492,13 @@ duck_pond: - *MOD_ANNOUNCEMENTS - *ADMIN_ANNOUNCEMENTS + python_news: mail_lists: - 'python-ideas' - 'python-announce-list' - 'pypi-announce' + - 'python-dev' channel: *PYNEWS_CHANNEL webhook: *PYNEWS_WEBHOOK @@ -482,5 +515,11 @@ verification: kick_confirmation_threshold: 0.01 # 1% +voice_gate: + minimum_days_verified: 3 # How many days the user must have been verified for + minimum_messages: 50 # How many messages a user must have to be eligible for voice + bot_message_delete_delay: 10 # Seconds before deleting bot's response in Voice Gate + + config: required_keys: ['bot.token'] diff --git a/docker-compose.yml b/docker-compose.yml index cff7d33d6..8be5aac0e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: - postgres environment: DATABASE_URL: postgres://pysite:pysite@postgres:5432/pysite + METRICITY_DB_URL: postgres://pysite:pysite@postgres:5432/metricity SECRET_KEY: suitable-for-development-only STATIC_ROOT: /var/www/static diff --git a/tests/bot/exts/moderation/infraction/test_infractions.py b/tests/bot/exts/moderation/infraction/test_infractions.py index be1b649e1..bf557a484 100644 --- a/tests/bot/exts/moderation/infraction/test_infractions.py +++ b/tests/bot/exts/moderation/infraction/test_infractions.py @@ -1,7 +1,8 @@ import textwrap import unittest -from unittest.mock import AsyncMock, Mock, patch +from unittest.mock import AsyncMock, MagicMock, Mock, patch +from bot.constants import Event from bot.exts.moderation.infraction.infractions import Infractions from tests.helpers import MockBot, MockContext, MockGuild, MockMember, MockRole @@ -53,3 +54,148 @@ class TruncationTests(unittest.IsolatedAsyncioTestCase): self.cog.apply_infraction.assert_awaited_once_with( self.ctx, {"foo": "bar"}, self.target, self.target.kick.return_value ) + + +@patch("bot.exts.moderation.infraction.infractions.constants.Roles.voice_verified", new=123456) +class VoiceBanTests(unittest.IsolatedAsyncioTestCase): + """Tests for voice ban related functions and commands.""" + + def setUp(self): + self.bot = MockBot() + self.mod = MockMember(top_role=10) + self.user = MockMember(top_role=1, roles=[MockRole(id=123456)]) + self.guild = MockGuild() + self.ctx = MockContext(bot=self.bot, author=self.mod) + self.cog = Infractions(self.bot) + + async def test_permanent_voice_ban(self): + """Should call voice ban applying function without expiry.""" + self.cog.apply_voice_ban = AsyncMock() + self.assertIsNone(await self.cog.voiceban(self.cog, self.ctx, self.user, reason="foobar")) + self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar") + + async def test_temporary_voice_ban(self): + """Should call voice ban applying function with expiry.""" + self.cog.apply_voice_ban = AsyncMock() + self.assertIsNone(await self.cog.tempvoiceban(self.cog, self.ctx, self.user, "baz", reason="foobar")) + self.cog.apply_voice_ban.assert_awaited_once_with(self.ctx, self.user, "foobar", expires_at="baz") + + async def test_voice_unban(self): + """Should call infraction pardoning function.""" + self.cog.pardon_infraction = AsyncMock() + self.assertIsNone(await self.cog.unvoiceban(self.cog, self.ctx, self.user)) + self.cog.pardon_infraction.assert_awaited_once_with(self.ctx, "voice_ban", self.user) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_user_have_active_infraction(self, get_active_infraction, post_infraction_mock): + """Should return early when user already have Voice Ban infraction.""" + get_active_infraction.return_value = {"foo": "bar"} + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + get_active_infraction.assert_awaited_once_with(self.ctx, self.user, "voice_ban") + post_infraction_mock.assert_not_awaited() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_infraction_post_failed(self, get_active_infraction, post_infraction_mock): + """Should return early when posting infraction fails.""" + self.cog.mod_log.ignore = MagicMock() + get_active_infraction.return_value = None + post_infraction_mock.return_value = None + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + post_infraction_mock.assert_awaited_once() + self.cog.mod_log.ignore.assert_not_called() + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_infraction_post_add_kwargs(self, get_active_infraction, post_infraction_mock): + """Should pass all kwargs passed to apply_voice_ban to post_infraction.""" + get_active_infraction.return_value = None + # We don't want that this continue yet + post_infraction_mock.return_value = None + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar", my_kwarg=23)) + post_infraction_mock.assert_awaited_once_with( + self.ctx, self.user, "voice_ban", "foobar", active=True, my_kwarg=23 + ) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_mod_log_ignore(self, get_active_infraction, post_infraction_mock): + """Should ignore Voice Verified role removing.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.cog.mod_log.ignore.assert_called_once_with(Event.member_update, self.user.id) + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_apply_infraction(self, get_active_infraction, post_infraction_mock): + """Should ignore Voice Verified role removing.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar")) + self.user.remove_roles.assert_called_once_with(self.cog._voice_verified_role, reason="foobar") + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + + @patch("bot.exts.moderation.infraction.infractions._utils.post_infraction") + @patch("bot.exts.moderation.infraction.infractions._utils.get_active_infraction") + async def test_voice_ban_truncate_reason(self, get_active_infraction, post_infraction_mock): + """Should truncate reason for voice ban.""" + self.cog.mod_log.ignore = MagicMock() + self.cog.apply_infraction = AsyncMock() + self.user.remove_roles = MagicMock(return_value="my_return_value") + + get_active_infraction.return_value = None + post_infraction_mock.return_value = {"foo": "bar"} + + self.assertIsNone(await self.cog.apply_voice_ban(self.ctx, self.user, "foobar" * 3000)) + self.user.remove_roles.assert_called_once_with( + self.cog._voice_verified_role, reason=textwrap.shorten("foobar" * 3000, 512, placeholder="...") + ) + self.cog.apply_infraction.assert_awaited_once_with(self.ctx, {"foo": "bar"}, self.user, "my_return_value") + + async def test_voice_unban_user_not_found(self): + """Should include info to return dict when user was not found from guild.""" + self.guild.get_member.return_value = None + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, {"Info": "User was not found in the guild."}) + + @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") + @patch("bot.exts.moderation.infraction.infractions.format_user") + async def test_voice_unban_user_found(self, format_user_mock, notify_pardon_mock): + """Should add role back with ignoring, notify user and return log dictionary..""" + self.guild.get_member.return_value = self.user + notify_pardon_mock.return_value = True + format_user_mock.return_value = "my-user" + + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, { + "Member": "my-user", + "DM": "Sent" + }) + notify_pardon_mock.assert_awaited_once() + + @patch("bot.exts.moderation.infraction.infractions._utils.notify_pardon") + @patch("bot.exts.moderation.infraction.infractions.format_user") + async def test_voice_unban_dm_fail(self, format_user_mock, notify_pardon_mock): + """Should add role back with ignoring, notify user and return log dictionary..""" + self.guild.get_member.return_value = self.user + notify_pardon_mock.return_value = False + format_user_mock.return_value = "my-user" + + result = await self.cog.pardon_voice_ban(self.user.id, self.guild, "foobar") + self.assertEqual(result, { + "Member": "my-user", + "DM": "**Failed**" + }) + notify_pardon_mock.assert_awaited_once() |