aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--LICENSE-THIRD-PARTY36
-rw-r--r--bot/__main__.py2
-rw-r--r--bot/constants.py25
-rw-r--r--bot/exts/filters/antimalware.py4
-rw-r--r--bot/exts/filters/antispam.py3
-rw-r--r--bot/exts/fun/duck_pond.py4
-rw-r--r--bot/exts/help_channels.py45
-rw-r--r--bot/exts/info/codeblock/__init__.py8
-rw-r--r--bot/exts/info/codeblock/_cog.py186
-rw-r--r--bot/exts/info/codeblock/_instructions.py184
-rw-r--r--bot/exts/info/codeblock/_parsing.py228
-rw-r--r--bot/exts/info/information.py9
-rw-r--r--bot/exts/info/reddit.py8
-rw-r--r--bot/exts/info/stats.py5
-rw-r--r--bot/exts/moderation/infraction/_scheduler.py23
-rw-r--r--bot/exts/moderation/infraction/_utils.py5
-rw-r--r--bot/exts/moderation/infraction/infractions.py80
-rw-r--r--bot/exts/moderation/infraction/management.py10
-rw-r--r--bot/exts/moderation/verification.py34
-rw-r--r--bot/exts/moderation/voice_gate.py168
-rw-r--r--bot/exts/utils/bot.py326
-rw-r--r--bot/exts/utils/snekbox.py4
-rw-r--r--bot/utils/__init__.py4
-rw-r--r--bot/utils/channel.py49
-rw-r--r--bot/utils/helpers.py9
-rw-r--r--config-default.yml49
-rw-r--r--docker-compose.yml1
-rw-r--r--tests/bot/exts/moderation/infraction/test_infractions.py148
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()